Compare commits

...

25 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
e6eab32473 fix: fetch real book counts in Stripstream getSeriesById to fix greyed covers
All checks were successful
Build, Push & Deploy / deploy (push) Successful in 4m18s
bookCount and booksReadCount were hardcoded to 0, causing all series
covers to appear completed (opacity-50). Now queries the series endpoint
to get actual counts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 16:41:27 +01:00
86b7382a04 refactor: merge onDeck and ongoingBooks into single "continue reading" carousel
Some checks failed
Build, Push & Deploy / deploy (push) Has been cancelled
Uses /books/ongoing as single source for Stripstream, displayed with
featured header style. Removes separate "up next" section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 16:39:33 +01:00
53af9db046 perf: use dedicated /books/ongoing and /series/ongoing Stripstream endpoints
All checks were successful
Build, Push & Deploy / deploy (push) Successful in 4m52s
Replaces manual ongoing series derivation (fetching 200 series per library)
with the new API endpoints, reducing API calls and improving accuracy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 16:25:49 +01:00
1d03cfc177 feat: add favorite series carousel on home page
All checks were successful
Build, Push & Deploy / deploy (push) Successful in 4m15s
Displays a carousel of favorite series after the ongoing sections.
Hidden when the user has no favorites.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 16:03:06 +01:00
b0d56948a3 fix: use proper reading_status filters for Stripstream home page
All checks were successful
Build, Push & Deploy / deploy (push) Successful in 7m13s
The Stripstream provider was showing all books and first library's series
instead of using reading status filters. Now ongoingBooks uses
reading_status=reading, ongoing series are derived from books being read,
and latest series are fetched from all libraries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 21:19:35 +01:00
fc9c220be6 perf: stream Stripstream images and increase image fetch timeout
Some checks failed
Build, Push & Deploy / deploy (push) Failing after 3s
Stream image responses directly to the client instead of buffering the
entire image in memory, reducing perceived latency. Increase image fetch
timeout from 15s to 60s to avoid AbortError on slow page loads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 17:53:37 +01:00
100d8b37e7 feat: add Docker image cleanup step after deploy
All checks were successful
Build, Push & Deploy / deploy (push) Successful in 37s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 12:30:47 +01:00
f9651676a5 feat: CI builds and pushes to DockerHub then restarts container via stack script
All checks were successful
Build, Push & Deploy / deploy (push) Successful in 4m46s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 12:25:17 +01:00
539bb34716 perf: optimize Komga caching with unstable_cache for POST requests and reduce API calls
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
- Fix POST requests (series/list, books/list) not being cached by Next.js fetch cache
  by wrapping them with unstable_cache in the private fetch method
- Wrap getHomeData() entirely with unstable_cache so all 5 home requests are cached
  as a single unit, reducing cold-start cost from 5 parallel calls to 0 on cache hit
- Remove N+1 book count enrichment from getLibraries() (8 extra calls per cold start)
  as LibraryDto does not return booksCount and the value was only used in BackgroundSettings
- Simplify getLibraryById() to reuse cached getLibraries() data instead of making
  separate HTTP calls (saves 2 calls per library page load)
- Fix cache debug logs: replace misleading x-nextjs-cache header check (always UNKNOWN
  on external APIs) with pre-request logs showing the configured cache strategy
- Remove book count display from BackgroundSettings as it is no longer fetched

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 23:10:31 +01:00
80 changed files with 612 additions and 317 deletions

View File

