Compare commits

...

16 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
11da2335cd fix: use sort=latest for home page books and series queries
All checks were successful
Build, Push & Deploy / deploy (push) Successful in 6m48s
Use the API's sort parameter to explicitly request most recently added
items. Simplify latest series fetch by using /series?sort=latest instead
of N+1 calls per library.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:45:57 +01:00
feceb61e30 fix: increase header logo text size on mobile for better readability
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:34:32 +01:00
701a02b55c fix: prevent iOS auto-zoom on input focus by overriding Tailwind text-sm
Move the 16px font-size rule outside @layer base and scope it to iOS
via @supports (-webkit-touch-callout: none) so it takes priority over
Tailwind utility classes without affecting desktop rendering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:32:54 +01:00
b2664cce08 fix: reset zoom on orientation change in reader to prevent iOS auto-zoom
Temporarily inject maximum-scale=1 into viewport meta tag on orientation
change to cancel the automatic zoom iOS Safari applies, then restore
it to keep pinch-zoom available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:29:54 +01:00
ff44a781c8 fix: add tolerance threshold for zoom detection to prevent swipe breakage
After pinch-zoom then de-zoom, visualViewport.scale may not return
exactly to 1.0, blocking swipe navigation. Use 1.05 threshold instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:26:05 +01:00
d535f9f28e fix: respect RTL direction for reader arrow buttons and swipe navigation
Arrow buttons now swap next/previous in RTL mode. Swipe navigation
receives isRTL from parent state instead of creating its own independent
copy, so toggling direction takes effect immediately without reload.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:24:11 +01:00
2174579cc1 fix: hide "mark as unread" button on unread books for Stripstream provider
Return null for readProgress when Stripstream book status is "unread"
with no current page, aligning behavior with Komga provider.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:19:20 +01:00
70 changed files with 456 additions and 182 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,6 +314,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<AuthProvider> <AuthProvider>
<I18nProvider locale={locale}> <I18nProvider locale={locale}>
<PreferencesProvider initialPreferences={preferences}> <PreferencesProvider initialPreferences={preferences}>
<AnonymousProvider>
<ClientLayout <ClientLayout
initialLibraries={libraries} initialLibraries={libraries}
initialFavorites={favorites} initialFavorites={favorites}
@@ -265,6 +322,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
> >
{children} {children}
</ClientLayout> </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);
@@ -51,7 +53,7 @@ export function Header({
<div className="mr-2 flex items-center md:mr-4"> <div className="mr-2 flex items-center md:mr-4">
<a className="mr-2 flex items-center md:mr-6" href="/"> <a className="mr-2 flex items-center md:mr-6" href="/">
<span className="inline-flex flex-col leading-none"> <span className="inline-flex flex-col leading-none">
<span className="bg-gradient-to-r from-primary via-cyan-500 to-fuchsia-500 bg-clip-text text-sm font-bold tracking-[0.06em] text-transparent sm:text-lg sm:tracking-[0.08em]"> <span className="bg-gradient-to-r from-primary via-cyan-500 to-fuchsia-500 bg-clip-text text-base font-bold tracking-[0.06em] text-transparent sm:text-lg sm:tracking-[0.08em]">
StripStream StripStream
</span> </span>
<span className="mt-1 hidden text-[10px] font-medium uppercase tracking-[0.22em] text-foreground/70 sm:inline"> <span className="mt-1 hidden text-[10px] font-medium uppercase tracking-[0.22em] text-foreground/70 sm:inline">
@@ -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,17 +75,19 @@ 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">
{!isAnonymous && (
<span <span
className={`px-2 py-0.5 rounded-full text-xs ${ className={`px-2 py-0.5 rounded-full text-xs ${
getReadingStatusInfo(seriesItem, t).className getReadingStatusInfo(seriesItem, t).className
@@ -91,6 +95,7 @@ export function SeriesGrid({ series, isCompact = false }: SeriesGridProps) {
> >
{getReadingStatusInfo(seriesItem, t).label} {getReadingStatusInfo(seriesItem, t).label}
</span> </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,6 +96,7 @@ 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>
{statusInfo && (
<span <span
className={cn( className={cn(
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0", "px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
@@ -101,6 +105,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
> >
{statusInfo.label} {statusInfo.label}
</span> </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,6 +159,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
</div> </div>
{/* Badge de statut */} {/* Badge de statut */}
{statusInfo && (
<span <span
className={cn( className={cn(
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0", "px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
@@ -161,6 +168,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
> >
{statusInfo.label} {statusInfo.label}
</span> </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

@@ -76,13 +76,30 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
onPreviousPage: handlePreviousPage, onPreviousPage: handlePreviousPage,
onNextPage: handleNextPage, onNextPage: handleNextPage,
pswpRef, pswpRef,
isRTL,
}); });
// Activer le zoom dans le reader en enlevant la classe no-pinch-zoom // Activer le zoom dans le reader en enlevant la classe no-pinch-zoom
// et reset le zoom lors des changements d'orientation (iOS applique un zoom automatique)
useEffect(() => { useEffect(() => {
document.body.classList.remove("no-pinch-zoom"); document.body.classList.remove("no-pinch-zoom");
const handleOrientationChange = () => {
const viewport = document.querySelector('meta[name="viewport"]');
if (viewport) {
const original = viewport.getAttribute("content") || "";
viewport.setAttribute("content", original + ", maximum-scale=1");
// Restaurer après que iOS ait appliqué le nouveau layout
requestAnimationFrame(() => {
viewport.setAttribute("content", original);
});
}
};
window.addEventListener("orientationchange", handleOrientationChange);
return () => { return () => {
window.removeEventListener("orientationchange", handleOrientationChange);
document.body.classList.add("no-pinch-zoom"); document.body.classList.add("no-pinch-zoom");
}; };
}, []); }, []);
@@ -248,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

@@ -173,7 +173,7 @@ export const ControlButtons = ({
icon={ChevronLeft} icon={ChevronLeft}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onPreviousPage(); direction === "rtl" ? onNextPage() : onPreviousPage();
}} }}
tooltip={t("reader.controls.previousPage")} tooltip={t("reader.controls.previousPage")}
iconClassName="h-8 w-8" iconClassName="h-8 w-8"
@@ -193,7 +193,7 @@ export const ControlButtons = ({
icon={ChevronRight} icon={ChevronRight}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onNextPage(); direction === "rtl" ? onPreviousPage() : onNextPage();
}} }}
tooltip={t("reader.controls.nextPage")} tooltip={t("reader.controls.nextPage")}
iconClassName="h-8 w-8" iconClassName="h-8 w-8"

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);
if (!isAnonymousRef.current) {
const completed = page === pagesLengthRef.current; const completed = page === pagesLengthRef.current;
await updateReadProgress(bookRef.current.id, page, completed); 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

@@ -1,31 +1,27 @@
import { useCallback, useRef, useEffect } from "react"; import { useCallback, useRef, useEffect } from "react";
import { useReadingDirection } from "./useReadingDirection";
interface UseTouchNavigationProps { interface UseTouchNavigationProps {
onPreviousPage: () => void; onPreviousPage: () => void;
onNextPage: () => void; onNextPage: () => void;
pswpRef: React.MutableRefObject<unknown>; pswpRef: React.MutableRefObject<unknown>;
isRTL: boolean;
} }
export function useTouchNavigation({ export function useTouchNavigation({
onPreviousPage, onPreviousPage,
onNextPage, onNextPage,
pswpRef, pswpRef,
isRTL,
}: UseTouchNavigationProps) { }: UseTouchNavigationProps) {
const { isRTL } = useReadingDirection();
const touchStartXRef = useRef<number | null>(null); const touchStartXRef = useRef<number | null>(null);
const touchStartYRef = useRef<number | null>(null); const touchStartYRef = useRef<number | null>(null);
const isPinchingRef = useRef(false); const isPinchingRef = useRef(false);
// Helper pour vérifier si la page est zoomée (zoom natif du navigateur) // Helper pour vérifier si la page est zoomée (zoom natif du navigateur)
const isZoomed = useCallback(() => { const isZoomed = useCallback(() => {
// Utiliser visualViewport.scale pour détecter le zoom natif
// Si scale > 1, la page est zoomée
if (window.visualViewport) { if (window.visualViewport) {
return window.visualViewport.scale > 1; return window.visualViewport.scale > 1.05;
} }
// Fallback pour les navigateurs qui ne supportent pas visualViewport
// Comparer la taille de la fenêtre avec la taille réelle
return window.innerWidth !== window.screen.width; return window.innerWidth !== window.screen.width;
}, []); }, []);

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,6 +120,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
> >
{title} {title}
</h3> </h3>
{!isAnonymous && (
<span <span
className={cn( className={cn(
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0", "px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
@@ -126,6 +129,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
> >
{statusInfo.label} {statusInfo.label}
</span> </span>
)}
</div> </div>
{/* Métadonnées minimales */} {/* Métadonnées minimales */}
@@ -191,6 +195,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
</div> </div>
{/* Badge de statut */} {/* Badge de statut */}
{!isAnonymous && (
<span <span
className={cn( className={cn(
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0", "px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
@@ -199,6 +204,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
> >
{statusInfo.label} {statusInfo.label}
</span> </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">
{statusInfo && (
<StatusBadge status={statusInfo.status} icon={statusInfo.icon}> <StatusBadge status={statusInfo.status} icon={statusInfo.icon}>
{statusInfo.label} {statusInfo.label}
</StatusBadge> </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>
{!isAnonymous && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded-full text-xs ${statusInfo.className}`}> <span className={`px-2 py-0.5 rounded-full text-xs ${statusInfo.className}`}>
{statusInfo.label} {statusInfo.label}
</span> </span>
</div> </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>
{!isAnonymous && (
<p className="text-xs text-white/80 mt-1"> <p className="text-xs text-white/80 mt-1">
{t("books.status.progress", { {t("books.status.progress", {
current: currentPage, current: currentPage,
total: book.pageCount, 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

@@ -34,7 +34,10 @@ export class StripstreamAdapter {
volume: book.volume ?? null, volume: book.volume ?? null,
pageCount: book.page_count ?? 0, pageCount: book.page_count ?? 0,
thumbnailUrl: `/api/stripstream/images/books/${book.id}/thumbnail`, thumbnailUrl: `/api/stripstream/images/books/${book.id}/thumbnail`,
readProgress: { readProgress:
book.reading_status === "unread" && !book.reading_current_page
? null
: {
page: book.reading_current_page ?? null, page: book.reading_current_page ?? null,
completed: book.reading_status === "read", completed: book.reading_status === "read",
lastReadAt: book.reading_last_read_at ?? null, lastReadAt: book.reading_last_read_at ?? null,
@@ -52,7 +55,10 @@ export class StripstreamAdapter {
volume: book.volume ?? null, volume: book.volume ?? null,
pageCount: book.page_count ?? 0, pageCount: book.page_count ?? 0,
thumbnailUrl: `/api/stripstream/images/books/${book.id}/thumbnail`, thumbnailUrl: `/api/stripstream/images/books/${book.id}/thumbnail`,
readProgress: { readProgress:
book.reading_status === "unread" && !book.reading_current_page
? null
: {
page: book.reading_current_page ?? null, page: book.reading_current_page ?? null,
completed: book.reading_status === "read", completed: book.reading_status === "read",
lastReadAt: book.reading_last_read_at ?? null, lastReadAt: book.reading_last_read_at ?? null,
@@ -67,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) };
@@ -222,11 +261,11 @@ export class StripstreamProvider implements IMediaProvider {
async getHomeData(): Promise<HomeData> { async getHomeData(): Promise<HomeData> {
const homeOpts = { revalidate: CACHE_TTL_MED, tags: [HOME_CACHE_TAG] }; const homeOpts = { revalidate: CACHE_TTL_MED, tags: [HOME_CACHE_TAG] };
const [ongoingBooksResult, ongoingSeriesResult, booksPage, libraries] = await Promise.allSettled([ const [ongoingBooksResult, ongoingSeriesResult, booksPage, latestSeriesResult] = await Promise.allSettled([
this.client.fetch<StripstreamBookItem[]>("books/ongoing", { limit: "20" }, homeOpts), this.client.fetch<StripstreamBookItem[]>("books/ongoing", { limit: "20" }, homeOpts),
this.client.fetch<StripstreamSeriesItem[]>("series/ongoing", { limit: "10" }, homeOpts), this.client.fetch<StripstreamSeriesItem[]>("series/ongoing", { limit: "10" }, homeOpts),
this.client.fetch<StripstreamBooksPage>("books", { limit: "10" }, homeOpts), this.client.fetch<StripstreamBooksPage>("books", { sort: "latest", limit: "10" }, homeOpts),
this.client.fetch<StripstreamLibraryResponse[]>("libraries", undefined, { revalidate: CACHE_TTL_LONG, tags: [HOME_CACHE_TAG] }), this.client.fetch<StripstreamSeriesPage>("series", { sort: "latest", limit: "10" }, homeOpts),
]); ]);
// /books/ongoing returns both currently reading and next unread per series // /books/ongoing returns both currently reading and next unread per series
@@ -242,23 +281,9 @@ export class StripstreamProvider implements IMediaProvider {
? booksPage.value.items.map(StripstreamAdapter.toNormalizedBook) ? booksPage.value.items.map(StripstreamAdapter.toNormalizedBook)
: []; : [];
let latestSeries: NormalizedSeries[] = []; const latestSeries = latestSeriesResult.status === "fulfilled"
if (libraries.status === "fulfilled" && libraries.value.length > 0) { ? latestSeriesResult.value.items.map(StripstreamAdapter.toNormalizedSeries)
const allSeriesResults = await Promise.allSettled( : [];
libraries.value.map((lib) =>
this.client.fetch<StripstreamSeriesPage>(
`libraries/${lib.id}/series`,
{ limit: "10" },
homeOpts
)
)
);
latestSeries = allSeriesResults
.filter((r): r is PromiseFulfilledResult<StripstreamSeriesPage> => r.status === "fulfilled")
.flatMap((r) => r.value.items)
.map(StripstreamAdapter.toNormalizedSeries)
.slice(0, 10);
}
return { return {
ongoing: ongoingSeries, ongoing: ongoingSeries,

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

@@ -118,11 +118,14 @@ body.no-pinch-zoom * {
font-family: var(--font-ui); font-family: var(--font-ui);
} }
/* Empêche le zoom automatique iOS sur les inputs */ }
/* Empêche le zoom automatique iOS sur les inputs (hors @layer pour surcharger text-sm) */
@supports (-webkit-touch-callout: none) {
input, input,
textarea, textarea,
select { select {
font-size: 16px; font-size: 16px !important;
} }
} }

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[];