Compare commits

...

26 Commits

Author SHA1 Message Date
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
8d1f91d636 feat: optimize Docker startup with Next.js standalone output and proper migrations
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
- Add `output: standalone` to next.config.js for faster cold start
- Rebuild runner stage around standalone bundle (node server.js instead of pnpm start)
- Replace prisma db push with prisma migrate deploy (proper migration workflow)
- Remove npx/pnpm at runtime, use direct binary paths
- Add HOSTNAME=0.0.0.0 for standalone server to listen on all interfaces
- Fix next.config.js not copied in builder stage
- Update README: pnpm instead of yarn, correct ports, full env vars documentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 21:52:49 +01:00
7e4c48469a feat: enhance Stripstream configuration handling
- Introduced a new resolver function to streamline fetching Stripstream configuration from the database or environment variables.
- Updated various components and API routes to utilize the new configuration resolver, improving code maintainability and reducing direct database calls.
- Added optional environment variables for Stripstream URL and token in the .env.example file.
- Refactored image loading logic in the reader components to improve performance and error handling.
2026-03-11 21:25:58 +01:00
94 changed files with 894 additions and 421 deletions

View File

@@ -5,4 +5,8 @@ MONGODB_URI=mongodb://admin:password@host.docker.internal:27017/stripstream?auth
NEXTAUTH_SECRET=SECRET
#openssl rand -base64 32
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_URL=http://localhost:3000
# Stripstream Librarian (optionnel : fallback si l'utilisateur n'a pas sauvegardé d'URL/token en base)
# STRIPSTREAM_URL=https://librarian.example.com
# STRIPSTREAM_TOKEN=stl_xxxx_xxxxxxxx

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

@@ -17,7 +17,7 @@ COPY package.json pnpm-lock.yaml ./
COPY prisma ./prisma
# Copy configuration files
COPY tsconfig.json .eslintrc.json ./
COPY tsconfig.json .eslintrc.json next.config.js ./
COPY tailwind.config.ts postcss.config.js ./
# Install dependencies with pnpm using cache mount for store
@@ -43,22 +43,20 @@ WORKDIR /app
# Install OpenSSL (required by Prisma)
RUN apk add --no-cache openssl libc6-compat
# Copy package files and prisma schema
COPY package.json pnpm-lock.yaml ./
COPY prisma ./prisma
# Copy standalone output (server.js + minimal node_modules)
COPY --from=builder /app/.next/standalone ./
# Enable pnpm
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
# Copy static assets and public directory
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
# Copy the entire node_modules from builder (includes Prisma Client)
# Copy full node_modules for Prisma CLI (pnpm symlinks prevent cherry-picking)
COPY --from=builder /app/node_modules ./node_modules
# Copy built application from builder stage
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/next-env.d.ts ./
COPY --from=builder /app/tailwind.config.ts ./
# Copy prisma schema and init scripts
COPY prisma ./prisma
COPY --from=builder /app/scripts ./scripts
COPY package.json ./
# Copy entrypoint script
COPY docker-entrypoint.sh ./
@@ -76,6 +74,7 @@ USER nextjs
# Set environment variables
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME="0.0.0.0"
# Expose the port the app runs on
EXPOSE 3000

View File