@@ -1,26 +1,33 @@
name: Deploy with Docker Compose
name: Build, Push & Deploy
on:
push:
branches:
- main # adapte la branche que tu veux déployer
- main
jobs:
deploy:
runs-on: mac-orbstack-runner # le nom que tu as donné au runner
runs-on: mac-orbstack-runner
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy stack
- name: Login to DockerHub
run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Build Docker image
env:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL }}
ADMIN_DEFAULT_PASSWORD: ${{ secrets.ADMIN_DEFAULT_PASSWORD }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
PRISMA_DATA_PATH: ${{ vars.PRISMA_DATA_PATH }}
NODE_ENV: production
run: docker build -t julienfroidefond32/stripstream:latest .
- name: Push to DockerHub
run: docker push julienfroidefond32/stripstream:latest
- name: Pull new image and restart container
run: |
docker compose up -d --build
docker pull julienfroidefond32/stripstream:latest
cd /Users/julienfroidefond/Sites/docker-stack
./scripts/stack.sh up stripstream
- name: Cleanup old images
run: docker image prune -f

View File

@@ -2,6 +2,7 @@ version: "3.8"
services:
stripstream-app:
image: julienfroidefond32/stripstream:latest
build:
context: .
dockerfile: Dockerfile

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
background Json
readerPrefetchCount Int @default(5)
anonymousMode Boolean @default(false)
createdAt DateTime @default(now())
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 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
const splashScreens = [
// iPad (portrait + landscape)
@@ -16,8 +19,14 @@ const splashScreens = [
{ width: 2732, height: 2048, name: "iPad Pro 12.9 landscape" },
{ width: 1668, height: 2388, name: "iPad Pro 11 portrait" },
{ 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: 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: 2160, height: 1620, name: "iPad 10.2 landscape" },
{ 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: 1284, height: 2778, name: "iPhone 12/13/14 Pro Max portrait" },
{ width: 2778, height: 1284, name: "iPhone 12/13/14 Pro Max landscape" },
{ width: 1179, height: 2556, name: "iPhone 14 Pro portrait" },
{ width: 2556, height: 1179, name: "iPhone 14 Pro landscape" },
{ width: 1179, height: 2556, name: "iPhone 14 Pro/15 portrait" },
{ width: 2556, height: 1179, name: "iPhone 14 Pro/15 landscape" },
{ width: 1290, height: 2796, name: "iPhone 14/15 Pro Max portrait" },
{ width: 2796, height: 1290, name: "iPhone 14/15 Pro Max landscape" },
{ width: 1179, height: 2556, name: "iPhone 15 portrait" },
{ width: 2556, height: 1179, name: "iPhone 15 landscape" },
{ width: 1206, height: 2622, name: "iPhone 16 Pro portrait" },
{ 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: 2532, height: 1170, name: "iPhone 16/16e landscape" },
];
async function generateSplashScreens() {
await fs.mkdir(splashDir, { recursive: true });
console.log(`\n📱 Génération des splash screens...`);
for (const screen of splashScreens) {
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, {
fit: "cover",
position: "center",
})
.composite([{ input: darkOverlay, blend: "over" }])
.png({
compressionLevel: 9,
})
.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

@@ -35,14 +35,15 @@ export async function GET(
const response = await client.fetchImage(path);
const contentType = response.headers.get("content-type") ?? "image/jpeg";
const buffer = await response.arrayBuffer();
const contentLength = response.headers.get("content-length");
return new NextResponse(buffer, {
headers: {
const headers: Record<string, string> = {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400",
},
});
};
if (contentLength) headers["Content-Length"] = contentLength;
return new NextResponse(response.body, { headers });
} catch (error) {
logger.error({ err: error }, "Stripstream page fetch error");

View File

@@ -5,6 +5,7 @@ import { cn } from "@/lib/utils";
import ClientLayout from "@/components/layout/ClientLayout";
import { PreferencesService } from "@/lib/services/preferences.service";
import { PreferencesProvider } from "@/contexts/PreferencesContext";
import { AnonymousProvider } from "@/contexts/AnonymousContext";
import { I18nProvider } from "@/components/providers/I18nProvider";
import { AuthProvider } from "@/components/providers/AuthProvider";
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"
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>
<body
className={cn(
@@ -258,6 +314,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<AuthProvider>
<I18nProvider locale={locale}>
<PreferencesProvider initialPreferences={preferences}>
<AnonymousProvider>
<ClientLayout
initialLibraries={libraries}
initialFavorites={favorites}
@@ -265,6 +322,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
>
{children}
</ClientLayout>
</AnonymousProvider>
</PreferencesProvider>
</I18nProvider>
</AuthProvider>

View File

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

View File

@@ -3,21 +3,34 @@ import type { HomeData } from "@/types/home";
interface HomeContentProps {
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),
// deduplicate by id, onDeck first
const continueReading = (() => {
const items = [...(data.onDeck ?? []), ...(data.ongoingBooks ?? [])];
const seen = new Set<string>();
return items.filter((item) => {
if (seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
})();
return (
<div className="space-y-10 pb-2">
{data.ongoingBooks && data.ongoingBooks.length > 0 && (
{!isAnonymous && continueReading.length > 0 && (
<MediaRow
titleKey="home.sections.continue_reading"
items={data.ongoingBooks}
items={continueReading}
iconName="BookOpen"
featuredHeader
/>
)}
{data.ongoing && data.ongoing.length > 0 && (
{!isAnonymous && data.ongoing && data.ongoing.length > 0 && (
<MediaRow
titleKey="home.sections.continue_series"
items={data.ongoing}
@@ -25,11 +38,11 @@ export function HomeContent({ data }: HomeContentProps) {
/>
)}
{data.onDeck && data.onDeck.length > 0 && (
{data.favorites && data.favorites.length > 0 && (
<MediaRow
titleKey="home.sections.up_next"
items={data.onDeck}
iconName="Clock"
titleKey="home.sections.favorites"
items={data.favorites}
iconName="Heart"
/>
)}

View File

@@ -7,10 +7,11 @@ import { SeriesCover } from "../ui/series-cover";
import { useTranslate } from "@/hooks/useTranslate";
import { ScrollContainer } from "@/components/ui/scroll-container";
import { Section } from "@/components/ui/section";
import { History, Sparkles, Clock, LibraryBig, BookOpen } from "lucide-react";
import { History, Sparkles, Clock, LibraryBig, BookOpen, Heart } from "lucide-react";
import { Card } from "@/components/ui/card";
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
import { cn } from "@/lib/utils";
import { useAnonymous } from "@/contexts/AnonymousContext";
interface MediaRowProps {
titleKey: string;
@@ -25,6 +26,7 @@ const iconMap = {
Clock,
Sparkles,
History,
Heart,
};
function isSeries(item: NormalizedSeries | NormalizedBook): item is NormalizedSeries {
@@ -77,6 +79,7 @@ interface MediaCardProps {
function MediaCard({ item, onClick }: MediaCardProps) {
const { t } = useTranslate();
const { isAnonymous } = useAnonymous();
const isSeriesItem = isSeries(item);
const { isAccessible } = useBookOfflineStatus(isSeriesItem ? "" : item.id);
@@ -104,7 +107,7 @@ function MediaCard({ item, onClick }: MediaCardProps) {
<div className="relative aspect-[2/3] bg-muted">
{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">
<h3 className="font-medium text-sm text-white line-clamp-2">{title}</h3>
<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 LanguageSelector from "@/components/LanguageSelector";
import { useTranslation } from "react-i18next";
import { IconButton } from "@/components/ui/icon-button";
import { useState } from "react";
import { GlobalSearch } from "@/components/layout/GlobalSearch";
import { useAnonymous } from "@/contexts/AnonymousContext";
interface HeaderProps {
onToggleSidebar: () => void;
@@ -19,6 +20,7 @@ export function Header({
}: HeaderProps) {
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const { isAnonymous, toggleAnonymous } = useAnonymous();
const [isRefreshing, setIsRefreshing] = useState(false);
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
@@ -51,7 +53,7 @@ export function Header({
<div className="mr-2 flex items-center md:mr-4">
<a className="mr-2 flex items-center md:mr-6" href="/">
<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
</span>
<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"
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 />
<button
onClick={toggleTheme}

View File

@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import { SeriesCover } from "@/components/ui/series-cover";
import { useTranslate } from "@/hooks/useTranslate";
import { useAnonymous } from "@/contexts/AnonymousContext";
interface SeriesGridProps {
series: NormalizedSeries[];
@@ -49,6 +50,7 @@ const getReadingStatusInfo = (
export function SeriesGrid({ series, isCompact = false }: SeriesGridProps) {
const router = useRouter();
const { t } = useTranslate();
const { isAnonymous } = useAnonymous();
if (!series.length) {
return (
@@ -73,17 +75,19 @@ export function SeriesGrid({ series, isCompact = false }: SeriesGridProps) {
onClick={() => router.push(`/series/${seriesItem.id}`)}
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",
seriesItem.bookCount === seriesItem.booksReadCount && "opacity-50",
!isAnonymous && seriesItem.bookCount === seriesItem.booksReadCount && "opacity-50",
isCompact && "aspect-[3/4]"
)}
>
<SeriesCover
series={seriesItem}
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">
<h3 className="font-medium text-sm text-white line-clamp-2">{seriesItem.name}</h3>
<div className="flex items-center gap-2">
{!isAnonymous && (
<span
className={`px-2 py-0.5 rounded-full text-xs ${
getReadingStatusInfo(seriesItem, t).className
@@ -91,6 +95,7 @@ export function SeriesGrid({ series, isCompact = false }: SeriesGridProps) {
>
{getReadingStatusInfo(seriesItem, t).label}
</span>
)}
<span className="text-xs text-white/80">
{t("series.books", { count: seriesItem.bookCount })}
</span>

View File

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

View File

@@ -76,13 +76,30 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
onPreviousPage: handlePreviousPage,
onNextPage: handleNextPage,
pswpRef,
isRTL,
});
// 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(() => {
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 () => {
window.removeEventListener("orientationchange", handleOrientationChange);
document.body.classList.add("no-pinch-zoom");
};
}, []);
@@ -248,9 +265,7 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
isDoublePage={isDoublePage}
shouldShowDoublePage={(page) => shouldShowDoublePage(page, pages.length)}
imageBlobUrls={imageBlobUrls}
getPageUrl={getPageUrl}
isRTL={isRTL}
isPageLoading={isPageLoading}
/>
<NavigationBar

View File

@@ -173,7 +173,7 @@ export const ControlButtons = ({
icon={ChevronLeft}
onClick={(e) => {
e.stopPropagation();
onPreviousPage();
direction === "rtl" ? onNextPage() : onPreviousPage();
}}
tooltip={t("reader.controls.previousPage")}
iconClassName="h-8 w-8"
@@ -193,7 +193,7 @@ export const ControlButtons = ({
icon={ChevronRight}
onClick={(e) => {
e.stopPropagation();
onNextPage();
direction === "rtl" ? onPreviousPage() : onNextPage();
}}
tooltip={t("reader.controls.nextPage")}
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";
interface PageDisplayProps {
@@ -7,9 +7,7 @@ interface PageDisplayProps {
isDoublePage: boolean;
shouldShowDoublePage: (page: number) => boolean;
imageBlobUrls: Record<number, string>;
getPageUrl: (pageNum: number) => string;
isRTL: boolean;
isPageLoading?: (pageNum: number) => boolean;
}
export function PageDisplay({
@@ -18,14 +16,14 @@ export function PageDisplay({
isDoublePage,
shouldShowDoublePage,
imageBlobUrls,
getPageUrl,
isRTL,
isPageLoading,
}: PageDisplayProps) {
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const [secondPageLoading, setSecondPageLoading] = useState(true);
const [secondPageHasError, setSecondPageHasError] = useState(false);
const imageBlobUrlsRef = useRef(imageBlobUrls);
imageBlobUrlsRef.current = imageBlobUrls;
const handleImageLoad = useCallback(() => {
setIsLoading(false);
@@ -45,14 +43,29 @@ export function PageDisplay({
setSecondPageHasError(true);
}, []);
// Reset loading when page changes
// Reset loading when page changes, but skip if blob URL is already available
useEffect(() => {
setIsLoading(true);
setIsLoading(!imageBlobUrlsRef.current[currentPage]);
setHasError(false);
setSecondPageLoading(true);
setSecondPageLoading(!imageBlobUrlsRef.current[currentPage + 1]);
setSecondPageHasError(false);
}, [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 (
<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">
@@ -99,15 +112,12 @@ export function PageDisplay({
</svg>
<span className="text-sm opacity-60">Image non disponible</span>
</div>
) : (
) : imageBlobUrls[currentPage] ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
key={`page-${currentPage}-${imageBlobUrls[currentPage] || ""}`}
src={
imageBlobUrls[currentPage] ||
(isPageLoading && isPageLoading(currentPage) ? undefined : getPageUrl(currentPage))
}
key={`page-${currentPage}-${imageBlobUrls[currentPage]}`}
src={imageBlobUrls[currentPage]}
alt={`Page ${currentPage}`}
className={cn(
"max-h-full max-w-full cursor-pointer object-contain transition-opacity",
@@ -124,7 +134,7 @@ export function PageDisplay({
}}
/>
</>
)}
) : null}
</div>
{/* Page 2 (double page) */}
@@ -166,17 +176,12 @@ export function PageDisplay({
</svg>
<span className="text-sm opacity-60">Image non disponible</span>
</div>
) : (
) : imageBlobUrls[currentPage + 1] ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
key={`page-${currentPage + 1}-${imageBlobUrls[currentPage + 1] || ""}`}
src={
imageBlobUrls[currentPage + 1] ||
(isPageLoading && isPageLoading(currentPage + 1)
? undefined
: getPageUrl(currentPage + 1))
}
key={`page-${currentPage + 1}-${imageBlobUrls[currentPage + 1]}`}
src={imageBlobUrls[currentPage + 1]}
alt={`Page ${currentPage + 1}`}
className={cn(
"max-h-full max-w-full cursor-pointer object-contain transition-opacity",
@@ -193,7 +198,7 @@ export function PageDisplay({
}}
/>
</>
)}
) : null}
</div>
)}
</div>

View File

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

View File

@@ -1,31 +1,27 @@
import { useCallback, useRef, useEffect } from "react";
import { useReadingDirection } from "./useReadingDirection";
interface UseTouchNavigationProps {
onPreviousPage: () => void;
onNextPage: () => void;
pswpRef: React.MutableRefObject<unknown>;
isRTL: boolean;
}
export function useTouchNavigation({
onPreviousPage,
onNextPage,
pswpRef,
isRTL,
}: UseTouchNavigationProps) {
const { isRTL } = useReadingDirection();
const touchStartXRef = useRef<number | null>(null);
const touchStartYRef = useRef<number | null>(null);
const isPinchingRef = useRef(false);
// Helper pour vérifier si la page est zoomée (zoom natif du navigateur)
const isZoomed = useCallback(() => {
// Utiliser visualViewport.scale pour détecter le zoom natif
// Si scale > 1, la page est zoomée
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;
}, []);

View File

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

View File

@@ -1,6 +1,6 @@
"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 { useState, useEffect } from "react";
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 logger from "@/lib/logger";
import { addToFavorites, removeFromFavorites } from "@/app/actions/favorites";
import { useAnonymous } from "@/contexts/AnonymousContext";
interface SeriesHeaderProps {
series: NormalizedSeries;
@@ -23,7 +24,9 @@ interface SeriesHeaderProps {
export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: SeriesHeaderProps) => {
const { toast } = useToast();
const { isAnonymous } = useAnonymous();
const [isFavorite, setIsFavorite] = useState(initialIsFavorite);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const { t } = useTranslate();
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 (
<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 */}
<div className="absolute inset-0">
<SeriesCover
@@ -128,20 +134,41 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
{/* Informations */}
<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>
{series.summary && (
<p className="text-white/80 line-clamp-3 text-sm md:text-base">
{series.summary}
{authorsText && (
<p className="text-white/70 text-sm flex items-center gap-1 justify-center md:justify-start">
<User className="h-3.5 w-3.5 flex-shrink-0" />
{authorsText}
</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">
{statusInfo && (
<StatusBadge status={statusInfo.status} icon={statusInfo.icon}>
{statusInfo.label}
</StatusBadge>
)}
<span className="text-sm text-white/80">
{series.bookCount === 1
? t("series.header.books", { count: series.bookCount })
: t("series.header.books_plural", { count: series.bookCount })}
</span>
{series.missingCount != null && series.missingCount > 0 && (
<StatusBadge status="warning" icon={BookX}>
{t("series.header.missing", { count: series.missingCount })}
</StatusBadge>
)}
<IconButton
variant="ghost"
size="icon"
@@ -158,6 +185,7 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
</div>
</div>
</div>
</div>
);
};

View File

@@ -278,7 +278,7 @@ export function BackgroundSettings({ initialLibraries }: BackgroundSettingsProps
htmlFor={`lib-${library.id}`}
className="cursor-pointer font-normal text-sm"
>
{library.name} ({library.bookCount} livres)
{library.name}
</Label>
</div>
))}

View File

@@ -34,14 +34,8 @@ export function KomgaSettings({ initialConfig }: KomgaSettingsProps) {
const handleTest = async () => {
setIsLoading(true);
const form = document.querySelector("form") as HTMLFormElement;
const formData = new FormData(form);
const serverUrl = formData.get("serverUrl") as string;
const username = formData.get("username") as string;
const password = formData.get("password") as string;
try {
const result = await testKomgaConnection(serverUrl.trim(), username, password || config.password);
const result = await testKomgaConnection(config.serverUrl.trim(), config.username, config.password);
if (!result.success) {
throw new Error(result.message);
@@ -55,8 +49,8 @@ export function KomgaSettings({ initialConfig }: KomgaSettingsProps) {
logger.error({ err: error }, "Erreur:");
toast({
variant: "destructive",
title: t("settings.komga.error.title"),
description: t("settings.komga.error.message"),
title: t("settings.komga.error.connectionTitle"),
description: t("settings.komga.error.connectionMessage"),
});
} finally {
setIsLoading(false);

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { ProgressBar } from "./progress-bar";
import { BookX } from "lucide-react";
import type { SeriesCoverProps } from "./cover-utils";
export function SeriesCover({
@@ -6,12 +7,14 @@ export function SeriesCover({
alt = "Image de couverture",
className,
showProgressUi = true,
isAnonymous = false,
}: 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 showProgress = Boolean(showProgressUi && totalBooks > 0 && readBooks > 0 && !isCompleted);
const showProgress = Boolean(!isAnonymous && showProgressUi && totalBooks > 0 && readBooks > 0 && !isCompleted);
const missingCount = series.missingCount;
return (
<div className="relative w-full h-full">
@@ -27,6 +30,12 @@ export function SeriesCover({
.filter(Boolean)
.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}
</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

@@ -35,6 +35,7 @@
},
"title": "Home",
"sections": {
"favorites": "Favorite series",
"continue_series": "Continue series",
"continue_reading": "Continue reading",
"up_next": "Up next",
@@ -134,7 +135,9 @@
},
"error": {
"title": "Error saving configuration",
"message": "An error occurred while saving the configuration"
"message": "An error occurred while saving the configuration",
"connectionTitle": "Connection error",
"connectionMessage": "Unable to connect to the Komga server. Check the URL and credentials."
}
},
"cache": {
@@ -258,6 +261,9 @@
"add": "Added to favorites",
"remove": "Removed from favorites"
},
"showMore": "Show more",
"showLess": "Show less",
"missing": "{{count}} missing",
"toggleSidebar": "Toggle sidebar",
"toggleTheme": "Toggle theme"
}
@@ -361,7 +367,6 @@
"errors": {
"MONGODB_MISSING_URI": "MongoDB URI missing",
"MONGODB_CONNECTION_FAILED": "MongoDB connection failed",
"AUTH_UNAUTHENTICATED": "Unauthenticated",
"AUTH_INVALID_CREDENTIALS": "Invalid credentials",
"AUTH_PASSWORD_NOT_STRONG": "Password is not strong enough",
@@ -371,7 +376,6 @@
"AUTH_LOGOUT_ERROR": "Error during logout",
"AUTH_LOGIN_ERROR": "Error during login",
"AUTH_REGISTER_ERROR": "Error during registration",
"KOMGA_MISSING_CONFIG": "Komga configuration missing",
"KOMGA_MISSING_CREDENTIALS": "Komga credentials missing",
"KOMGA_CONNECTION_ERROR": "Error connecting to Komga server",
@@ -380,25 +384,20 @@
"STRIPSTREAM_MISSING_CONFIG": "Stripstream Librarian configuration missing",
"STRIPSTREAM_CONNECTION_ERROR": "Error connecting to Stripstream Librarian",
"STRIPSTREAM_HTTP_ERROR": "HTTP error while communicating with Stripstream Librarian",
"CONFIG_SAVE_ERROR": "Error saving configuration",
"CONFIG_FETCH_ERROR": "Error fetching configuration",
"CONFIG_TTL_SAVE_ERROR": "Error saving TTL configuration",
"CONFIG_TTL_FETCH_ERROR": "Error fetching TTL configuration",
"LIBRARY_NOT_FOUND": "Library not found",
"LIBRARY_FETCH_ERROR": "Error fetching library",
"LIBRARY_SCAN_ERROR": "Error scanning library",
"SERIES_FETCH_ERROR": "Error fetching series",
"SERIES_NO_BOOKS_FOUND": "No books found in series",
"BOOK_NOT_FOUND": "Book not found",
"BOOK_PROGRESS_UPDATE_ERROR": "Error updating reading progress",
"BOOK_PROGRESS_DELETE_ERROR": "Error deleting reading progress",
"BOOK_PAGES_FETCH_ERROR": "Error fetching book pages",
"BOOK_DOWNLOAD_CANCELLED": "Book download cancelled",
"FAVORITE_ADD_ERROR": "Error adding to favorites",
"FAVORITE_DELETE_ERROR": "Error removing from favorites",
"FAVORITE_FETCH_ERROR": "Error fetching favorites",
@@ -406,26 +405,19 @@
"FAVORITE_NETWORK_ERROR": "Network error while accessing favorites",
"FAVORITE_SERVER_ERROR": "Server error while accessing favorites",
"FAVORITE_STATUS_CHECK_ERROR": "Error checking favorites status",
"PREFERENCES_FETCH_ERROR": "Error fetching preferences",
"PREFERENCES_UPDATE_ERROR": "Error updating preferences",
"PREFERENCES_CONTEXT_ERROR": "Preferences context error",
"UI_TABS_TRIGGER_ERROR": "Error triggering tabs",
"UI_TABS_CONTENT_ERROR": "Error loading tabs content",
"IMAGE_FETCH_ERROR": "Error fetching image",
"HOME_FETCH_ERROR": "Error fetching home page",
"MIDDLEWARE_UNAUTHORIZED": "Unauthorized",
"MIDDLEWARE_INVALID_TOKEN": "Invalid authentication token",
"MIDDLEWARE_INVALID_SESSION": "Invalid session",
"CLIENT_FETCH_ERROR": "Error fetching data",
"CLIENT_NETWORK_ERROR": "Network error",
"CLIENT_REQUEST_FAILED": "Request failed",
"GENERIC_ERROR": "An error occurred"
},
"reader": {
@@ -460,6 +452,9 @@
"header": {
"toggleSidebar": "Toggle sidebar",
"toggleTheme": "Toggle theme",
"anonymousMode": "Anonymous mode",
"anonymousModeOn": "Anonymous mode enabled",
"anonymousModeOff": "Anonymous mode disabled",
"search": {
"placeholder": "Search series and books...",
"empty": "No results",

View File

@@ -35,6 +35,7 @@
},
"title": "Accueil",
"sections": {
"favorites": "Séries favorites",
"continue_series": "Continuer la série",
"continue_reading": "Continuer la lecture",
"up_next": "À suivre",
@@ -134,7 +135,9 @@
},
"error": {
"title": "Erreur lors de la sauvegarde de la configuration",
"message": "Une erreur est survenue lors de la sauvegarde de la configuration"
"message": "Une erreur est survenue lors de la sauvegarde de la configuration",
"connectionTitle": "Erreur de connexion",
"connectionMessage": "Impossible de se connecter au serveur Komga. Vérifiez l'URL et les identifiants."
}
},
"cache": {
@@ -257,7 +260,10 @@
"favorite": {
"add": "Ajouté aux favoris",
"remove": "Retiré des favoris"
}
},
"showMore": "Voir plus",
"showLess": "Voir moins",
"missing": "{{count}} manquant(s)"
}
},
"books": {
@@ -359,7 +365,6 @@
"errors": {
"MONGODB_MISSING_URI": "URI MongoDB manquante",
"MONGODB_CONNECTION_FAILED": "Erreur lors de la connexion à MongoDB",
"AUTH_UNAUTHENTICATED": "Non authentifié",
"AUTH_INVALID_CREDENTIALS": "Identifiants invalides",
"AUTH_PASSWORD_NOT_STRONG": "Le mot de passe n'est pas assez fort",
@@ -369,7 +374,6 @@
"AUTH_LOGOUT_ERROR": "Erreur lors de la déconnexion",
"AUTH_LOGIN_ERROR": "Erreur lors de la connexion",
"AUTH_REGISTER_ERROR": "Erreur lors de l'inscription",
"KOMGA_MISSING_CONFIG": "Configuration Komga manquante",
"KOMGA_MISSING_CREDENTIALS": "Identifiants Komga manquants",
"KOMGA_CONNECTION_ERROR": "Erreur de connexion au serveur Komga",
@@ -378,25 +382,20 @@
"STRIPSTREAM_MISSING_CONFIG": "Configuration Stripstream Librarian manquante",
"STRIPSTREAM_CONNECTION_ERROR": "Erreur de connexion à Stripstream Librarian",
"STRIPSTREAM_HTTP_ERROR": "Erreur HTTP lors de la communication avec Stripstream Librarian",
"CONFIG_SAVE_ERROR": "Erreur lors de la sauvegarde de la configuration",
"CONFIG_FETCH_ERROR": "Erreur lors de la récupération de la configuration",
"CONFIG_TTL_SAVE_ERROR": "Erreur lors de la sauvegarde des TTL",
"CONFIG_TTL_FETCH_ERROR": "Erreur lors de la récupération des TTL",
"LIBRARY_NOT_FOUND": "Bibliothèque introuvable",
"LIBRARY_FETCH_ERROR": "Erreur lors de la récupération de la bibliothèque",
"LIBRARY_SCAN_ERROR": "Erreur lors de l'analyse de la bibliothèque",
"SERIES_FETCH_ERROR": "Erreur lors de la récupération des séries",
"SERIES_NO_BOOKS_FOUND": "Aucun livre trouvé dans la série",
"BOOK_NOT_FOUND": "Livre introuvable",
"BOOK_PROGRESS_UPDATE_ERROR": "Erreur lors de la mise à jour de la progression",
"BOOK_PROGRESS_DELETE_ERROR": "Erreur lors de la suppression de la progression",
"BOOK_PAGES_FETCH_ERROR": "Erreur lors de la récupération des pages du livre",
"BOOK_DOWNLOAD_CANCELLED": "Téléchargement du livre annulé",
"FAVORITE_ADD_ERROR": "Erreur lors de l'ajout aux favoris",
"FAVORITE_DELETE_ERROR": "Erreur lors de la suppression des favoris",
"FAVORITE_FETCH_ERROR": "Erreur lors de la récupération des favoris",
@@ -404,26 +403,19 @@
"FAVORITE_NETWORK_ERROR": "Erreur réseau lors de l'accès aux favoris",
"FAVORITE_SERVER_ERROR": "Erreur serveur lors de l'accès aux favoris",
"FAVORITE_STATUS_CHECK_ERROR": "Erreur lors de la vérification du statut des favoris",
"PREFERENCES_FETCH_ERROR": "Erreur lors de la récupération des préférences",
"PREFERENCES_UPDATE_ERROR": "Erreur lors de la mise à jour des préférences",
"PREFERENCES_CONTEXT_ERROR": "Erreur de contexte des préférences",
"UI_TABS_TRIGGER_ERROR": "Erreur lors du déclenchement des onglets",
"UI_TABS_CONTENT_ERROR": "Erreur lors du chargement du contenu des onglets",
"IMAGE_FETCH_ERROR": "Erreur lors de la récupération de l'image",
"HOME_FETCH_ERROR": "Erreur lors de la récupération de l'accueil",
"MIDDLEWARE_UNAUTHORIZED": "Non autorisé",
"MIDDLEWARE_INVALID_TOKEN": "Jeton d'authentification invalide",
"MIDDLEWARE_INVALID_SESSION": "Session invalide",
"CLIENT_FETCH_ERROR": "Erreur lors de la récupération des données",
"CLIENT_NETWORK_ERROR": "Erreur réseau",
"CLIENT_REQUEST_FAILED": "La requête a échoué",
"GENERIC_ERROR": "Une erreur est survenue"
},
"reader": {
@@ -458,6 +450,9 @@
"header": {
"toggleSidebar": "Afficher/masquer le menu latéral",
"toggleTheme": "Changer le thème",
"anonymousMode": "Mode anonyme",
"anonymousModeOn": "Mode anonyme activé",
"anonymousModeOff": "Mode anonyme désactivé",
"search": {
"placeholder": "Rechercher séries et tomes...",
"empty": "Aucun résultat",

View File

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

View File

@@ -17,6 +17,7 @@ import type { LibraryResponse } from "@/types/library";
import type { AuthConfig } from "@/types/auth";
import logger from "@/lib/logger";
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG, SERIES_BOOKS_CACHE_TAG } from "@/constants/cacheConstants";
import { unstable_cache } from "next/cache";
type KomgaCondition = Record<string, unknown>;
@@ -64,7 +65,6 @@ export class KomgaProvider implements IMediaProvider {
const isDebug = process.env.KOMGA_DEBUG === "true";
const isCacheDebug = process.env.CACHE_DEBUG === "true";
const startTime = isDebug ? Date.now() : 0;
if (isDebug) {
logger.info(
@@ -72,8 +72,14 @@ export class KomgaProvider implements IMediaProvider {
"🔵 Komga Request"
);
}
if (isCacheDebug && options.revalidate) {
logger.info({ url, cache: "enabled", ttl: options.revalidate }, "💾 Cache enabled");
if (isCacheDebug) {
if (options.tags) {
logger.info({ url, cache: "tags", tags: options.tags }, "💾 Cache tags");
} else if (options.revalidate !== undefined) {
logger.info({ url, cache: "revalidate", ttl: options.revalidate }, "💾 Cache revalidate");
} else {
logger.info({ url, cache: "none" }, "💾 Cache none");
}
}
const nextOptions = options.tags
@@ -88,6 +94,19 @@ export class KomgaProvider implements IMediaProvider {
next: nextOptions,
};
// Next.js does not cache POST fetch requests — use unstable_cache to cache results instead
if (options.method === "POST" && nextOptions) {
const cacheKey = ["komga", this.config.authHeader, url, String(options.body ?? "")];
return unstable_cache(() => this.executeRequest<T>(url, fetchOptions), cacheKey, nextOptions)();
}
return this.executeRequest<T>(url, fetchOptions);
}
private async executeRequest<T>(url: string, fetchOptions: RequestInit): Promise<T> {
const isDebug = process.env.KOMGA_DEBUG === "true";
const startTime = isDebug ? Date.now() : 0;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
@@ -124,10 +143,6 @@ export class KomgaProvider implements IMediaProvider {
"🟢 Komga Response"
);
}
if (isCacheDebug && options.revalidate) {
const cacheStatus = response.headers.get("x-nextjs-cache") ?? "UNKNOWN";
logger.info({ url, cacheStatus }, `💾 Cache ${cacheStatus}`);
}
if (!response.ok) {
if (isDebug) {
@@ -163,25 +178,7 @@ export class KomgaProvider implements IMediaProvider {
const raw = await this.fetch<KomgaLibrary[]>("libraries", undefined, {
revalidate: CACHE_TTL_LONG,
});
// Enrich with book counts
const enriched = await Promise.all(
raw.map(async (lib) => {
try {
const resp = await this.fetch<{ totalElements: number }>(
"books",
{
library_id: lib.id,
size: "0",
},
{ revalidate: CACHE_TTL_LONG }
);
return { ...lib, booksCount: resp.totalElements, booksReadCount: 0 } as KomgaLibrary;
} catch {
return { ...lib, booksCount: 0, booksReadCount: 0 } as KomgaLibrary;
}
})
);
return enriched.map(KomgaAdapter.toNormalizedLibrary);
return raw.map(KomgaAdapter.toNormalizedLibrary);
}
async getSeries(libraryId: string, cursor?: string, limit = 20, unreadOnly = false, search?: string): Promise<NormalizedSeriesPage> {
@@ -356,30 +353,8 @@ export class KomgaProvider implements IMediaProvider {
}
async getLibraryById(libraryId: string): Promise<NormalizedLibrary | null> {
try {
const lib = await this.fetch<KomgaLibrary>(`libraries/${libraryId}`, undefined, {
revalidate: CACHE_TTL_LONG,
});
try {
const resp = await this.fetch<{ totalElements: number }>(
"books",
{
library_id: lib.id,
size: "0",
},
{ revalidate: CACHE_TTL_LONG }
);
return KomgaAdapter.toNormalizedLibrary({
...lib,
booksCount: resp.totalElements,
booksReadCount: 0,
});
} catch {
return KomgaAdapter.toNormalizedLibrary({ ...lib, booksCount: 0, booksReadCount: 0 });
}
} catch {
return null;
}
const libraries = await this.getLibraries();
return libraries.find((lib) => lib.id === libraryId) ?? null;
}
async getNextBook(bookId: string): Promise<NormalizedBook | null> {
@@ -398,7 +373,14 @@ export class KomgaProvider implements IMediaProvider {
}
async getHomeData(): Promise<HomeData> {
const homeOpts = { revalidate: CACHE_TTL_MED, tags: [HOME_CACHE_TAG] };
return unstable_cache(
() => this.fetchHomeData(),
["komga-home", this.config.authHeader],
{ revalidate: CACHE_TTL_MED, tags: [HOME_CACHE_TAG] }
)();
}
private async fetchHomeData(): Promise<HomeData> {
const [ongoing, ongoingBooks, recentlyRead, onDeck, latestSeries] = await Promise.all([
this.fetch<LibraryResponse<KomgaSeries>>(
"series/list",
@@ -408,7 +390,6 @@ export class KomgaProvider implements IMediaProvider {
body: JSON.stringify({
condition: { readStatus: { operator: "is", value: "IN_PROGRESS" } },
}),
...homeOpts,
}
).catch(() => ({ content: [] as KomgaSeries[] })),
this.fetch<LibraryResponse<KomgaBook>>(
@@ -419,23 +400,19 @@ export class KomgaProvider implements IMediaProvider {
body: JSON.stringify({
condition: { readStatus: { operator: "is", value: "IN_PROGRESS" } },
}),
...homeOpts,
}
).catch(() => ({ content: [] as KomgaBook[] })),
this.fetch<LibraryResponse<KomgaBook>>(
"books/latest",
{ page: "0", size: "10", media_status: "READY" },
{ ...homeOpts }
{ page: "0", size: "10", media_status: "READY" }
).catch(() => ({ content: [] as KomgaBook[] })),
this.fetch<LibraryResponse<KomgaBook>>(
"books/ondeck",
{ page: "0", size: "10", media_status: "READY" },
{ ...homeOpts }
{ page: "0", size: "10", media_status: "READY" }
).catch(() => ({ content: [] as KomgaBook[] })),
this.fetch<LibraryResponse<KomgaSeries>>(
"series/latest",
{ page: "0", size: "10", media_status: "READY" },
{ ...homeOpts }
{ page: "0", size: "10", media_status: "READY" }
).catch(() => ({ content: [] as KomgaSeries[] })),
]);
return {

View File

@@ -34,7 +34,10 @@ export class StripstreamAdapter {
volume: book.volume ?? null,
pageCount: book.page_count ?? 0,
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,
completed: book.reading_status === "read",
lastReadAt: book.reading_last_read_at ?? null,
@@ -52,7 +55,10 @@ export class StripstreamAdapter {
volume: book.volume ?? null,
pageCount: book.page_count ?? 0,
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,
completed: book.reading_status === "read",
lastReadAt: book.reading_last_read_at ?? null,
@@ -67,11 +73,13 @@ export class StripstreamAdapter {
bookCount: series.book_count,
booksReadCount: series.books_read_count,
thumbnailUrl: `/api/stripstream/images/books/${series.first_book_id}/thumbnail`,
libraryId: series.library_id,
summary: null,
authors: [],
genres: [],
tags: [],
createdAt: null,
missingCount: series.missing_count ?? null,
};
}

View File

@@ -3,6 +3,7 @@ import { ERROR_CODES } from "@/constants/errorCodes";
import logger from "@/lib/logger";
const TIMEOUT_MS = 15000;
const IMAGE_TIMEOUT_MS = 60000;
interface FetchErrorLike { code?: string; cause?: { code?: string } }
@@ -59,8 +60,14 @@ export class StripstreamClient {
"🔵 Stripstream Request"
);
}
if (isCacheDebug && options.revalidate) {
logger.info({ url, cache: "enabled", ttl: options.revalidate }, "💾 Cache enabled");
if (isCacheDebug) {
if (options.tags) {
logger.info({ url, cache: "tags", tags: options.tags }, "💾 Cache tags");
} else if (options.revalidate !== undefined) {
logger.info({ url, cache: "revalidate", ttl: options.revalidate }, "💾 Cache revalidate");
} else {
logger.info({ url, cache: "none" }, "💾 Cache none");
}
}
const nextOptions = options.tags
@@ -106,10 +113,6 @@ export class StripstreamClient {
"🟢 Stripstream Response"
);
}
if (isCacheDebug && options.revalidate) {
const cacheStatus = response.headers.get("x-nextjs-cache") ?? "UNKNOWN";
logger.info({ url, cacheStatus }, `💾 Cache ${cacheStatus}`);
}
if (!response.ok) {
if (isDebug) {
@@ -147,7 +150,7 @@ export class StripstreamClient {
Accept: "image/webp, image/jpeg, image/png, */*",
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
const timeoutId = setTimeout(() => controller.abort(), IMAGE_TIMEOUT_MS);
try {
const response = await fetch(url, { headers, signal: controller.signal });
if (!response.ok) {

View File

@@ -12,15 +12,16 @@ import type {
import type { HomeData } from "@/types/home";
import { StripstreamClient } from "./stripstream.client";
import { StripstreamAdapter } from "./stripstream.adapter";
import { ERROR_CODES } from "@/constants/errorCodes";
import { AppError } from "@/utils/errors";
import type {
StripstreamLibraryResponse,
StripstreamBooksPage,
StripstreamSeriesPage,
StripstreamBookItem,
StripstreamSeriesItem,
StripstreamBookDetails,
StripstreamReadingProgressResponse,
StripstreamSearchResponse,
StripstreamSeriesMetadata,
} from "@/types/stripstream";
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG, SERIES_BOOKS_CACHE_TAG } from "@/constants/cacheConstants";
@@ -84,18 +85,25 @@ export class StripstreamProvider implements IMediaProvider {
revalidate: CACHE_TTL_MED,
});
if (!book.series) return null;
return {
// Try to find series in library to get real book counts
const seriesInfo = await this.findSeriesByName(book.series, book.library_id);
if (seriesInfo) return seriesInfo;
const fallback: NormalizedSeries = {
id: seriesId,
name: book.series,
bookCount: 0,
booksReadCount: 0,
thumbnailUrl: `/api/stripstream/images/books/${seriesId}/thumbnail`,
libraryId: book.library_id,
summary: null,
authors: [],
genres: [],
tags: [],
createdAt: null,
};
return this.enrichSeriesWithMetadata(fallback, book.library_id, book.series);
} catch {
// Fall back: treat seriesId as a series name, find its first book
try {
@@ -106,24 +114,81 @@ export class StripstreamProvider implements IMediaProvider {
);
if (!page.items.length) return null;
const firstBook = page.items[0];
return {
const seriesInfo = await this.findSeriesByName(seriesId, firstBook.library_id);
if (seriesInfo) return seriesInfo;
const fallback: NormalizedSeries = {
id: firstBook.id,
name: seriesId,
bookCount: 0,
booksReadCount: 0,
thumbnailUrl: `/api/stripstream/images/books/${firstBook.id}/thumbnail`,
libraryId: firstBook.library_id,
summary: null,
authors: [],
genres: [],
tags: [],
createdAt: null,
};
return this.enrichSeriesWithMetadata(fallback, firstBook.library_id, seriesId);
} catch {
return null;
}
}
}
private async findSeriesByName(seriesName: string, libraryId: string): Promise<NormalizedSeries | null> {
try {
const seriesPage = await this.client.fetch<StripstreamSeriesPage>(
`libraries/${libraryId}/series`,
{ q: seriesName, limit: "10" },
{ revalidate: CACHE_TTL_MED }
);
const match = seriesPage.items.find((s) => s.name === seriesName);
if (match) {
const normalized = StripstreamAdapter.toNormalizedSeries(match);
return this.enrichSeriesWithMetadata(normalized, libraryId, seriesName);
}
} catch {
// ignore
}
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> {
const limit = filter.limit ?? 24;
const params: Record<string, string | undefined> = { limit: String(limit) };
@@ -195,36 +260,36 @@ export class StripstreamProvider implements IMediaProvider {
}
async getHomeData(): Promise<HomeData> {
// Stripstream has no "in-progress" filter — show recent books and first library's series
const homeOpts = { revalidate: CACHE_TTL_MED, tags: [HOME_CACHE_TAG] };
const [booksPage, libraries] = await Promise.allSettled([
this.client.fetch<StripstreamBooksPage>("books", { limit: "10" }, homeOpts),
this.client.fetch<StripstreamLibraryResponse[]>("libraries", undefined, { revalidate: CACHE_TTL_LONG, tags: [HOME_CACHE_TAG] }),
const [ongoingBooksResult, ongoingSeriesResult, booksPage, latestSeriesResult] = await Promise.allSettled([
this.client.fetch<StripstreamBookItem[]>("books/ongoing", { limit: "20" }, homeOpts),
this.client.fetch<StripstreamSeriesItem[]>("series/ongoing", { limit: "10" }, homeOpts),
this.client.fetch<StripstreamBooksPage>("books", { sort: "latest", limit: "10" }, homeOpts),
this.client.fetch<StripstreamSeriesPage>("series", { sort: "latest", limit: "10" }, homeOpts),
]);
const books = booksPage.status === "fulfilled"
// /books/ongoing returns both currently reading and next unread per series
const ongoingBooks = ongoingBooksResult.status === "fulfilled"
? ongoingBooksResult.value.map(StripstreamAdapter.toNormalizedBook)
: [];
const ongoingSeries = ongoingSeriesResult.status === "fulfilled"
? ongoingSeriesResult.value.map(StripstreamAdapter.toNormalizedSeries)
: [];
const recentlyRead = booksPage.status === "fulfilled"
? booksPage.value.items.map(StripstreamAdapter.toNormalizedBook)
: [];
let latestSeries: NormalizedSeries[] = [];
if (libraries.status === "fulfilled" && libraries.value.length > 0) {
try {
const seriesPage = await this.client.fetch<StripstreamSeriesPage>(
`libraries/${libraries.value[0].id}/series`,
{ limit: "10" },
homeOpts
);
latestSeries = seriesPage.items.map(StripstreamAdapter.toNormalizedSeries);
} catch {
latestSeries = [];
}
}
const latestSeries = latestSeriesResult.status === "fulfilled"
? latestSeriesResult.value.items.map(StripstreamAdapter.toNormalizedSeries)
: [];
return {
ongoing: latestSeries,
ongoingBooks: books,
recentlyRead: books,
onDeck: [],
ongoing: ongoingSeries,
ongoingBooks: [],
recentlyRead,
onDeck: ongoingBooks,
latestSeries,
};
}

View File

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

View File

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

View File

@@ -118,11 +118,14 @@ body.no-pinch-zoom * {
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,
textarea,
select {
font-size: 16px;
font-size: 16px !important;
}
}

View File

@@ -1,6 +1,7 @@
import type { NormalizedBook, NormalizedSeries } from "@/lib/providers/types";
export interface HomeData {
favorites?: NormalizedSeries[];
ongoing: NormalizedSeries[];
ongoingBooks: NormalizedBook[];
recentlyRead: NormalizedBook[];

View File

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

View File

@@ -48,6 +48,8 @@ export interface StripstreamSeriesItem {
book_count: number;
books_read_count: number;
first_book_id: string;
library_id: string;
missing_count?: number | null;
}
export interface StripstreamSeriesPage {
@@ -80,6 +82,15 @@ export interface StripstreamUpdateReadingProgressRequest {
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 {
hits: StripstreamSearchHit[];
series_hits: StripstreamSeriesHit[];