Compare commits

..

9 Commits

Author SHA1 Message Date
acb12b946e fix: add missing migration for anonymousMode column
All checks were successful
Build, Push & Deploy / deploy (push) Successful in 7m33s
The column was added to the schema but no migration was created,
causing a PrismaClientKnownRequestError in production.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 20:39:37 +01:00
d9ffacc124 fix: prevent second page flicker in double page mode when image is already loaded
All checks were successful
Build, Push & Deploy / deploy (push) Successful in 5m15s
Skip resetting loading state to true when the blob URL already exists,
avoiding an unnecessary opacity-0 → opacity-100 CSS transition.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:33:38 +01:00
8cdbebaafb fix: prevent returning to previous book when exiting reader after auto-advance
Some checks failed
Build, Push & Deploy / deploy (push) Has been cancelled
Use router.replace instead of router.push when auto-advancing to next book,
so closing the reader navigates back to the series view instead of the previous book.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 10:29:39 +01:00
c5da33d6b2 feat: add anonymous mode toggle to hide reading progress and tracking
Adds a toggleable anonymous mode (eye icon in header) that:
- Stops syncing read progress to the server while reading
- Hides mark as read/unread buttons on book covers and lists
- Hides reading status badges on series and books
- Hides progress bars on series and book covers
- Hides "continue reading" and "continue series" sections on home
- Persists the setting server-side in user preferences (anonymousMode)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:35:22 +01:00
a82ce024ee feat: display missing books count badge on series covers
Show an orange badge with BookX icon on series covers when the Stripstream
API reports missing books in the collection. Also display a warning status
badge on the series detail page header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:17:53 +01:00
f48d894eca feat: add show more/less toggle for series description
Allow users to expand long series descriptions with a "Show more" button
and scroll through the full text, instead of being limited to 3 lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:11:05 +01:00
a1a986f462 fix: regenerate splash screens from new artwork and add missing device support
All checks were successful
Build, Push & Deploy / deploy (push) Successful in 5m23s
Use Gemini-generated dark artwork as splash source instead of stretched logo.
Add missing media queries for iPad Mini 6, iPad Pro M4 11"/13", iPhone 16 Pro/Pro Max.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:05:15 +01:00
894ea7114c fix: show spinner instead of broken image icon while loading reader pages
All checks were successful
Build, Push & Deploy / deploy (push) Successful in 4m25s
Only render <img> when blob URL is available from prefetch, preventing
broken image icons from src={undefined} or failed direct URL fallbacks.
Reset error state when blob URL arrives to recover from transient failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 13:25:42 +01:00
32757a8723 feat: display series metadata (authors, description) from Stripstream API
All checks were successful
Build, Push & Deploy / deploy (push) Successful in 4m14s
Fetch metadata from GET /libraries/{id}/series/{name}/metadata and display
authors with icon and description in the series header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 21:11:55 +01:00
67 changed files with 406 additions and 140 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "preferences" ADD COLUMN "anonymousMode" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -64,6 +64,7 @@ model Preferences {
displayMode Json displayMode Json
background Json background Json
readerPrefetchCount Int @default(5) readerPrefetchCount Int @default(5)
anonymousMode Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

After

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 MiB

After

Width:  |  Height:  |  Size: 6.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

After

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 MiB

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

After

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 MiB

After

Width:  |  Height:  |  Size: 6.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@@ -9,6 +9,9 @@ const screenshotsDir = path.join(__dirname, "../public/images/screenshots");
const splashDir = path.join(__dirname, "../public/images/splash"); const splashDir = path.join(__dirname, "../public/images/splash");
const faviconPath = path.join(__dirname, "../public/favicon.png"); const faviconPath = path.join(__dirname, "../public/favicon.png");
// Source pour les splash screens
const splashSource = path.join(__dirname, "../public/images/Gemini_Generated_Image_wyfsoiwyfsoiwyfs.png");
// Configuration des splashscreens pour différents appareils // Configuration des splashscreens pour différents appareils
const splashScreens = [ const splashScreens = [
// iPad (portrait + landscape) // iPad (portrait + landscape)
@@ -16,8 +19,14 @@ const splashScreens = [
{ width: 2732, height: 2048, name: "iPad Pro 12.9 landscape" }, { width: 2732, height: 2048, name: "iPad Pro 12.9 landscape" },
{ width: 1668, height: 2388, name: "iPad Pro 11 portrait" }, { width: 1668, height: 2388, name: "iPad Pro 11 portrait" },
{ width: 2388, height: 1668, name: "iPad Pro 11 landscape" }, { width: 2388, height: 1668, name: "iPad Pro 11 landscape" },
{ width: 1668, height: 2420, name: "iPad Pro 11 M4 portrait" },
{ width: 2420, height: 1668, name: "iPad Pro 11 M4 landscape" },
{ width: 2064, height: 2752, name: "iPad Pro 13 M4 portrait" },
{ width: 2752, height: 2064, name: "iPad Pro 13 M4 landscape" },
{ width: 1536, height: 2048, name: "iPad Mini/Air portrait" }, { width: 1536, height: 2048, name: "iPad Mini/Air portrait" },
{ width: 2048, height: 1536, name: "iPad Mini/Air landscape" }, { width: 2048, height: 1536, name: "iPad Mini/Air landscape" },
{ width: 1488, height: 2266, name: "iPad Mini 6 portrait" },
{ width: 2266, height: 1488, name: "iPad Mini 6 landscape" },
{ width: 1620, height: 2160, name: "iPad 10.2 portrait" }, { width: 1620, height: 2160, name: "iPad 10.2 portrait" },
{ width: 2160, height: 1620, name: "iPad 10.2 landscape" }, { width: 2160, height: 1620, name: "iPad 10.2 landscape" },
{ width: 1640, height: 2360, name: "iPad Air 10.9 portrait" }, { width: 1640, height: 2360, name: "iPad Air 10.9 portrait" },
@@ -40,39 +49,36 @@ const splashScreens = [
{ width: 2532, height: 1170, name: "iPhone 12/13/14 landscape" }, { width: 2532, height: 1170, name: "iPhone 12/13/14 landscape" },
{ width: 1284, height: 2778, name: "iPhone 12/13/14 Pro Max portrait" }, { width: 1284, height: 2778, name: "iPhone 12/13/14 Pro Max portrait" },
{ width: 2778, height: 1284, name: "iPhone 12/13/14 Pro Max landscape" }, { width: 2778, height: 1284, name: "iPhone 12/13/14 Pro Max landscape" },
{ width: 1179, height: 2556, name: "iPhone 14 Pro portrait" }, { width: 1179, height: 2556, name: "iPhone 14 Pro/15 portrait" },
{ width: 2556, height: 1179, name: "iPhone 14 Pro landscape" }, { width: 2556, height: 1179, name: "iPhone 14 Pro/15 landscape" },
{ width: 1290, height: 2796, name: "iPhone 14/15 Pro Max portrait" }, { width: 1290, height: 2796, name: "iPhone 14/15 Pro Max portrait" },
{ width: 2796, height: 1290, name: "iPhone 14/15 Pro Max landscape" }, { width: 2796, height: 1290, name: "iPhone 14/15 Pro Max landscape" },
{ width: 1179, height: 2556, name: "iPhone 15 portrait" }, { width: 1206, height: 2622, name: "iPhone 16 Pro portrait" },
{ width: 2556, height: 1179, name: "iPhone 15 landscape" }, { width: 2622, height: 1206, name: "iPhone 16 Pro landscape" },
{ width: 1320, height: 2868, name: "iPhone 16 Pro Max portrait" },
{ width: 2868, height: 1320, name: "iPhone 16 Pro Max landscape" },
{ width: 1170, height: 2532, name: "iPhone 16/16e portrait" }, { width: 1170, height: 2532, name: "iPhone 16/16e portrait" },
{ width: 2532, height: 1170, name: "iPhone 16/16e landscape" }, { width: 2532, height: 1170, name: "iPhone 16/16e landscape" },
]; ];
async function generateSplashScreens() { async function generateSplashScreens() {
await fs.mkdir(splashDir, { recursive: true }); await fs.mkdir(splashDir, { recursive: true });
console.log(`\n📱 Génération des splash screens...`);
for (const screen of splashScreens) { for (const screen of splashScreens) {
const outputPath = path.join(splashDir, `splash-${screen.width}x${screen.height}.png`); const outputPath = path.join(splashDir, `splash-${screen.width}x${screen.height}.png`);
const darkOverlay = Buffer.from(
`<svg width="${screen.width}" height="${screen.height}" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="rgba(4, 8, 20, 0.22)" />
</svg>`
);
await sharp(sourceLogo) await sharp(splashSource)
.resize(screen.width, screen.height, { .resize(screen.width, screen.height, {
fit: "cover", fit: "cover",
position: "center", position: "center",
}) })
.composite([{ input: darkOverlay, blend: "over" }])
.png({ .png({
compressionLevel: 9, compressionLevel: 9,
}) })
.toFile(outputPath); .toFile(outputPath);
console.log(` Splashscreen ${screen.name} (${screen.width}x${screen.height}) générée`); console.log(` ${screen.name} (${screen.width}x${screen.height})`);
} }
} }

View File

@@ -5,6 +5,7 @@ import { cn } from "@/lib/utils";
import ClientLayout from "@/components/layout/ClientLayout"; import ClientLayout from "@/components/layout/ClientLayout";
import { PreferencesService } from "@/lib/services/preferences.service"; import { PreferencesService } from "@/lib/services/preferences.service";
import { PreferencesProvider } from "@/contexts/PreferencesContext"; import { PreferencesProvider } from "@/contexts/PreferencesContext";
import { AnonymousProvider } from "@/contexts/AnonymousContext";
import { I18nProvider } from "@/components/providers/I18nProvider"; import { I18nProvider } from "@/components/providers/I18nProvider";
import { AuthProvider } from "@/components/providers/AuthProvider"; import { AuthProvider } from "@/components/providers/AuthProvider";
import { cookies, headers } from "next/headers"; import { cookies, headers } from "next/headers";
@@ -248,6 +249,61 @@ export default async function RootLayout({ children }: { children: React.ReactNo
href="/images/splash/splash-2796x1290.png" href="/images/splash/splash-2796x1290.png"
media="(device-width: 932px) and (device-height: 430px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" media="(device-width: 932px) and (device-height: 430px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/> />
{/* iPad Mini 6 */}
<link
rel="apple-touch-startup-image"
href="/images/splash/splash-1488x2266.png"
media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/images/splash/splash-2266x1488.png"
media="(device-width: 1133px) and (device-height: 744px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
{/* iPad Pro 11" M4 */}
<link
rel="apple-touch-startup-image"
href="/images/splash/splash-1668x2420.png"
media="(device-width: 834px) and (device-height: 1210px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/images/splash/splash-2420x1668.png"
media="(device-width: 1210px) and (device-height: 834px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
{/* iPad Pro 13" M4 */}
<link
rel="apple-touch-startup-image"
href="/images/splash/splash-2064x2752.png"
media="(device-width: 1032px) and (device-height: 1376px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/images/splash/splash-2752x2064.png"
media="(device-width: 1376px) and (device-height: 1032px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
{/* iPhone 16 Pro */}
<link
rel="apple-touch-startup-image"
href="/images/splash/splash-1206x2622.png"
media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/images/splash/splash-2622x1206.png"
media="(device-width: 874px) and (device-height: 402px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
{/* iPhone 16 Pro Max */}
<link
rel="apple-touch-startup-image"
href="/images/splash/splash-1320x2868.png"
media="(device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/images/splash/splash-2868x1320.png"
media="(device-width: 956px) and (device-height: 440px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
</head> </head>
<body <body
className={cn( className={cn(
@@ -258,13 +314,15 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<AuthProvider> <AuthProvider>
<I18nProvider locale={locale}> <I18nProvider locale={locale}>
<PreferencesProvider initialPreferences={preferences}> <PreferencesProvider initialPreferences={preferences}>
<ClientLayout <AnonymousProvider>
initialLibraries={libraries} <ClientLayout
initialFavorites={favorites} initialLibraries={libraries}
userIsAdmin={userIsAdmin} initialFavorites={favorites}
> userIsAdmin={userIsAdmin}
{children} >
</ClientLayout> {children}
</ClientLayout>
</AnonymousProvider>
</PreferencesProvider> </PreferencesProvider>
</I18nProvider> </I18nProvider>
</AuthProvider> </AuthProvider>

View File

@@ -5,6 +5,7 @@ import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { ERROR_CODES } from "@/constants/errorCodes"; import { ERROR_CODES } from "@/constants/errorCodes";
import { AppError } from "@/utils/errors"; import { AppError } from "@/utils/errors";
import { FavoritesService } from "@/lib/services/favorites.service"; import { FavoritesService } from "@/lib/services/favorites.service";
import { PreferencesService } from "@/lib/services/preferences.service";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export default async function HomePage() { export default async function HomePage() {
@@ -12,16 +13,17 @@ export default async function HomePage() {
const provider = await getProvider(); const provider = await getProvider();
if (!provider) redirect("/settings"); if (!provider) redirect("/settings");
const [homeData, favorites] = await Promise.all([ const [homeData, favorites, preferences] = await Promise.all([
provider.getHomeData(), provider.getHomeData(),
FavoritesService.getFavorites(), FavoritesService.getFavorites(),
PreferencesService.getPreferences().catch(() => null),
]); ]);
const data = { ...homeData, favorites }; const data = { ...homeData, favorites };
return ( return (
<HomeClientWrapper> <HomeClientWrapper>
<HomeContent data={data} /> <HomeContent data={data} isAnonymous={preferences?.anonymousMode ?? false} />
</HomeClientWrapper> </HomeClientWrapper>
); );
} catch (error) { } catch (error) {

View File

@@ -3,9 +3,10 @@ import type { HomeData } from "@/types/home";
interface HomeContentProps { interface HomeContentProps {
data: HomeData; data: HomeData;
isAnonymous?: boolean;
} }
export function HomeContent({ data }: HomeContentProps) { export function HomeContent({ data, isAnonymous = false }: HomeContentProps) {
// Merge onDeck (next unread per series) and ongoingBooks (currently reading), // Merge onDeck (next unread per series) and ongoingBooks (currently reading),
// deduplicate by id, onDeck first // deduplicate by id, onDeck first
const continueReading = (() => { const continueReading = (() => {
@@ -20,7 +21,7 @@ export function HomeContent({ data }: HomeContentProps) {
return ( return (
<div className="space-y-10 pb-2"> <div className="space-y-10 pb-2">
{continueReading.length > 0 && ( {!isAnonymous && continueReading.length > 0 && (
<MediaRow <MediaRow
titleKey="home.sections.continue_reading" titleKey="home.sections.continue_reading"
items={continueReading} items={continueReading}
@@ -29,7 +30,7 @@ export function HomeContent({ data }: HomeContentProps) {
/> />
)} )}
{data.ongoing && data.ongoing.length > 0 && ( {!isAnonymous && data.ongoing && data.ongoing.length > 0 && (
<MediaRow <MediaRow
titleKey="home.sections.continue_series" titleKey="home.sections.continue_series"
items={data.ongoing} items={data.ongoing}

View File

@@ -11,6 +11,7 @@ import { History, Sparkles, Clock, LibraryBig, BookOpen, Heart } from "lucide-re
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus"; import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAnonymous } from "@/contexts/AnonymousContext";
interface MediaRowProps { interface MediaRowProps {
titleKey: string; titleKey: string;
@@ -78,6 +79,7 @@ interface MediaCardProps {
function MediaCard({ item, onClick }: MediaCardProps) { function MediaCard({ item, onClick }: MediaCardProps) {
const { t } = useTranslate(); const { t } = useTranslate();
const { isAnonymous } = useAnonymous();
const isSeriesItem = isSeries(item); const isSeriesItem = isSeries(item);
const { isAccessible } = useBookOfflineStatus(isSeriesItem ? "" : item.id); const { isAccessible } = useBookOfflineStatus(isSeriesItem ? "" : item.id);
@@ -105,7 +107,7 @@ function MediaCard({ item, onClick }: MediaCardProps) {
<div className="relative aspect-[2/3] bg-muted"> <div className="relative aspect-[2/3] bg-muted">
{isSeriesItem ? ( {isSeriesItem ? (
<> <>
<SeriesCover series={item} alt={`Couverture de ${title}`} /> <SeriesCover series={item} alt={`Couverture de ${title}`} isAnonymous={isAnonymous} />
<div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/75 via-black/30 to-transparent p-3 opacity-0 transition-opacity duration-200 hover:opacity-100"> <div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/75 via-black/30 to-transparent p-3 opacity-0 transition-opacity duration-200 hover:opacity-100">
<h3 className="font-medium text-sm text-white line-clamp-2">{title}</h3> <h3 className="font-medium text-sm text-white line-clamp-2">{title}</h3>
<p className="text-xs text-white/80 mt-1"> <p className="text-xs text-white/80 mt-1">

View File

@@ -1,10 +1,11 @@
import { Menu, Moon, Sun, RefreshCw, Search } from "lucide-react"; import { Menu, Moon, Sun, RefreshCw, Search, EyeOff, Eye } from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import LanguageSelector from "@/components/LanguageSelector"; import LanguageSelector from "@/components/LanguageSelector";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IconButton } from "@/components/ui/icon-button"; import { IconButton } from "@/components/ui/icon-button";
import { useState } from "react"; import { useState } from "react";
import { GlobalSearch } from "@/components/layout/GlobalSearch"; import { GlobalSearch } from "@/components/layout/GlobalSearch";
import { useAnonymous } from "@/contexts/AnonymousContext";
interface HeaderProps { interface HeaderProps {
onToggleSidebar: () => void; onToggleSidebar: () => void;
@@ -19,6 +20,7 @@ export function Header({
}: HeaderProps) { }: HeaderProps) {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
const { isAnonymous, toggleAnonymous } = useAnonymous();
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false); const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
@@ -87,6 +89,14 @@ export function Header({
className="h-9 w-9 rounded-full sm:hidden" className="h-9 w-9 rounded-full sm:hidden"
tooltip={t("header.search.placeholder")} tooltip={t("header.search.placeholder")}
/> />
<IconButton
onClick={toggleAnonymous}
variant="ghost"
size="icon"
icon={isAnonymous ? EyeOff : Eye}
className={`h-9 w-9 rounded-full ${isAnonymous ? "text-yellow-500 hover:text-yellow-400" : ""}`}
tooltip={t(isAnonymous ? "header.anonymousModeOn" : "header.anonymousModeOff")}
/>
<LanguageSelector /> <LanguageSelector />
<button <button
onClick={toggleTheme} onClick={toggleTheme}

View File

@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SeriesCover } from "@/components/ui/series-cover"; import { SeriesCover } from "@/components/ui/series-cover";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { useAnonymous } from "@/contexts/AnonymousContext";
interface SeriesGridProps { interface SeriesGridProps {
series: NormalizedSeries[]; series: NormalizedSeries[];
@@ -49,6 +50,7 @@ const getReadingStatusInfo = (
export function SeriesGrid({ series, isCompact = false }: SeriesGridProps) { export function SeriesGrid({ series, isCompact = false }: SeriesGridProps) {
const router = useRouter(); const router = useRouter();
const { t } = useTranslate(); const { t } = useTranslate();
const { isAnonymous } = useAnonymous();
if (!series.length) { if (!series.length) {
return ( return (
@@ -73,24 +75,27 @@ export function SeriesGrid({ series, isCompact = false }: SeriesGridProps) {
onClick={() => router.push(`/series/${seriesItem.id}`)} onClick={() => router.push(`/series/${seriesItem.id}`)}
className={cn( className={cn(
"group relative aspect-[2/3] overflow-hidden rounded-xl border border-border/60 bg-card/80 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md", "group relative aspect-[2/3] overflow-hidden rounded-xl border border-border/60 bg-card/80 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md",
seriesItem.bookCount === seriesItem.booksReadCount && "opacity-50", !isAnonymous && seriesItem.bookCount === seriesItem.booksReadCount && "opacity-50",
isCompact && "aspect-[3/4]" isCompact && "aspect-[3/4]"
)} )}
> >
<SeriesCover <SeriesCover
series={seriesItem} series={seriesItem}
alt={t("series.coverAlt", { title: seriesItem.name })} alt={t("series.coverAlt", { title: seriesItem.name })}
isAnonymous={isAnonymous}
/> />
<div className="absolute inset-x-0 bottom-0 translate-y-full space-y-2 bg-gradient-to-t from-black/75 via-black/25 to-transparent p-4 transition-transform duration-200 group-hover:translate-y-0"> <div className="absolute inset-x-0 bottom-0 translate-y-full space-y-2 bg-gradient-to-t from-black/75 via-black/25 to-transparent p-4 transition-transform duration-200 group-hover:translate-y-0">
<h3 className="font-medium text-sm text-white line-clamp-2">{seriesItem.name}</h3> <h3 className="font-medium text-sm text-white line-clamp-2">{seriesItem.name}</h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span {!isAnonymous && (
className={`px-2 py-0.5 rounded-full text-xs ${ <span
getReadingStatusInfo(seriesItem, t).className className={`px-2 py-0.5 rounded-full text-xs ${
}`} getReadingStatusInfo(seriesItem, t).className
> }`}
{getReadingStatusInfo(seriesItem, t).label} >
</span> {getReadingStatusInfo(seriesItem, t).label}
</span>
)}
<span className="text-xs text-white/80"> <span className="text-xs text-white/80">
{t("series.books", { count: seriesItem.bookCount })} {t("series.books", { count: seriesItem.bookCount })}
</span> </span>

View File

@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { BookOpen, Calendar, Tag, User } from "lucide-react"; import { BookOpen, Calendar, Tag, User } from "lucide-react";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import { useAnonymous } from "@/contexts/AnonymousContext";
interface SeriesListProps { interface SeriesListProps {
series: NormalizedSeries[]; series: NormalizedSeries[];
@@ -57,16 +58,17 @@ const getReadingStatusInfo = (
function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) { function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
const router = useRouter(); const router = useRouter();
const { t } = useTranslate(); const { t } = useTranslate();
const { isAnonymous } = useAnonymous();
const handleClick = () => { const handleClick = () => {
router.push(`/series/${series.id}`); router.push(`/series/${series.id}`);
}; };
const isCompleted = series.bookCount === series.booksReadCount; const isCompleted = isAnonymous ? false : series.bookCount === series.booksReadCount;
const progressPercentage = const progressPercentage =
series.bookCount > 0 ? (series.booksReadCount / series.bookCount) * 100 : 0; series.bookCount > 0 ? (series.booksReadCount / series.bookCount) * 100 : 0;
const statusInfo = getReadingStatusInfo(series, t); const statusInfo = isAnonymous ? null : getReadingStatusInfo(series, t);
if (isCompact) { if (isCompact) {
return ( return (
@@ -83,6 +85,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
series={series} series={series}
alt={t("series.coverAlt", { title: series.name })} alt={t("series.coverAlt", { title: series.name })}
className="w-full h-full" className="w-full h-full"
isAnonymous={isAnonymous}
/> />
</div> </div>
@@ -93,14 +96,16 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<h3 className="font-medium text-sm sm:text-base line-clamp-1 hover:text-primary transition-colors flex-1 min-w-0"> <h3 className="font-medium text-sm sm:text-base line-clamp-1 hover:text-primary transition-colors flex-1 min-w-0">
{series.name} {series.name}
</h3> </h3>
<span {statusInfo && (
className={cn( <span
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0", className={cn(
statusInfo.className "px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
)} statusInfo.className
> )}
{statusInfo.label} >
</span> {statusInfo.label}
</span>
)}
</div> </div>
{/* Métadonnées minimales */} {/* Métadonnées minimales */}
@@ -139,6 +144,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
series={series} series={series}
alt={t("series.coverAlt", { title: series.name })} alt={t("series.coverAlt", { title: series.name })}
className="w-full h-full" className="w-full h-full"
isAnonymous={isAnonymous}
/> />
</div> </div>
@@ -153,14 +159,16 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
</div> </div>
{/* Badge de statut */} {/* Badge de statut */}
<span {statusInfo && (
className={cn( <span
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0", className={cn(
statusInfo.className "px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
)} statusInfo.className
> )}
{statusInfo.label} >
</span> {statusInfo.label}
</span>
)}
</div> </div>
{/* Résumé */} {/* Résumé */}
@@ -224,7 +232,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
</div> </div>
{/* Barre de progression */} {/* Barre de progression */}
{series.bookCount > 0 && !isCompleted && series.booksReadCount > 0 && ( {!isAnonymous && series.bookCount > 0 && !isCompleted && series.booksReadCount > 0 && (
<div className="space-y-1"> <div className="space-y-1">
<Progress value={progressPercentage} className="h-2" /> <Progress value={progressPercentage} className="h-2" />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">

View File

@@ -265,9 +265,7 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
isDoublePage={isDoublePage} isDoublePage={isDoublePage}
shouldShowDoublePage={(page) => shouldShowDoublePage(page, pages.length)} shouldShowDoublePage={(page) => shouldShowDoublePage(page, pages.length)}
imageBlobUrls={imageBlobUrls} imageBlobUrls={imageBlobUrls}
getPageUrl={getPageUrl}
isRTL={isRTL} isRTL={isRTL}
isPageLoading={isPageLoading}
/> />
<NavigationBar <NavigationBar

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect, useRef } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface PageDisplayProps { interface PageDisplayProps {
@@ -7,9 +7,7 @@ interface PageDisplayProps {
isDoublePage: boolean; isDoublePage: boolean;
shouldShowDoublePage: (page: number) => boolean; shouldShowDoublePage: (page: number) => boolean;
imageBlobUrls: Record<number, string>; imageBlobUrls: Record<number, string>;
getPageUrl: (pageNum: number) => string;
isRTL: boolean; isRTL: boolean;
isPageLoading?: (pageNum: number) => boolean;
} }
export function PageDisplay({ export function PageDisplay({
@@ -18,14 +16,14 @@ export function PageDisplay({
isDoublePage, isDoublePage,
shouldShowDoublePage, shouldShowDoublePage,
imageBlobUrls, imageBlobUrls,
getPageUrl,
isRTL, isRTL,
isPageLoading,
}: PageDisplayProps) { }: PageDisplayProps) {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false); const [hasError, setHasError] = useState(false);
const [secondPageLoading, setSecondPageLoading] = useState(true); const [secondPageLoading, setSecondPageLoading] = useState(true);
const [secondPageHasError, setSecondPageHasError] = useState(false); const [secondPageHasError, setSecondPageHasError] = useState(false);
const imageBlobUrlsRef = useRef(imageBlobUrls);
imageBlobUrlsRef.current = imageBlobUrls;
const handleImageLoad = useCallback(() => { const handleImageLoad = useCallback(() => {
setIsLoading(false); setIsLoading(false);
@@ -45,14 +43,29 @@ export function PageDisplay({
setSecondPageHasError(true); setSecondPageHasError(true);
}, []); }, []);
// Reset loading when page changes // Reset loading when page changes, but skip if blob URL is already available
useEffect(() => { useEffect(() => {
setIsLoading(true); setIsLoading(!imageBlobUrlsRef.current[currentPage]);
setHasError(false); setHasError(false);
setSecondPageLoading(true); setSecondPageLoading(!imageBlobUrlsRef.current[currentPage + 1]);
setSecondPageHasError(false); setSecondPageHasError(false);
}, [currentPage, isDoublePage]); }, [currentPage, isDoublePage]);
// Reset error state when blob URL becomes available
useEffect(() => {
if (imageBlobUrls[currentPage] && hasError) {
setHasError(false);
setIsLoading(true);
}
}, [imageBlobUrls[currentPage], currentPage, hasError]);
useEffect(() => {
if (imageBlobUrls[currentPage + 1] && secondPageHasError) {
setSecondPageHasError(false);
setSecondPageLoading(true);
}
}, [imageBlobUrls[currentPage + 1], currentPage, secondPageHasError]);
return ( return (
<div className="relative flex w-full flex-1 items-center justify-center overflow-hidden"> <div className="relative flex w-full flex-1 items-center justify-center overflow-hidden">
<div className="relative flex h-[calc(100vh-2.5rem)] w-full items-center justify-center px-2 sm:px-4"> <div className="relative flex h-[calc(100vh-2.5rem)] w-full items-center justify-center px-2 sm:px-4">
@@ -99,15 +112,12 @@ export function PageDisplay({
</svg> </svg>
<span className="text-sm opacity-60">Image non disponible</span> <span className="text-sm opacity-60">Image non disponible</span>
</div> </div>
) : ( ) : imageBlobUrls[currentPage] ? (
<> <>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
key={`page-${currentPage}-${imageBlobUrls[currentPage] || ""}`} key={`page-${currentPage}-${imageBlobUrls[currentPage]}`}
src={ src={imageBlobUrls[currentPage]}
imageBlobUrls[currentPage] ||
(isPageLoading && isPageLoading(currentPage) ? undefined : getPageUrl(currentPage))
}
alt={`Page ${currentPage}`} alt={`Page ${currentPage}`}
className={cn( className={cn(
"max-h-full max-w-full cursor-pointer object-contain transition-opacity", "max-h-full max-w-full cursor-pointer object-contain transition-opacity",
@@ -124,7 +134,7 @@ export function PageDisplay({
}} }}
/> />
</> </>
)} ) : null}
</div> </div>
{/* Page 2 (double page) */} {/* Page 2 (double page) */}
@@ -166,17 +176,12 @@ export function PageDisplay({
</svg> </svg>
<span className="text-sm opacity-60">Image non disponible</span> <span className="text-sm opacity-60">Image non disponible</span>
</div> </div>
) : ( ) : imageBlobUrls[currentPage + 1] ? (
<> <>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
key={`page-${currentPage + 1}-${imageBlobUrls[currentPage + 1] || ""}`} key={`page-${currentPage + 1}-${imageBlobUrls[currentPage + 1]}`}
src={ src={imageBlobUrls[currentPage + 1]}
imageBlobUrls[currentPage + 1] ||
(isPageLoading && isPageLoading(currentPage + 1)
? undefined
: getPageUrl(currentPage + 1))
}
alt={`Page ${currentPage + 1}`} alt={`Page ${currentPage + 1}`}
className={cn( className={cn(
"max-h-full max-w-full cursor-pointer object-contain transition-opacity", "max-h-full max-w-full cursor-pointer object-contain transition-opacity",
@@ -193,7 +198,7 @@ export function PageDisplay({
}} }}
/> />
</> </>
)} ) : null}
</div> </div>
)} )}
</div> </div>

View File

@@ -4,6 +4,7 @@ import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.serv
import type { NormalizedBook } from "@/lib/providers/types"; import type { NormalizedBook } from "@/lib/providers/types";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
import { updateReadProgress } from "@/app/actions/read-progress"; import { updateReadProgress } from "@/app/actions/read-progress";
import { useAnonymous } from "@/contexts/AnonymousContext";
interface UsePageNavigationProps { interface UsePageNavigationProps {
book: NormalizedBook; book: NormalizedBook;
@@ -23,6 +24,13 @@ export function usePageNavigation({
nextBook, nextBook,
}: UsePageNavigationProps) { }: UsePageNavigationProps) {
const router = useRouter(); const router = useRouter();
const { isAnonymous } = useAnonymous();
const isAnonymousRef = useRef(isAnonymous);
useEffect(() => {
isAnonymousRef.current = isAnonymous;
}, [isAnonymous]);
const [currentPage, setCurrentPage] = useState(() => { const [currentPage, setCurrentPage] = useState(() => {
const saved = ClientOfflineBookService.getCurrentPage(book); const saved = ClientOfflineBookService.getCurrentPage(book);
return saved < 1 ? 1 : saved; return saved < 1 ? 1 : saved;
@@ -48,8 +56,10 @@ export function usePageNavigation({
async (page: number) => { async (page: number) => {
try { try {
ClientOfflineBookService.setCurrentPage(bookRef.current, page); ClientOfflineBookService.setCurrentPage(bookRef.current, page);
const completed = page === pagesLengthRef.current; if (!isAnonymousRef.current) {
await updateReadProgress(bookRef.current.id, page, completed); const completed = page === pagesLengthRef.current;
await updateReadProgress(bookRef.current.id, page, completed);
}
} catch (error) { } catch (error) {
logger.error({ err: error }, "Sync error:"); logger.error({ err: error }, "Sync error:");
} }
@@ -89,7 +99,7 @@ export function usePageNavigation({
const handleNextPage = useCallback(() => { const handleNextPage = useCallback(() => {
if (currentPage === pages.length) { if (currentPage === pages.length) {
if (nextBook) { if (nextBook) {
router.push(`/books/${nextBook.id}`); router.replace(`/books/${nextBook.id}`);
return; return;
} }
setShowEndMessage(true); setShowEndMessage(true);

View File

@@ -13,6 +13,7 @@ import { FileText } from "lucide-react";
import { MarkAsReadButton } from "@/components/ui/mark-as-read-button"; import { MarkAsReadButton } from "@/components/ui/mark-as-read-button";
import { MarkAsUnreadButton } from "@/components/ui/mark-as-unread-button"; import { MarkAsUnreadButton } from "@/components/ui/mark-as-unread-button";
import { BookOfflineButton } from "@/components/ui/book-offline-button"; import { BookOfflineButton } from "@/components/ui/book-offline-button";
import { useAnonymous } from "@/contexts/AnonymousContext";
interface BookListProps { interface BookListProps {
books: NormalizedBook[]; books: NormalizedBook[];
@@ -30,6 +31,7 @@ interface BookListItemProps {
function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookListItemProps) { function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookListItemProps) {
const { t } = useTranslate(); const { t } = useTranslate();
const { isAnonymous } = useAnonymous();
const { isAccessible } = useBookOfflineStatus(book.id); const { isAccessible } = useBookOfflineStatus(book.id);
const handleClick = () => { const handleClick = () => {
@@ -37,9 +39,9 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
onBookClick(book); onBookClick(book);
}; };
const isRead = book.readProgress?.completed || false; const isRead = isAnonymous ? false : (book.readProgress?.completed || false);
const hasReadProgress = book.readProgress !== null; const hasReadProgress = isAnonymous ? false : book.readProgress !== null;
const currentPage = ClientOfflineBookService.getCurrentPage(book); const currentPage = isAnonymous ? 0 : ClientOfflineBookService.getCurrentPage(book);
const totalPages = book.pageCount; const totalPages = book.pageCount;
const progressPercentage = totalPages > 0 ? (currentPage / totalPages) * 100 : 0; const progressPercentage = totalPages > 0 ? (currentPage / totalPages) * 100 : 0;
@@ -118,14 +120,16 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
> >
{title} {title}
</h3> </h3>
<span {!isAnonymous && (
className={cn( <span
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0", className={cn(
statusInfo.className "px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
)} statusInfo.className
> )}
{statusInfo.label} >
</span> {statusInfo.label}
</span>
)}
</div> </div>
{/* Métadonnées minimales */} {/* Métadonnées minimales */}
@@ -191,14 +195,16 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
</div> </div>
{/* Badge de statut */} {/* Badge de statut */}
<span {!isAnonymous && (
className={cn( <span
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0", className={cn(
statusInfo.className "px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
)} statusInfo.className
> )}
{statusInfo.label} >
</span> {statusInfo.label}
</span>
)}
</div> </div>
{/* Métadonnées */} {/* Métadonnées */}
@@ -224,7 +230,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2 mt-auto pt-2"> <div className="flex items-center gap-2 mt-auto pt-2">
{!isRead && ( {!isAnonymous && !isRead && (
<MarkAsReadButton <MarkAsReadButton
bookId={book.id} bookId={book.id}
pagesCount={book.pageCount} pagesCount={book.pageCount}
@@ -233,7 +239,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
className="text-xs" className="text-xs"
/> />
)} )}
{hasReadProgress && ( {!isAnonymous && hasReadProgress && (
<MarkAsUnreadButton <MarkAsUnreadButton
bookId={book.id} bookId={book.id}
onSuccess={() => onSuccess(book, "unread")} onSuccess={() => onSuccess(book, "unread")}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { Book, BookOpen, BookMarked, Star, StarOff } from "lucide-react"; import { Book, BookOpen, BookMarked, BookX, Star, StarOff, User } from "lucide-react";
import type { NormalizedSeries } from "@/lib/providers/types"; import type { NormalizedSeries } from "@/lib/providers/types";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
@@ -14,6 +14,7 @@ import { StatusBadge } from "@/components/ui/status-badge";
import { IconButton } from "@/components/ui/icon-button"; import { IconButton } from "@/components/ui/icon-button";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
import { addToFavorites, removeFromFavorites } from "@/app/actions/favorites"; import { addToFavorites, removeFromFavorites } from "@/app/actions/favorites";
import { useAnonymous } from "@/contexts/AnonymousContext";
interface SeriesHeaderProps { interface SeriesHeaderProps {
series: NormalizedSeries; series: NormalizedSeries;
@@ -23,7 +24,9 @@ interface SeriesHeaderProps {
export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: SeriesHeaderProps) => { export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: SeriesHeaderProps) => {
const { toast } = useToast(); const { toast } = useToast();
const { isAnonymous } = useAnonymous();
const [isFavorite, setIsFavorite] = useState(initialIsFavorite); const [isFavorite, setIsFavorite] = useState(initialIsFavorite);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const { t } = useTranslate(); const { t } = useTranslate();
useEffect(() => { useEffect(() => {
@@ -99,10 +102,13 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
}; };
}; };
const statusInfo = getReadingStatusInfo(); const statusInfo = isAnonymous ? null : getReadingStatusInfo();
const authorsText = series.authors?.length
? series.authors.map((a) => a.name).join(", ")
: null;
return ( return (
<div className="relative min-h-[300px] md:h-[300px] w-screen -ml-[calc((100vw-100%)/2)] overflow-hidden"> <div className="relative min-h-[300px] w-screen -ml-[calc((100vw-100%)/2)] overflow-hidden">
{/* Image de fond */} {/* Image de fond */}
<div className="absolute inset-0"> <div className="absolute inset-0">
<SeriesCover <SeriesCover
@@ -128,20 +134,41 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
{/* Informations */} {/* Informations */}
<div className="flex-1 text-white space-y-2 text-center md:text-left"> <div className="flex-1 text-white space-y-2 text-center md:text-left">
<h1 className="text-2xl md:text-3xl font-bold">{series.name}</h1> <h1 className="text-2xl md:text-3xl font-bold">{series.name}</h1>
{series.summary && ( {authorsText && (
<p className="text-white/80 line-clamp-3 text-sm md:text-base"> <p className="text-white/70 text-sm flex items-center gap-1 justify-center md:justify-start">
{series.summary} <User className="h-3.5 w-3.5 flex-shrink-0" />
{authorsText}
</p> </p>
)} )}
{series.summary && (
<div>
<p className={`text-white/80 text-sm md:text-base ${isDescriptionExpanded ? "max-h-[200px] overflow-y-auto" : "line-clamp-3"}`}>
{series.summary}
</p>
<button
onClick={() => setIsDescriptionExpanded(!isDescriptionExpanded)}
className="text-white/60 hover:text-white/90 text-xs mt-1 transition-colors"
>
{t(isDescriptionExpanded ? "series.header.showLess" : "series.header.showMore")}
</button>
</div>
)}
<div className="flex items-center gap-4 mt-4 justify-center md:justify-start flex-wrap"> <div className="flex items-center gap-4 mt-4 justify-center md:justify-start flex-wrap">
<StatusBadge status={statusInfo.status} icon={statusInfo.icon}> {statusInfo && (
{statusInfo.label} <StatusBadge status={statusInfo.status} icon={statusInfo.icon}>
</StatusBadge> {statusInfo.label}
</StatusBadge>
)}
<span className="text-sm text-white/80"> <span className="text-sm text-white/80">
{series.bookCount === 1 {series.bookCount === 1
? t("series.header.books", { count: series.bookCount }) ? t("series.header.books", { count: series.bookCount })
: t("series.header.books_plural", { count: series.bookCount })} : t("series.header.books_plural", { count: series.bookCount })}
</span> </span>
{series.missingCount != null && series.missingCount > 0 && (
<StatusBadge status="warning" icon={BookX}>
{t("series.header.missing", { count: series.missingCount })}
</StatusBadge>
)}
<IconButton <IconButton
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -158,6 +185,7 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
}; };

View File

@@ -10,6 +10,7 @@ import { useTranslate } from "@/hooks/useTranslate";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus"; import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
import { WifiOff } from "lucide-react"; import { WifiOff } from "lucide-react";
import { useAnonymous } from "@/contexts/AnonymousContext";
// Fonction utilitaire pour obtenir les informations de statut de lecture // Fonction utilitaire pour obtenir les informations de statut de lecture
const getReadingStatusInfo = ( const getReadingStatusInfo = (
@@ -60,17 +61,18 @@ export function BookCover({
overlayVariant = "default", overlayVariant = "default",
}: BookCoverProps) { }: BookCoverProps) {
const { t } = useTranslate(); const { t } = useTranslate();
const { isAnonymous } = useAnonymous();
const { isAccessible } = useBookOfflineStatus(book.id); const { isAccessible } = useBookOfflineStatus(book.id);
const isCompleted = book.readProgress?.completed || false; const isCompleted = isAnonymous ? false : (book.readProgress?.completed || false);
const currentPage = ClientOfflineBookService.getCurrentPage(book); const currentPage = isAnonymous ? 0 : ClientOfflineBookService.getCurrentPage(book);
const totalPages = book.pageCount; const totalPages = book.pageCount;
const showProgress = Boolean(showProgressUi && totalPages > 0 && currentPage > 0 && !isCompleted); const showProgress = Boolean(!isAnonymous && showProgressUi && totalPages > 0 && currentPage > 0 && !isCompleted);
const statusInfo = getReadingStatusInfo(book, t); const statusInfo = isAnonymous ? { label: "", className: "" } : getReadingStatusInfo(book, t);
const isRead = book.readProgress?.completed || false; const isRead = isAnonymous ? false : (book.readProgress?.completed || false);
const hasReadProgress = book.readProgress !== null || currentPage > 0; const hasReadProgress = isAnonymous ? false : (book.readProgress !== null || currentPage > 0);
// Détermine si le livre doit être grisé (non accessible hors ligne) // Détermine si le livre doit être grisé (non accessible hors ligne)
const isUnavailable = !isAccessible; const isUnavailable = !isAccessible;
@@ -115,7 +117,7 @@ export function BookCover({
{showControls && ( {showControls && (
// Boutons en haut à droite avec un petit décalage // Boutons en haut à droite avec un petit décalage
<div className="absolute top-2 right-2 pointer-events-auto flex gap-1"> <div className="absolute top-2 right-2 pointer-events-auto flex gap-1">
{!isRead && ( {!isAnonymous && !isRead && (
<MarkAsReadButton <MarkAsReadButton
bookId={book.id} bookId={book.id}
pagesCount={book.pageCount} pagesCount={book.pageCount}
@@ -124,7 +126,7 @@ export function BookCover({
className="bg-white/90 hover:bg-white text-black shadow-sm" className="bg-white/90 hover:bg-white text-black shadow-sm"
/> />
)} )}
{hasReadProgress && ( {!isAnonymous && hasReadProgress && (
<MarkAsUnreadButton <MarkAsUnreadButton
bookId={book.id} bookId={book.id}
onSuccess={() => handleMarkAsUnread()} onSuccess={() => handleMarkAsUnread()}
@@ -145,11 +147,13 @@ export function BookCover({
? t("navigation.volume", { number: book.number }) ? t("navigation.volume", { number: book.number })
: "")} : "")}
</p> </p>
<div className="flex items-center gap-2"> {!isAnonymous && (
<span className={`px-2 py-0.5 rounded-full text-xs ${statusInfo.className}`}> <div className="flex items-center gap-2">
{statusInfo.label} <span className={`px-2 py-0.5 rounded-full text-xs ${statusInfo.className}`}>
</span> {statusInfo.label}
</div> </span>
</div>
)}
</div> </div>
)} )}
</div> </div>
@@ -162,12 +166,14 @@ export function BookCover({
? t("navigation.volume", { number: book.number }) ? t("navigation.volume", { number: book.number })
: "")} : "")}
</h3> </h3>
<p className="text-xs text-white/80 mt-1"> {!isAnonymous && (
{t("books.status.progress", { <p className="text-xs text-white/80 mt-1">
current: currentPage, {t("books.status.progress", {
total: book.pageCount, current: currentPage,
})} total: book.pageCount,
</p> })}
</p>
)}
</div> </div>
)} )}
</> </>

View File

@@ -18,4 +18,5 @@ export interface BookCoverProps extends BaseCoverProps {
export interface SeriesCoverProps extends BaseCoverProps { export interface SeriesCoverProps extends BaseCoverProps {
series: NormalizedSeries; series: NormalizedSeries;
isAnonymous?: boolean;
} }

View File

@@ -1,4 +1,5 @@
import { ProgressBar } from "./progress-bar"; import { ProgressBar } from "./progress-bar";
import { BookX } from "lucide-react";
import type { SeriesCoverProps } from "./cover-utils"; import type { SeriesCoverProps } from "./cover-utils";
export function SeriesCover({ export function SeriesCover({
@@ -6,12 +7,14 @@ export function SeriesCover({
alt = "Image de couverture", alt = "Image de couverture",
className, className,
showProgressUi = true, showProgressUi = true,
isAnonymous = false,
}: SeriesCoverProps) { }: SeriesCoverProps) {
const isCompleted = series.bookCount === series.booksReadCount; const isCompleted = isAnonymous ? false : series.bookCount === series.booksReadCount;
const readBooks = series.booksReadCount; const readBooks = isAnonymous ? 0 : series.booksReadCount;
const totalBooks = series.bookCount; const totalBooks = series.bookCount;
const showProgress = Boolean(showProgressUi && totalBooks > 0 && readBooks > 0 && !isCompleted); const showProgress = Boolean(!isAnonymous && showProgressUi && totalBooks > 0 && readBooks > 0 && !isCompleted);
const missingCount = series.missingCount;
return ( return (
<div className="relative w-full h-full"> <div className="relative w-full h-full">
@@ -27,6 +30,12 @@ export function SeriesCover({
.filter(Boolean) .filter(Boolean)
.join(" ")} .join(" ")}
/> />
{showProgressUi && missingCount != null && missingCount > 0 && (
<div className="absolute top-1.5 right-1.5 flex items-center gap-0.5 rounded-full bg-orange-500/90 px-1.5 py-0.5 text-white shadow-md backdrop-blur-sm">
<BookX className="h-3 w-3" />
<span className="text-[10px] font-bold leading-none">{missingCount}</span>
</div>
)}
{showProgress ? <ProgressBar progress={readBooks} total={totalBooks} type="series" /> : null} {showProgress ? <ProgressBar progress={readBooks} total={totalBooks} type="series" /> : null}
</div> </div>
); );

View File

@@ -0,0 +1,34 @@
"use client";
import React, { createContext, useContext, useMemo, useCallback } from "react";
import { usePreferences } from "@/contexts/PreferencesContext";
interface AnonymousContextType {
isAnonymous: boolean;
toggleAnonymous: () => void;
}
const AnonymousContext = createContext<AnonymousContextType | undefined>(undefined);
export function AnonymousProvider({ children }: { children: React.ReactNode }) {
const { preferences, updatePreferences } = usePreferences();
const toggleAnonymous = useCallback(() => {
updatePreferences({ anonymousMode: !preferences.anonymousMode });
}, [preferences.anonymousMode, updatePreferences]);
const contextValue = useMemo(
() => ({ isAnonymous: preferences.anonymousMode, toggleAnonymous }),
[preferences.anonymousMode, toggleAnonymous]
);
return <AnonymousContext.Provider value={contextValue}>{children}</AnonymousContext.Provider>;
}
export function useAnonymous() {
const context = useContext(AnonymousContext);
if (context === undefined) {
throw new Error("useAnonymous must be used within an AnonymousProvider");
}
return context;
}

View File

@@ -261,6 +261,9 @@
"add": "Added to favorites", "add": "Added to favorites",
"remove": "Removed from favorites" "remove": "Removed from favorites"
}, },
"showMore": "Show more",
"showLess": "Show less",
"missing": "{{count}} missing",
"toggleSidebar": "Toggle sidebar", "toggleSidebar": "Toggle sidebar",
"toggleTheme": "Toggle theme" "toggleTheme": "Toggle theme"
} }
@@ -449,6 +452,9 @@
"header": { "header": {
"toggleSidebar": "Toggle sidebar", "toggleSidebar": "Toggle sidebar",
"toggleTheme": "Toggle theme", "toggleTheme": "Toggle theme",
"anonymousMode": "Anonymous mode",
"anonymousModeOn": "Anonymous mode enabled",
"anonymousModeOff": "Anonymous mode disabled",
"search": { "search": {
"placeholder": "Search series and books...", "placeholder": "Search series and books...",
"empty": "No results", "empty": "No results",

View File

@@ -260,7 +260,10 @@
"favorite": { "favorite": {
"add": "Ajouté aux favoris", "add": "Ajouté aux favoris",
"remove": "Retiré des favoris" "remove": "Retiré des favoris"
} },
"showMore": "Voir plus",
"showLess": "Voir moins",
"missing": "{{count}} manquant(s)"
} }
}, },
"books": { "books": {
@@ -447,6 +450,9 @@
"header": { "header": {
"toggleSidebar": "Afficher/masquer le menu latéral", "toggleSidebar": "Afficher/masquer le menu latéral",
"toggleTheme": "Changer le thème", "toggleTheme": "Changer le thème",
"anonymousMode": "Mode anonyme",
"anonymousModeOn": "Mode anonyme activé",
"anonymousModeOff": "Mode anonyme désactivé",
"search": { "search": {
"placeholder": "Rechercher séries et tomes...", "placeholder": "Rechercher séries et tomes...",
"empty": "Aucun résultat", "empty": "Aucun résultat",

View File

@@ -37,6 +37,7 @@ export class KomgaAdapter {
bookCount: series.booksCount, bookCount: series.booksCount,
booksReadCount: series.booksReadCount, booksReadCount: series.booksReadCount,
thumbnailUrl: `/api/komga/images/series/${series.id}/thumbnail`, thumbnailUrl: `/api/komga/images/series/${series.id}/thumbnail`,
libraryId: series.libraryId,
summary: series.metadata?.summary ?? null, summary: series.metadata?.summary ?? null,
authors: series.booksMetadata?.authors ?? [], authors: series.booksMetadata?.authors ?? [],
genres: series.metadata?.genres ?? [], genres: series.metadata?.genres ?? [],

View File

@@ -73,11 +73,13 @@ export class StripstreamAdapter {
bookCount: series.book_count, bookCount: series.book_count,
booksReadCount: series.books_read_count, booksReadCount: series.books_read_count,
thumbnailUrl: `/api/stripstream/images/books/${series.first_book_id}/thumbnail`, thumbnailUrl: `/api/stripstream/images/books/${series.first_book_id}/thumbnail`,
libraryId: series.library_id,
summary: null, summary: null,
authors: [], authors: [],
genres: [], genres: [],
tags: [], tags: [],
createdAt: null, createdAt: null,
missingCount: series.missing_count ?? null,
}; };
} }

View File

@@ -12,8 +12,6 @@ import type {
import type { HomeData } from "@/types/home"; import type { HomeData } from "@/types/home";
import { StripstreamClient } from "./stripstream.client"; import { StripstreamClient } from "./stripstream.client";
import { StripstreamAdapter } from "./stripstream.adapter"; import { StripstreamAdapter } from "./stripstream.adapter";
import { ERROR_CODES } from "@/constants/errorCodes";
import { AppError } from "@/utils/errors";
import type { import type {
StripstreamLibraryResponse, StripstreamLibraryResponse,
StripstreamBooksPage, StripstreamBooksPage,
@@ -23,6 +21,7 @@ import type {
StripstreamBookDetails, StripstreamBookDetails,
StripstreamReadingProgressResponse, StripstreamReadingProgressResponse,
StripstreamSearchResponse, StripstreamSearchResponse,
StripstreamSeriesMetadata,
} from "@/types/stripstream"; } from "@/types/stripstream";
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG, SERIES_BOOKS_CACHE_TAG } from "@/constants/cacheConstants"; import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG, SERIES_BOOKS_CACHE_TAG } from "@/constants/cacheConstants";
@@ -91,18 +90,20 @@ export class StripstreamProvider implements IMediaProvider {
const seriesInfo = await this.findSeriesByName(book.series, book.library_id); const seriesInfo = await this.findSeriesByName(book.series, book.library_id);
if (seriesInfo) return seriesInfo; if (seriesInfo) return seriesInfo;
return { const fallback: NormalizedSeries = {
id: seriesId, id: seriesId,
name: book.series, name: book.series,
bookCount: 0, bookCount: 0,
booksReadCount: 0, booksReadCount: 0,
thumbnailUrl: `/api/stripstream/images/books/${seriesId}/thumbnail`, thumbnailUrl: `/api/stripstream/images/books/${seriesId}/thumbnail`,
libraryId: book.library_id,
summary: null, summary: null,
authors: [], authors: [],
genres: [], genres: [],
tags: [], tags: [],
createdAt: null, createdAt: null,
}; };
return this.enrichSeriesWithMetadata(fallback, book.library_id, book.series);
} catch { } catch {
// Fall back: treat seriesId as a series name, find its first book // Fall back: treat seriesId as a series name, find its first book
try { try {
@@ -117,18 +118,20 @@ export class StripstreamProvider implements IMediaProvider {
const seriesInfo = await this.findSeriesByName(seriesId, firstBook.library_id); const seriesInfo = await this.findSeriesByName(seriesId, firstBook.library_id);
if (seriesInfo) return seriesInfo; if (seriesInfo) return seriesInfo;
return { const fallback: NormalizedSeries = {
id: firstBook.id, id: firstBook.id,
name: seriesId, name: seriesId,
bookCount: 0, bookCount: 0,
booksReadCount: 0, booksReadCount: 0,
thumbnailUrl: `/api/stripstream/images/books/${firstBook.id}/thumbnail`, thumbnailUrl: `/api/stripstream/images/books/${firstBook.id}/thumbnail`,
libraryId: firstBook.library_id,
summary: null, summary: null,
authors: [], authors: [],
genres: [], genres: [],
tags: [], tags: [],
createdAt: null, createdAt: null,
}; };
return this.enrichSeriesWithMetadata(fallback, firstBook.library_id, seriesId);
} catch { } catch {
return null; return null;
} }
@@ -143,13 +146,49 @@ export class StripstreamProvider implements IMediaProvider {
{ revalidate: CACHE_TTL_MED } { revalidate: CACHE_TTL_MED }
); );
const match = seriesPage.items.find((s) => s.name === seriesName); const match = seriesPage.items.find((s) => s.name === seriesName);
if (match) return StripstreamAdapter.toNormalizedSeries(match); if (match) {
const normalized = StripstreamAdapter.toNormalizedSeries(match);
return this.enrichSeriesWithMetadata(normalized, libraryId, seriesName);
}
} catch { } catch {
// ignore // ignore
} }
return null; return null;
} }
private async enrichSeriesWithMetadata(
series: NormalizedSeries,
libraryId: string,
seriesName: string
): Promise<NormalizedSeries> {
try {
const metadata = await this.client.fetch<StripstreamSeriesMetadata>(
`libraries/${libraryId}/series/${encodeURIComponent(seriesName)}/metadata`,
undefined,
{ revalidate: CACHE_TTL_MED }
);
return {
...series,
summary: metadata.description ?? null,
authors: metadata.authors.map((name) => ({ name, role: "writer" })),
};
} catch (error) {
return series;
}
}
private async resolveSeriesInfo(seriesId: string): Promise<{ libraryId: string; seriesName: string } | null> {
try {
const book = await this.client.fetch<StripstreamBookDetails>(`books/${seriesId}`, undefined, {
revalidate: CACHE_TTL_MED,
});
if (!book.series) return null;
return { libraryId: book.library_id, seriesName: book.series };
} catch {
return null;
}
}
async getBooks(filter: BookListFilter): Promise<NormalizedBooksPage> { async getBooks(filter: BookListFilter): Promise<NormalizedBooksPage> {
const limit = filter.limit ?? 24; const limit = filter.limit ?? 24;
const params: Record<string, string | undefined> = { limit: String(limit) }; const params: Record<string, string | undefined> = { limit: String(limit) };

View File

@@ -18,12 +18,14 @@ export interface NormalizedSeries {
bookCount: number; bookCount: number;
booksReadCount: number; booksReadCount: number;
thumbnailUrl: string; thumbnailUrl: string;
libraryId?: string;
// Optional metadata (Komga-rich, Stripstream-sparse) // Optional metadata (Komga-rich, Stripstream-sparse)
summary?: string | null; summary?: string | null;
authors?: Array<{ name: string; role: string }>; authors?: Array<{ name: string; role: string }>;
genres?: string[]; genres?: string[];
tags?: string[]; tags?: string[];
createdAt?: string | null; createdAt?: string | null;
missingCount?: number | null;
} }
export interface NormalizedBook { export interface NormalizedBook {

View File

@@ -34,6 +34,7 @@ export class PreferencesService {
return { return {
showThumbnails: preferences.showThumbnails, showThumbnails: preferences.showThumbnails,
showOnlyUnread: preferences.showOnlyUnread, showOnlyUnread: preferences.showOnlyUnread,
anonymousMode: preferences.anonymousMode,
displayMode: { displayMode: {
...defaultPreferences.displayMode, ...defaultPreferences.displayMode,
...displayMode, ...displayMode,
@@ -72,6 +73,8 @@ export class PreferencesService {
} }
if (preferences.readerPrefetchCount !== undefined) if (preferences.readerPrefetchCount !== undefined)
updateData.readerPrefetchCount = preferences.readerPrefetchCount; updateData.readerPrefetchCount = preferences.readerPrefetchCount;
if (preferences.anonymousMode !== undefined)
updateData.anonymousMode = preferences.anonymousMode;
const updatedPreferences = await prisma.preferences.upsert({ const updatedPreferences = await prisma.preferences.upsert({
where: { userId }, where: { userId },
@@ -80,6 +83,7 @@ export class PreferencesService {
userId, userId,
showThumbnails: preferences.showThumbnails ?? defaultPreferences.showThumbnails, showThumbnails: preferences.showThumbnails ?? defaultPreferences.showThumbnails,
showOnlyUnread: preferences.showOnlyUnread ?? defaultPreferences.showOnlyUnread, showOnlyUnread: preferences.showOnlyUnread ?? defaultPreferences.showOnlyUnread,
anonymousMode: preferences.anonymousMode ?? defaultPreferences.anonymousMode,
displayMode: preferences.displayMode ?? defaultPreferences.displayMode, displayMode: preferences.displayMode ?? defaultPreferences.displayMode,
background: (preferences.background ?? background: (preferences.background ??
defaultPreferences.background) as unknown as Prisma.InputJsonValue, defaultPreferences.background) as unknown as Prisma.InputJsonValue,
@@ -90,6 +94,7 @@ export class PreferencesService {
return { return {
showThumbnails: updatedPreferences.showThumbnails, showThumbnails: updatedPreferences.showThumbnails,
showOnlyUnread: updatedPreferences.showOnlyUnread, showOnlyUnread: updatedPreferences.showOnlyUnread,
anonymousMode: updatedPreferences.anonymousMode,
displayMode: updatedPreferences.displayMode as UserPreferences["displayMode"], displayMode: updatedPreferences.displayMode as UserPreferences["displayMode"],
background: { background: {
...defaultPreferences.background, ...defaultPreferences.background,

View File

@@ -12,6 +12,7 @@ export interface BackgroundPreferences {
export interface UserPreferences { export interface UserPreferences {
showThumbnails: boolean; showThumbnails: boolean;
showOnlyUnread: boolean; showOnlyUnread: boolean;
anonymousMode: boolean;
displayMode: { displayMode: {
compact: boolean; compact: boolean;
itemsPerPage: number; itemsPerPage: number;
@@ -24,6 +25,7 @@ export interface UserPreferences {
export const defaultPreferences: UserPreferences = { export const defaultPreferences: UserPreferences = {
showThumbnails: true, showThumbnails: true,
showOnlyUnread: false, showOnlyUnread: false,
anonymousMode: false,
displayMode: { displayMode: {
compact: false, compact: false,
itemsPerPage: 20, itemsPerPage: 20,

View File

@@ -48,6 +48,8 @@ export interface StripstreamSeriesItem {
book_count: number; book_count: number;
books_read_count: number; books_read_count: number;
first_book_id: string; first_book_id: string;
library_id: string;
missing_count?: number | null;
} }
export interface StripstreamSeriesPage { export interface StripstreamSeriesPage {
@@ -80,6 +82,15 @@ export interface StripstreamUpdateReadingProgressRequest {
current_page?: number | null; current_page?: number | null;
} }
export interface StripstreamSeriesMetadata {
authors: string[];
publishers: string[];
description?: string | null;
start_year?: number | null;
book_author?: string | null;
book_language?: string | null;
}
export interface StripstreamSearchResponse { export interface StripstreamSearchResponse {
hits: StripstreamSearchHit[]; hits: StripstreamSearchHit[];
series_hits: StripstreamSeriesHit[]; series_hits: StripstreamSeriesHit[];