@@ -74,7 +74,7 @@ A modern web application for reading digital comics, built with Next.js 14 and t
## 🛠 Prerequisites
- Node.js 20.x or higher
- Yarn 1.22.x or higher
- pnpm 9.x or higher
- Docker and Docker Compose (optional)
## 📦 Installation
@@ -91,7 +91,7 @@ cd stripstream
2. Install dependencies
```bash
yarn install
pnpm install
```
3. Copy the example environment file and adjust it to your needs
@@ -103,7 +103,7 @@ cp .env.example .env.local
4. Start the development server
```bash
yarn dev
pnpm dev
```
### With Docker (Build Local)
@@ -121,7 +121,7 @@ cd stripstream
docker-compose up --build
```
The application will be accessible at `http://localhost:3000`
The application will be accessible at `http://localhost:3020`
### With Docker (DockerHub Image)
@@ -130,18 +130,24 @@ You can also use the pre-built image from DockerHub without cloning the reposito
1. Create a `docker-compose.yml` file:
```yaml
version: '3.8'
services:
app:
image: julienfroidefond32/stripstream:latest
ports:
- "3000:3000"
environment:
- NODE_ENV=production
# Add your environment variables here or use an .env file
# Required
- NEXTAUTH_SECRET=your_secret_here # openssl rand -base64 32
- NEXTAUTH_URL=http://localhost:3000
# Optional — defaults shown
# - NODE_ENV=production
# - DATABASE_URL=file:/app/prisma/data/stripstream.db
# - ADMIN_DEFAULT_PASSWORD=Admin@2025
# - AUTH_TRUST_HOST=true
# - KOMGA_MAX_CONCURRENT_REQUESTS=5
volumes:
- ./data:/app/data
- ./data:/app/prisma/data
restart: unless-stopped
```
@@ -155,11 +161,10 @@ The application will be accessible at `http://localhost:3000`
## 🔧 Available Scripts
- `yarn dev` - Starts the development server
- `yarn build` - Creates a production build
- `yarn start` - Runs the production version
- `yarn lint` - Checks code with ESLint
- `yarn format` - Formats code with Prettier
- `pnpm dev` - Starts the development server
- `pnpm build` - Creates a production build
- `pnpm start` - Runs the production version
- `pnpm lint` - Checks code with ESLint
- `./docker-push.sh [tag]` - Build and push Docker image to DockerHub (default tag: `latest`)
### Docker Push Script

View File

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

View File

@@ -1,15 +1,11 @@
#!/bin/sh
set -e
echo "📁 Ensuring data directory exists..."
mkdir -p /app/data
echo "🔄 Pushing Prisma schema to database..."
npx prisma db push --skip-generate --accept-data-loss
echo "🔄 Applying database migrations..."
./node_modules/.bin/prisma migrate deploy
echo "🔧 Initializing database..."
node scripts/init-db.mjs
echo "🚀 Starting application..."
exec pnpm start
exec node server.js

View File

@@ -1,5 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
webpack: (config) => {
config.resolve.fallback = {
...config.resolve.fallback,

View File

@@ -0,0 +1,77 @@
-- CreateTable
CREATE TABLE "users" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"roles" JSONB NOT NULL DEFAULT ["ROLE_USER"],
"authenticated" BOOLEAN NOT NULL DEFAULT true,
"activeProvider" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "komgaconfigs" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"userId" INTEGER NOT NULL,
"url" TEXT NOT NULL,
"username" TEXT NOT NULL,
"authHeader" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "komgaconfigs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "stripstreamconfigs" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"userId" INTEGER NOT NULL,
"url" TEXT NOT NULL,
"token" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "stripstreamconfigs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "preferences" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"userId" INTEGER NOT NULL,
"showThumbnails" BOOLEAN NOT NULL DEFAULT true,
"showOnlyUnread" BOOLEAN NOT NULL DEFAULT false,
"displayMode" JSONB NOT NULL,
"background" JSONB NOT NULL,
"readerPrefetchCount" INTEGER NOT NULL DEFAULT 5,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "preferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "favorites" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"userId" INTEGER NOT NULL,
"seriesId" TEXT NOT NULL,
"provider" TEXT NOT NULL DEFAULT 'komga',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "favorites_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "komgaconfigs_userId_key" ON "komgaconfigs"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "stripstreamconfigs_userId_key" ON "stripstreamconfigs"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "preferences_userId_key" ON "preferences"("userId");
-- CreateIndex
CREATE INDEX "favorites_userId_idx" ON "favorites"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "favorites_userId_provider_seriesId_key" ON "favorites"("userId", "provider", "seriesId");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

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

@@ -38,7 +38,7 @@ export async function testKomgaConnection(
message: `Connexion réussie ! ${libraries.length} bibliothèque${libraries.length > 1 ? "s" : ""} trouvée${libraries.length > 1 ? "s" : ""}`,
};
} catch (error) {
if (error instanceof AppError) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors de la connexion" };
@@ -59,7 +59,7 @@ export async function saveKomgaConfig(
revalidatePath("/settings");
return { success: true, message: "Configuration sauvegardée", data: mongoConfig };
} catch (error) {
if (error instanceof AppError) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors de la sauvegarde" };

View File

@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
import prisma from "@/lib/prisma";
import { getCurrentUser } from "@/lib/auth-utils";
import { StripstreamProvider } from "@/lib/providers/stripstream/stripstream.provider";
import { getResolvedStripstreamConfig } from "@/lib/providers/stripstream/stripstream-config-resolver";
import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
import type { ProviderType } from "@/lib/providers/types";
@@ -81,8 +82,8 @@ export async function setActiveProvider(
if (!config) {
return { success: false, message: "Komga n'est pas encore configuré" };
}
} else if (provider === "stripstream") {
const config = await prisma.stripstreamConfig.findUnique({ where: { userId } });
} else if (provider === "stripstream") {
const config = await getResolvedStripstreamConfig(userId);
if (!config) {
return { success: false, message: "Stripstream n'est pas encore configuré" };
}
@@ -108,7 +109,8 @@ export async function setActiveProvider(
}
/**
* Récupère la configuration Stripstream de l'utilisateur
* Récupère la configuration Stripstream de l'utilisateur (affichage settings).
* Priorité : config en base, sinon env STRIPSTREAM_URL / STRIPSTREAM_TOKEN.
*/
export async function getStripstreamConfig(): Promise<{
url?: string;
@@ -119,13 +121,9 @@ export async function getStripstreamConfig(): Promise<{
if (!user) return null;
const userId = parseInt(user.id, 10);
const config = await prisma.stripstreamConfig.findUnique({
where: { userId },
select: { url: true },
});
if (!config) return null;
return { url: config.url, hasToken: true };
const resolved = await getResolvedStripstreamConfig(userId);
if (!resolved) return null;
return { url: resolved.url, hasToken: true };
} catch {
return null;
}
@@ -166,15 +164,15 @@ export async function getProvidersStatus(): Promise<{
}
const userId = parseInt(user.id, 10);
const [dbUser, komgaConfig, stripstreamConfig] = await Promise.all([
const [dbUser, komgaConfig, stripstreamResolved] = await Promise.all([
prisma.user.findUnique({ where: { id: userId }, select: { activeProvider: true } }),
prisma.komgaConfig.findUnique({ where: { userId }, select: { id: true } }),
prisma.stripstreamConfig.findUnique({ where: { userId }, select: { id: true } }),
getResolvedStripstreamConfig(userId),
]);
return {
komgaConfigured: !!komgaConfig,
stripstreamConfigured: !!stripstreamConfig,
stripstreamConfigured: !!stripstreamResolved,
activeProvider: (dbUser?.activeProvider as ProviderType) ?? "komga",
};
} catch {

View File

@@ -1,7 +1,7 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth-utils";
import prisma from "@/lib/prisma";
import { getResolvedStripstreamConfig } from "@/lib/providers/stripstream/stripstream-config-resolver";
import { StripstreamClient } from "@/lib/providers/stripstream/stripstream.client";
import { ERROR_CODES } from "@/constants/errorCodes";
import { AppError } from "@/utils/errors";
@@ -23,7 +23,7 @@ export async function GET(
}
const userId = parseInt(user.id, 10);
const config = await prisma.stripstreamConfig.findUnique({ where: { userId } });
const config = await getResolvedStripstreamConfig(userId);
if (!config) {
throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG);
}
@@ -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: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400",
},
});
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

@@ -1,7 +1,7 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth-utils";
import prisma from "@/lib/prisma";
import { getResolvedStripstreamConfig } from "@/lib/providers/stripstream/stripstream-config-resolver";
import { StripstreamClient } from "@/lib/providers/stripstream/stripstream.client";
import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
@@ -20,7 +20,7 @@ export async function GET(
}
const userId = parseInt(user.id, 10);
const config = await prisma.stripstreamConfig.findUnique({ where: { userId } });
const config = await getResolvedStripstreamConfig(userId);
if (!config) {
throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG);
}

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,13 +314,15 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<AuthProvider>
<I18nProvider locale={locale}>
<PreferencesProvider initialPreferences={preferences}>
<ClientLayout
initialLibraries={libraries}
initialFavorites={favorites}
userIsAdmin={userIsAdmin}
>
{children}
</ClientLayout>
<AnonymousProvider>
<ClientLayout
initialLibraries={libraries}
initialFavorites={favorites}
userIsAdmin={userIsAdmin}
>
{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

@@ -1,7 +1,6 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { signIn } from "next-auth/react";
import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { useTranslate } from "@/hooks/useTranslate";
@@ -16,7 +15,6 @@ interface LoginFormProps {
}
export function LoginForm({ from }: LoginFormProps) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<AppErrorType | null>(null);
const { t } = useTranslate();
@@ -57,8 +55,7 @@ export function LoginForm({ from }: LoginFormProps) {
}
const redirectPath = getSafeRedirectPath(from);
window.location.assign(redirectPath);
router.refresh();
window.location.href = redirectPath;
} catch {
setError({
code: "AUTH_FETCH_ERROR",

View File

@@ -1,7 +1,6 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { signIn } from "next-auth/react";
import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { useTranslate } from "@/hooks/useTranslate";
@@ -16,7 +15,6 @@ interface RegisterFormProps {
}
export function RegisterForm({ from }: RegisterFormProps) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<AppErrorType | null>(null);
const { t } = useTranslate();
@@ -77,8 +75,7 @@ export function RegisterForm({ from }: RegisterFormProps) {
});
} else {
const redirectPath = getSafeRedirectPath(from);
window.location.assign(redirectPath);
router.refresh();
window.location.href = redirectPath;
}
} catch {
setError({

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,24 +75,27 @@ 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">
<span
className={`px-2 py-0.5 rounded-full text-xs ${
getReadingStatusInfo(seriesItem, t).className
}`}
>
{getReadingStatusInfo(seriesItem, t).label}
</span>
{!isAnonymous && (
<span
className={`px-2 py-0.5 rounded-full text-xs ${
getReadingStatusInfo(seriesItem, t).className
}`}
>
{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,14 +96,16 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<h3 className="font-medium text-sm sm:text-base line-clamp-1 hover:text-primary transition-colors flex-1 min-w-0">
{series.name}
</h3>
<span
className={cn(
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
statusInfo.className
)}
>
{statusInfo.label}
</span>
{statusInfo && (
<span
className={cn(
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
statusInfo.className
)}
>
{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,14 +159,16 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
</div>
{/* Badge de statut */}
<span
className={cn(
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
statusInfo.className
)}
>
{statusInfo.label}
</span>
{statusInfo && (
<span
className={cn(
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
statusInfo.className
)}
>
{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

@@ -42,12 +42,14 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
const {
loadedImages,
imageBlobUrls,
prefetchImage,
prefetchPages,
prefetchNextBook,
cancelAllPrefetches,
handleForceReload,
getPageUrl,
prefetchCount,
isPageLoading,
} = useImageLoader({
pageUrlBuilder: bookPageUrlBuilder,
pages,
@@ -74,21 +76,56 @@ 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");
};
}, []);
// Prefetch current and next pages
useEffect(() => {
// Prefetch pages starting from current page
prefetchPages(currentPage, prefetchCount);
// Determine visible pages that need to be loaded immediately
const visiblePages: number[] = [];
if (isDoublePage && shouldShowDoublePage(currentPage, pages.length)) {
visiblePages.push(currentPage, currentPage + 1);
} else {
visiblePages.push(currentPage);
}
// Load visible pages first (priority) to avoid duplicate requests from <img> tags
// These will populate imageBlobUrls so <img> tags use blob URLs instead of making HTTP requests
const loadVisiblePages = async () => {
await Promise.all(visiblePages.map((page) => prefetchImage(page)));
};
loadVisiblePages().catch(() => {
// Silently fail - will fallback to direct HTTP requests
});
// Then prefetch other pages, excluding visible ones to avoid duplicates
const concurrency = isDoublePage && shouldShowDoublePage(currentPage, pages.length) ? 2 : 4;
prefetchPages(currentPage, prefetchCount, visiblePages, concurrency);
// If double page mode, also prefetch additional pages for smooth double page navigation
if (
@@ -96,7 +133,7 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
shouldShowDoublePage(currentPage, pages.length) &&
currentPage + prefetchCount < pages.length
) {
prefetchPages(currentPage + prefetchCount, 1);
prefetchPages(currentPage + prefetchCount, 1, visiblePages, concurrency);
}
// If we're near the end of the book, prefetch the next book
@@ -108,6 +145,7 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
currentPage,
isDoublePage,
shouldShowDoublePage,
prefetchImage,
prefetchPages,
prefetchNextBook,
prefetchCount,
@@ -227,7 +265,6 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
isDoublePage={isDoublePage}
shouldShowDoublePage={(page) => shouldShowDoublePage(page, pages.length)}
imageBlobUrls={imageBlobUrls}
getPageUrl={getPageUrl}
isRTL={isRTL}
/>

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,7 +7,6 @@ interface PageDisplayProps {
isDoublePage: boolean;
shouldShowDoublePage: (page: number) => boolean;
imageBlobUrls: Record<number, string>;
getPageUrl: (pageNum: number) => string;
isRTL: boolean;
}
@@ -17,13 +16,14 @@ export function PageDisplay({
isDoublePage,
shouldShowDoublePage,
imageBlobUrls,
getPageUrl,
isRTL,
}: 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);
@@ -43,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">
@@ -97,12 +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] || 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",
@@ -119,7 +134,7 @@ export function PageDisplay({
}}
/>
</>
)}
) : null}
</div>
{/* Page 2 (double page) */}
@@ -161,12 +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] || 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",
@@ -183,7 +198,7 @@ export function PageDisplay({
}}
/>
</>
)}
) : null}
</div>
)}
</div>

View File

@@ -30,6 +30,8 @@ export function useImageLoader({
// Track ongoing fetch requests to prevent duplicates
const pendingFetchesRef = useRef<Set<ImageKey>>(new Set());
const abortControllersRef = useRef<Map<ImageKey, AbortController>>(new Map());
// Track promises for pages being loaded so we can await them
const loadingPromisesRef = useRef<Map<ImageKey, Promise<void>>>(new Map());
// Keep refs in sync with state
useEffect(() => {
@@ -44,12 +46,14 @@ export function useImageLoader({
isMountedRef.current = true;
const abortControllers = abortControllersRef.current;
const pendingFetches = pendingFetchesRef.current;
const loadingPromises = loadingPromisesRef.current;
return () => {
isMountedRef.current = false;
abortControllers.forEach((controller) => controller.abort());
abortControllers.clear();
pendingFetches.clear();
loadingPromises.clear();
};
}, []);
@@ -57,6 +61,7 @@ export function useImageLoader({
abortControllersRef.current.forEach((controller) => controller.abort());
abortControllersRef.current.clear();
pendingFetchesRef.current.clear();
loadingPromisesRef.current.clear();
}, []);
const runWithConcurrency = useCallback(
@@ -92,73 +97,96 @@ export function useImageLoader({
return;
}
// Check if this page is already being fetched
if (pendingFetchesRef.current.has(pageNum)) {
return;
// Check if this page is already being fetched - if so, wait for it
const existingPromise = loadingPromisesRef.current.get(pageNum);
if (existingPromise) {
return existingPromise;
}
// Mark as pending
// Mark as pending and create promise
pendingFetchesRef.current.add(pageNum);
const controller = new AbortController();
abortControllersRef.current.set(pageNum, controller);
try {
// Use browser cache if available - the server sets Cache-Control headers
const response = await fetch(getPageUrl(pageNum), {
cache: "default", // Respect Cache-Control headers from server
signal: controller.signal,
});
if (!response.ok) {
return;
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// Create image to get dimensions
const img = new Image();
img.onload = () => {
if (!isMountedRef.current || controller.signal.aborted) {
URL.revokeObjectURL(blobUrl);
const promise = (async () => {
try {
// Use browser cache if available - the server sets Cache-Control headers
const response = await fetch(getPageUrl(pageNum), {
cache: "default", // Respect Cache-Control headers from server
signal: controller.signal,
});
if (!response.ok) {
return;
}
setLoadedImages((prev) => ({
...prev,
[pageNum]: { width: img.naturalWidth, height: img.naturalHeight },
}));
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// Store the blob URL for immediate use
setImageBlobUrls((prev) => ({
...prev,
[pageNum]: blobUrl,
}));
};
// Create image to get dimensions
const img = new Image();
// Wait for image to load before resolving promise
await new Promise<void>((resolve, reject) => {
img.onload = () => {
if (!isMountedRef.current || controller.signal.aborted) {
URL.revokeObjectURL(blobUrl);
reject(new Error("Aborted"));
return;
}
img.onerror = () => {
URL.revokeObjectURL(blobUrl);
};
setLoadedImages((prev) => ({
...prev,
[pageNum]: { width: img.naturalWidth, height: img.naturalHeight },
}));
img.src = blobUrl;
} catch {
// Silently fail prefetch
} finally {
// Remove from pending set
pendingFetchesRef.current.delete(pageNum);
abortControllersRef.current.delete(pageNum);
}
// Store the blob URL for immediate use
setImageBlobUrls((prev) => ({
...prev,
[pageNum]: blobUrl,
}));
resolve();
};
img.onerror = () => {
URL.revokeObjectURL(blobUrl);
reject(new Error("Image load error"));
};
img.src = blobUrl;
});
} catch {
// Silently fail prefetch
} finally {
// Remove from pending set and promise map
pendingFetchesRef.current.delete(pageNum);
abortControllersRef.current.delete(pageNum);
loadingPromisesRef.current.delete(pageNum);
}
})();
// Store promise so other calls can await it
loadingPromisesRef.current.set(pageNum, promise);
return promise;
},
[getPageUrl]
);
// Prefetch multiple pages starting from a given page
const prefetchPages = useCallback(
async (startPage: number, count: number = prefetchCount) => {
async (
startPage: number,
count: number = prefetchCount,
excludePages: number[] = [],
concurrency?: number
) => {
const pagesToPrefetch = [];
const excludeSet = new Set(excludePages);
for (let i = 0; i < count; i++) {
const pageNum = startPage + i;
if (pageNum <= _pages.length) {
if (pageNum <= _pages.length && !excludeSet.has(pageNum)) {
const hasDimensions = loadedImagesRef.current[pageNum];
const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
const isPending = pendingFetchesRef.current.has(pageNum);
@@ -170,10 +198,13 @@ export function useImageLoader({
}
}
// Use provided concurrency or default
const effectiveConcurrency = concurrency ?? PREFETCH_CONCURRENCY;
// Let all prefetch requests run - the server queue will manage concurrency
// The browser cache and our deduplication prevent redundant requests
if (pagesToPrefetch.length > 0) {
runWithConcurrency(pagesToPrefetch, prefetchImage).catch(() => {
runWithConcurrency(pagesToPrefetch, prefetchImage, effectiveConcurrency).catch(() => {
// Silently fail - prefetch is non-critical
});
}
@@ -340,6 +371,14 @@ export function useImageLoader({
};
}, []); // Empty dependency array - only cleanup on unmount
// Check if a page is currently being loaded
const isPageLoading = useCallback(
(pageNum: number) => {
return pendingFetchesRef.current.has(pageNum);
},
[]
);
return {
loadedImages,
imageBlobUrls,
@@ -350,5 +389,6 @@ export function useImageLoader({
handleForceReload,
getPageUrl,
prefetchCount,
isPageLoading,
};
}

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

@@ -1,5 +1,6 @@
import prisma from "@/lib/prisma";
import { getCurrentUser } from "@/lib/auth-utils";
import { getResolvedStripstreamConfig } from "./stripstream/stripstream-config-resolver";
import type { IMediaProvider } from "./provider.interface";
export async function getProvider(): Promise<IMediaProvider | null> {
@@ -13,7 +14,7 @@ export async function getProvider(): Promise<IMediaProvider | null> {
select: {
activeProvider: true,
config: { select: { url: true, authHeader: true } },
stripstreamConfig: { select: { url: true, token: true } },
stripstreamConfig: { select: { id: true } },
},
});
@@ -21,12 +22,12 @@ export async function getProvider(): Promise<IMediaProvider | null> {
const activeProvider = dbUser.activeProvider ?? "komga";
if (activeProvider === "stripstream" && dbUser.stripstreamConfig) {
const { StripstreamProvider } = await import("./stripstream/stripstream.provider");
return new StripstreamProvider(
dbUser.stripstreamConfig.url,
dbUser.stripstreamConfig.token
);
if (activeProvider === "stripstream") {
const resolved = await getResolvedStripstreamConfig(userId);
if (resolved) {
const { StripstreamProvider } = await import("./stripstream/stripstream.provider");
return new StripstreamProvider(resolved.url, resolved.token);
}
}
if (activeProvider === "komga" || !dbUser.activeProvider) {

View File

@@ -0,0 +1,26 @@
import prisma from "@/lib/prisma";
export interface ResolvedStripstreamConfig {
url: string;
token: string;
source: "db" | "env";
}
/**
* Résout la config Stripstream : d'abord en base (par utilisateur), sinon depuis les env STRIPSTREAM_URL et STRIPSTREAM_TOKEN.
*/
export async function getResolvedStripstreamConfig(
userId: number
): Promise<ResolvedStripstreamConfig | null> {
const fromDb = await prisma.stripstreamConfig.findUnique({
where: { userId },
select: { url: true, token: true },
});
if (fromDb) return { ...fromDb, source: "db" };
const url = process.env.STRIPSTREAM_URL?.trim();
const token = process.env.STRIPSTREAM_TOKEN?.trim();
if (url && token) return { url, token, source: "env" };
return null;
}

View File

@@ -34,11 +34,14 @@ export class StripstreamAdapter {
volume: book.volume ?? null,
pageCount: book.page_count ?? 0,
thumbnailUrl: `/api/stripstream/images/books/${book.id}/thumbnail`,
readProgress: {
page: book.reading_current_page ?? null,
completed: book.reading_status === "read",
lastReadAt: book.reading_last_read_at ?? null,
},
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,11 +55,14 @@ export class StripstreamAdapter {
volume: book.volume ?? null,
pageCount: book.page_count ?? 0,
thumbnailUrl: `/api/stripstream/images/books/${book.id}/thumbnail`,
readProgress: {
page: book.reading_current_page ?? null,
completed: book.reading_status === "read",
lastReadAt: book.reading_last_read_at ?? null,
},
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

@@ -39,7 +39,7 @@ export class ConfigDBService {
return config as KomgaConfig;
} catch (error) {
if (error instanceof AppError) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(ERROR_CODES.CONFIG.SAVE_ERROR, {}, error);

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;
}
}

4
src/types/env.d.ts vendored
View File

@@ -3,5 +3,9 @@ declare namespace NodeJS {
NEXT_PUBLIC_APP_URL: string;
NEXT_PUBLIC_DEFAULT_KOMGA_URL?: string;
NEXT_PUBLIC_APP_VERSION: string;
/** URL Stripstream Librarian (fallback si pas de config en base) */
STRIPSTREAM_URL?: string;
/** Token API Stripstream (fallback si pas de config en base) */
STRIPSTREAM_TOKEN?: string;
}
}

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