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 NEXTAUTH_SECRET=SECRET
#openssl rand -base64 32 #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: on:
push: push:
branches: branches:
- main # adapte la branche que tu veux déployer - main
jobs: jobs:
deploy: deploy:
runs-on: mac-orbstack-runner # le nom que tu as donné au runner runs-on: mac-orbstack-runner
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 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: env:
DOCKER_BUILDKIT: 1 DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1 run: docker build -t julienfroidefond32/stripstream:latest .
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL }} - name: Push to DockerHub
ADMIN_DEFAULT_PASSWORD: ${{ secrets.ADMIN_DEFAULT_PASSWORD }} run: docker push julienfroidefond32/stripstream:latest
DATABASE_URL: ${{ secrets.DATABASE_URL }}
PRISMA_DATA_PATH: ${{ vars.PRISMA_DATA_PATH }} - name: Pull new image and restart container
NODE_ENV: production
run: | 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 prisma ./prisma
# Copy configuration files # Copy configuration files
COPY tsconfig.json .eslintrc.json ./ COPY tsconfig.json .eslintrc.json next.config.js ./
COPY tailwind.config.ts postcss.config.js ./ COPY tailwind.config.ts postcss.config.js ./
# Install dependencies with pnpm using cache mount for store # Install dependencies with pnpm using cache mount for store
@@ -43,22 +43,20 @@ WORKDIR /app
# Install OpenSSL (required by Prisma) # Install OpenSSL (required by Prisma)
RUN apk add --no-cache openssl libc6-compat RUN apk add --no-cache openssl libc6-compat
# Copy package files and prisma schema # Copy standalone output (server.js + minimal node_modules)
COPY package.json pnpm-lock.yaml ./ COPY --from=builder /app/.next/standalone ./
COPY prisma ./prisma
# Enable pnpm # Copy static assets and public directory
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate 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 --from=builder /app/node_modules ./node_modules
# Copy built application from builder stage # Copy prisma schema and init scripts
COPY --from=builder /app/.next ./.next COPY prisma ./prisma
COPY --from=builder /app/public ./public
COPY --from=builder /app/next-env.d.ts ./
COPY --from=builder /app/tailwind.config.ts ./
COPY --from=builder /app/scripts ./scripts COPY --from=builder /app/scripts ./scripts
COPY package.json ./
# Copy entrypoint script # Copy entrypoint script
COPY docker-entrypoint.sh ./ COPY docker-entrypoint.sh ./
@@ -76,6 +74,7 @@ USER nextjs
# Set environment variables # Set environment variables
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME="0.0.0.0"
# Expose the port the app runs on # Expose the port the app runs on
EXPOSE 3000 EXPOSE 3000

View File

@@ -74,7 +74,7 @@ A modern web application for reading digital comics, built with Next.js 14 and t
## 🛠 Prerequisites ## 🛠 Prerequisites
- Node.js 20.x or higher - Node.js 20.x or higher
- Yarn 1.22.x or higher - pnpm 9.x or higher
- Docker and Docker Compose (optional) - Docker and Docker Compose (optional)
## 📦 Installation ## 📦 Installation
@@ -91,7 +91,7 @@ cd stripstream
2. Install dependencies 2. Install dependencies
```bash ```bash
yarn install pnpm install
``` ```
3. Copy the example environment file and adjust it to your needs 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 4. Start the development server
```bash ```bash
yarn dev pnpm dev
``` ```
### With Docker (Build Local) ### With Docker (Build Local)
@@ -121,7 +121,7 @@ cd stripstream
docker-compose up --build 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) ### 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: 1. Create a `docker-compose.yml` file:
```yaml ```yaml
version: '3.8'
services: services:
app: app:
image: julienfroidefond32/stripstream:latest image: julienfroidefond32/stripstream:latest
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
- NODE_ENV=production # Required
# Add your environment variables here or use an .env file - 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: volumes:
- ./data:/app/data - ./data:/app/prisma/data
restart: unless-stopped restart: unless-stopped
``` ```
@@ -155,11 +161,10 @@ The application will be accessible at `http://localhost:3000`
## 🔧 Available Scripts ## 🔧 Available Scripts
- `yarn dev` - Starts the development server - `pnpm dev` - Starts the development server
- `yarn build` - Creates a production build - `pnpm build` - Creates a production build
- `yarn start` - Runs the production version - `pnpm start` - Runs the production version
- `yarn lint` - Checks code with ESLint - `pnpm lint` - Checks code with ESLint
- `yarn format` - Formats code with Prettier
- `./docker-push.sh [tag]` - Build and push Docker image to DockerHub (default tag: `latest`) - `./docker-push.sh [tag]` - Build and push Docker image to DockerHub (default tag: `latest`)
### Docker Push Script ### Docker Push Script

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: "standalone",
webpack: (config) => { webpack: (config) => {
config.resolve.fallback = { config.resolve.fallback = {
...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 displayMode Json
background Json background Json
readerPrefetchCount Int @default(5) readerPrefetchCount Int @default(5)
anonymousMode Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

After

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 MiB

After

Width:  |  Height:  |  Size: 6.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

After

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 MiB

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

After

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 MiB

After

Width:  |  Height:  |  Size: 6.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

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

View File

@@ -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" : ""}`, message: `Connexion réussie ! ${libraries.length} bibliothèque${libraries.length > 1 ? "s" : ""} trouvée${libraries.length > 1 ? "s" : ""}`,
}; };
} catch (error) { } catch (error) {
if (error instanceof AppError) { if (error instanceof AppError) {
return { success: false, message: error.message }; return { success: false, message: error.message };
} }
return { success: false, message: "Erreur lors de la connexion" }; return { success: false, message: "Erreur lors de la connexion" };
@@ -59,7 +59,7 @@ export async function saveKomgaConfig(
revalidatePath("/settings"); revalidatePath("/settings");
return { success: true, message: "Configuration sauvegardée", data: mongoConfig }; return { success: true, message: "Configuration sauvegardée", data: mongoConfig };
} catch (error) { } catch (error) {
if (error instanceof AppError) { if (error instanceof AppError) {
return { success: false, message: error.message }; return { success: false, message: error.message };
} }
return { success: false, message: "Erreur lors de la sauvegarde" }; 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 prisma from "@/lib/prisma";
import { getCurrentUser } from "@/lib/auth-utils"; import { getCurrentUser } from "@/lib/auth-utils";
import { StripstreamProvider } from "@/lib/providers/stripstream/stripstream.provider"; import { StripstreamProvider } from "@/lib/providers/stripstream/stripstream.provider";
import { getResolvedStripstreamConfig } from "@/lib/providers/stripstream/stripstream-config-resolver";
import { AppError } from "@/utils/errors"; import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes"; import { ERROR_CODES } from "@/constants/errorCodes";
import type { ProviderType } from "@/lib/providers/types"; import type { ProviderType } from "@/lib/providers/types";
@@ -81,8 +82,8 @@ export async function setActiveProvider(
if (!config) { if (!config) {
return { success: false, message: "Komga n'est pas encore configuré" }; return { success: false, message: "Komga n'est pas encore configuré" };
} }
} else if (provider === "stripstream") { } else if (provider === "stripstream") {
const config = await prisma.stripstreamConfig.findUnique({ where: { userId } }); const config = await getResolvedStripstreamConfig(userId);
if (!config) { if (!config) {
return { success: false, message: "Stripstream n'est pas encore configuré" }; 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<{ export async function getStripstreamConfig(): Promise<{
url?: string; url?: string;
@@ -119,13 +121,9 @@ export async function getStripstreamConfig(): Promise<{
if (!user) return null; if (!user) return null;
const userId = parseInt(user.id, 10); const userId = parseInt(user.id, 10);
const config = await prisma.stripstreamConfig.findUnique({ const resolved = await getResolvedStripstreamConfig(userId);
where: { userId }, if (!resolved) return null;
select: { url: true }, return { url: resolved.url, hasToken: true };
});
if (!config) return null;
return { url: config.url, hasToken: true };
} catch { } catch {
return null; return null;
} }
@@ -166,15 +164,15 @@ export async function getProvidersStatus(): Promise<{
} }
const userId = parseInt(user.id, 10); 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.user.findUnique({ where: { id: userId }, select: { activeProvider: true } }),
prisma.komgaConfig.findUnique({ where: { userId }, select: { id: true } }), prisma.komgaConfig.findUnique({ where: { userId }, select: { id: true } }),
prisma.stripstreamConfig.findUnique({ where: { userId }, select: { id: true } }), getResolvedStripstreamConfig(userId),
]); ]);
return { return {
komgaConfigured: !!komgaConfig, komgaConfigured: !!komgaConfig,
stripstreamConfigured: !!stripstreamConfig, stripstreamConfigured: !!stripstreamResolved,
activeProvider: (dbUser?.activeProvider as ProviderType) ?? "komga", activeProvider: (dbUser?.activeProvider as ProviderType) ?? "komga",
}; };
} catch { } catch {

View File

@@ -1,7 +1,7 @@
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth-utils"; 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 { StripstreamClient } from "@/lib/providers/stripstream/stripstream.client";
import { ERROR_CODES } from "@/constants/errorCodes"; import { ERROR_CODES } from "@/constants/errorCodes";
import { AppError } from "@/utils/errors"; import { AppError } from "@/utils/errors";
@@ -23,7 +23,7 @@ export async function GET(
} }
const userId = parseInt(user.id, 10); const userId = parseInt(user.id, 10);
const config = await prisma.stripstreamConfig.findUnique({ where: { userId } }); const config = await getResolvedStripstreamConfig(userId);
if (!config) { if (!config) {
throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG); throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG);
} }
@@ -35,14 +35,15 @@ export async function GET(
const response = await client.fetchImage(path); const response = await client.fetchImage(path);
const contentType = response.headers.get("content-type") ?? "image/jpeg"; 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, { const headers: Record<string, string> = {
headers: { "Content-Type": contentType,
"Content-Type": contentType, "Cache-Control": "public, max-age=86400",
"Cache-Control": "public, max-age=86400", };
}, if (contentLength) headers["Content-Length"] = contentLength;
});
return new NextResponse(response.body, { headers });
} catch (error) { } catch (error) {
logger.error({ err: error }, "Stripstream page fetch error"); logger.error({ err: error }, "Stripstream page fetch error");

View File

@@ -1,7 +1,7 @@
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth-utils"; 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 { StripstreamClient } from "@/lib/providers/stripstream/stripstream.client";
import { AppError } from "@/utils/errors"; import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes"; import { ERROR_CODES } from "@/constants/errorCodes";
@@ -20,7 +20,7 @@ export async function GET(
} }
const userId = parseInt(user.id, 10); const userId = parseInt(user.id, 10);
const config = await prisma.stripstreamConfig.findUnique({ where: { userId } }); const config = await getResolvedStripstreamConfig(userId);
if (!config) { if (!config) {
throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_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 ClientLayout from "@/components/layout/ClientLayout";
import { PreferencesService } from "@/lib/services/preferences.service"; import { PreferencesService } from "@/lib/services/preferences.service";
import { PreferencesProvider } from "@/contexts/PreferencesContext"; import { PreferencesProvider } from "@/contexts/PreferencesContext";
import { AnonymousProvider } from "@/contexts/AnonymousContext";
import { I18nProvider } from "@/components/providers/I18nProvider"; import { I18nProvider } from "@/components/providers/I18nProvider";
import { AuthProvider } from "@/components/providers/AuthProvider"; import { AuthProvider } from "@/components/providers/AuthProvider";
import { cookies, headers } from "next/headers"; import { cookies, headers } from "next/headers";
@@ -248,6 +249,61 @@ export default async function RootLayout({ children }: { children: React.ReactNo
href="/images/splash/splash-2796x1290.png" href="/images/splash/splash-2796x1290.png"
media="(device-width: 932px) and (device-height: 430px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" media="(device-width: 932px) and (device-height: 430px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/> />
{/* iPad Mini 6 */}
<link
rel="apple-touch-startup-image"
href="/images/splash/splash-1488x2266.png"
media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/images/splash/splash-2266x1488.png"
media="(device-width: 1133px) and (device-height: 744px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
{/* iPad Pro 11" M4 */}
<link
rel="apple-touch-startup-image"
href="/images/splash/splash-1668x2420.png"
media="(device-width: 834px) and (device-height: 1210px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/images/splash/splash-2420x1668.png"
media="(device-width: 1210px) and (device-height: 834px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
{/* iPad Pro 13" M4 */}
<link
rel="apple-touch-startup-image"
href="/images/splash/splash-2064x2752.png"
media="(device-width: 1032px) and (device-height: 1376px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/images/splash/splash-2752x2064.png"
media="(device-width: 1376px) and (device-height: 1032px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
{/* iPhone 16 Pro */}
<link
rel="apple-touch-startup-image"
href="/images/splash/splash-1206x2622.png"
media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/images/splash/splash-2622x1206.png"
media="(device-width: 874px) and (device-height: 402px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
{/* iPhone 16 Pro Max */}
<link
rel="apple-touch-startup-image"
href="/images/splash/splash-1320x2868.png"
media="(device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/images/splash/splash-2868x1320.png"
media="(device-width: 956px) and (device-height: 440px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
</head> </head>
<body <body
className={cn( className={cn(
@@ -258,13 +314,15 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<AuthProvider> <AuthProvider>
<I18nProvider locale={locale}> <I18nProvider locale={locale}>
<PreferencesProvider initialPreferences={preferences}> <PreferencesProvider initialPreferences={preferences}>
<ClientLayout <AnonymousProvider>
initialLibraries={libraries} <ClientLayout
initialFavorites={favorites} initialLibraries={libraries}
userIsAdmin={userIsAdmin} initialFavorites={favorites}
> userIsAdmin={userIsAdmin}
{children} >
</ClientLayout> {children}
</ClientLayout>
</AnonymousProvider>
</PreferencesProvider> </PreferencesProvider>
</I18nProvider> </I18nProvider>
</AuthProvider> </AuthProvider>

View File

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

View File

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

View File

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

View File

@@ -3,21 +3,34 @@ import type { HomeData } from "@/types/home";
interface HomeContentProps { interface HomeContentProps {
data: HomeData; data: HomeData;
isAnonymous?: boolean;
} }
export function HomeContent({ data }: HomeContentProps) { export function HomeContent({ data, isAnonymous = false }: HomeContentProps) {
// Merge onDeck (next unread per series) and ongoingBooks (currently reading),
// 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 ( return (
<div className="space-y-10 pb-2"> <div className="space-y-10 pb-2">
{data.ongoingBooks && data.ongoingBooks.length > 0 && ( {!isAnonymous && continueReading.length > 0 && (
<MediaRow <MediaRow
titleKey="home.sections.continue_reading" titleKey="home.sections.continue_reading"
items={data.ongoingBooks} items={continueReading}
iconName="BookOpen" iconName="BookOpen"
featuredHeader featuredHeader
/> />
)} )}
{data.ongoing && data.ongoing.length > 0 && ( {!isAnonymous && data.ongoing && data.ongoing.length > 0 && (
<MediaRow <MediaRow
titleKey="home.sections.continue_series" titleKey="home.sections.continue_series"
items={data.ongoing} items={data.ongoing}
@@ -25,11 +38,11 @@ export function HomeContent({ data }: HomeContentProps) {
/> />
)} )}
{data.onDeck && data.onDeck.length > 0 && ( {data.favorites && data.favorites.length > 0 && (
<MediaRow <MediaRow
titleKey="home.sections.up_next" titleKey="home.sections.favorites"
items={data.onDeck} items={data.favorites}
iconName="Clock" iconName="Heart"
/> />
)} )}

View File

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

View File

@@ -1,10 +1,11 @@
import { Menu, Moon, Sun, RefreshCw, Search } from "lucide-react"; import { Menu, Moon, Sun, RefreshCw, Search, EyeOff, Eye } from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import LanguageSelector from "@/components/LanguageSelector"; import LanguageSelector from "@/components/LanguageSelector";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IconButton } from "@/components/ui/icon-button"; import { IconButton } from "@/components/ui/icon-button";
import { useState } from "react"; import { useState } from "react";
import { GlobalSearch } from "@/components/layout/GlobalSearch"; import { GlobalSearch } from "@/components/layout/GlobalSearch";
import { useAnonymous } from "@/contexts/AnonymousContext";
interface HeaderProps { interface HeaderProps {
onToggleSidebar: () => void; onToggleSidebar: () => void;
@@ -19,6 +20,7 @@ export function Header({
}: HeaderProps) { }: HeaderProps) {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
const { isAnonymous, toggleAnonymous } = useAnonymous();
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false); const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
@@ -51,7 +53,7 @@ export function Header({
<div className="mr-2 flex items-center md:mr-4"> <div className="mr-2 flex items-center md:mr-4">
<a className="mr-2 flex items-center md:mr-6" href="/"> <a className="mr-2 flex items-center md:mr-6" href="/">
<span className="inline-flex flex-col leading-none"> <span className="inline-flex flex-col leading-none">
<span className="bg-gradient-to-r from-primary via-cyan-500 to-fuchsia-500 bg-clip-text text-sm font-bold tracking-[0.06em] text-transparent sm:text-lg sm:tracking-[0.08em]"> <span className="bg-gradient-to-r from-primary via-cyan-500 to-fuchsia-500 bg-clip-text text-base font-bold tracking-[0.06em] text-transparent sm:text-lg sm:tracking-[0.08em]">
StripStream StripStream
</span> </span>
<span className="mt-1 hidden text-[10px] font-medium uppercase tracking-[0.22em] text-foreground/70 sm:inline"> <span className="mt-1 hidden text-[10px] font-medium uppercase tracking-[0.22em] text-foreground/70 sm:inline">
@@ -87,6 +89,14 @@ export function Header({
className="h-9 w-9 rounded-full sm:hidden" className="h-9 w-9 rounded-full sm:hidden"
tooltip={t("header.search.placeholder")} tooltip={t("header.search.placeholder")}
/> />
<IconButton
onClick={toggleAnonymous}
variant="ghost"
size="icon"
icon={isAnonymous ? EyeOff : Eye}
className={`h-9 w-9 rounded-full ${isAnonymous ? "text-yellow-500 hover:text-yellow-400" : ""}`}
tooltip={t(isAnonymous ? "header.anonymousModeOn" : "header.anonymousModeOff")}
/>
<LanguageSelector /> <LanguageSelector />
<button <button
onClick={toggleTheme} onClick={toggleTheme}

View File

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

View File

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

View File

@@ -42,12 +42,14 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
const { const {
loadedImages, loadedImages,
imageBlobUrls, imageBlobUrls,
prefetchImage,
prefetchPages, prefetchPages,
prefetchNextBook, prefetchNextBook,
cancelAllPrefetches, cancelAllPrefetches,
handleForceReload, handleForceReload,
getPageUrl, getPageUrl,
prefetchCount, prefetchCount,
isPageLoading,
} = useImageLoader({ } = useImageLoader({
pageUrlBuilder: bookPageUrlBuilder, pageUrlBuilder: bookPageUrlBuilder,
pages, pages,
@@ -74,21 +76,56 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
onPreviousPage: handlePreviousPage, onPreviousPage: handlePreviousPage,
onNextPage: handleNextPage, onNextPage: handleNextPage,
pswpRef, pswpRef,
isRTL,
}); });
// Activer le zoom dans le reader en enlevant la classe no-pinch-zoom // Activer le zoom dans le reader en enlevant la classe no-pinch-zoom
// et reset le zoom lors des changements d'orientation (iOS applique un zoom automatique)
useEffect(() => { useEffect(() => {
document.body.classList.remove("no-pinch-zoom"); document.body.classList.remove("no-pinch-zoom");
const handleOrientationChange = () => {
const viewport = document.querySelector('meta[name="viewport"]');
if (viewport) {
const original = viewport.getAttribute("content") || "";
viewport.setAttribute("content", original + ", maximum-scale=1");
// Restaurer après que iOS ait appliqué le nouveau layout
requestAnimationFrame(() => {
viewport.setAttribute("content", original);
});
}
};
window.addEventListener("orientationchange", handleOrientationChange);
return () => { return () => {
window.removeEventListener("orientationchange", handleOrientationChange);
document.body.classList.add("no-pinch-zoom"); document.body.classList.add("no-pinch-zoom");
}; };
}, []); }, []);
// Prefetch current and next pages // Prefetch current and next pages
useEffect(() => { useEffect(() => {
// Prefetch pages starting from current page // Determine visible pages that need to be loaded immediately
prefetchPages(currentPage, prefetchCount); 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 double page mode, also prefetch additional pages for smooth double page navigation
if ( if (
@@ -96,7 +133,7 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
shouldShowDoublePage(currentPage, pages.length) && shouldShowDoublePage(currentPage, pages.length) &&
currentPage + prefetchCount < 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 // 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, currentPage,
isDoublePage, isDoublePage,
shouldShowDoublePage, shouldShowDoublePage,
prefetchImage,
prefetchPages, prefetchPages,
prefetchNextBook, prefetchNextBook,
prefetchCount, prefetchCount,
@@ -227,7 +265,6 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
isDoublePage={isDoublePage} isDoublePage={isDoublePage}
shouldShowDoublePage={(page) => shouldShowDoublePage(page, pages.length)} shouldShowDoublePage={(page) => shouldShowDoublePage(page, pages.length)}
imageBlobUrls={imageBlobUrls} imageBlobUrls={imageBlobUrls}
getPageUrl={getPageUrl}
isRTL={isRTL} isRTL={isRTL}
/> />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,14 +34,8 @@ export function KomgaSettings({ initialConfig }: KomgaSettingsProps) {
const handleTest = async () => { const handleTest = async () => {
setIsLoading(true); 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 { 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) { if (!result.success) {
throw new Error(result.message); throw new Error(result.message);
@@ -55,8 +49,8 @@ export function KomgaSettings({ initialConfig }: KomgaSettingsProps) {
logger.error({ err: error }, "Erreur:"); logger.error({ err: error }, "Erreur:");
toast({ toast({
variant: "destructive", variant: "destructive",
title: t("settings.komga.error.title"), title: t("settings.komga.error.connectionTitle"),
description: t("settings.komga.error.message"), description: t("settings.komga.error.connectionMessage"),
}); });
} finally { } finally {
setIsLoading(false); setIsLoading(false);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ import type { LibraryResponse } from "@/types/library";
import type { AuthConfig } from "@/types/auth"; import type { AuthConfig } from "@/types/auth";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG, SERIES_BOOKS_CACHE_TAG } from "@/constants/cacheConstants"; import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG, SERIES_BOOKS_CACHE_TAG } from "@/constants/cacheConstants";
import { unstable_cache } from "next/cache";
type KomgaCondition = Record<string, unknown>; type KomgaCondition = Record<string, unknown>;
@@ -64,7 +65,6 @@ export class KomgaProvider implements IMediaProvider {
const isDebug = process.env.KOMGA_DEBUG === "true"; const isDebug = process.env.KOMGA_DEBUG === "true";
const isCacheDebug = process.env.CACHE_DEBUG === "true"; const isCacheDebug = process.env.CACHE_DEBUG === "true";
const startTime = isDebug ? Date.now() : 0;
if (isDebug) { if (isDebug) {
logger.info( logger.info(
@@ -72,8 +72,14 @@ export class KomgaProvider implements IMediaProvider {
"🔵 Komga Request" "🔵 Komga Request"
); );
} }
if (isCacheDebug && options.revalidate) { if (isCacheDebug) {
logger.info({ url, cache: "enabled", ttl: options.revalidate }, "💾 Cache enabled"); 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 const nextOptions = options.tags
@@ -88,6 +94,19 @@ export class KomgaProvider implements IMediaProvider {
next: nextOptions, 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 controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
@@ -124,10 +143,6 @@ export class KomgaProvider implements IMediaProvider {
"🟢 Komga Response" "🟢 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 (!response.ok) {
if (isDebug) { if (isDebug) {
@@ -163,25 +178,7 @@ export class KomgaProvider implements IMediaProvider {
const raw = await this.fetch<KomgaLibrary[]>("libraries", undefined, { const raw = await this.fetch<KomgaLibrary[]>("libraries", undefined, {
revalidate: CACHE_TTL_LONG, revalidate: CACHE_TTL_LONG,
}); });
// Enrich with book counts return raw.map(KomgaAdapter.toNormalizedLibrary);
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);
} }
async getSeries(libraryId: string, cursor?: string, limit = 20, unreadOnly = false, search?: string): Promise<NormalizedSeriesPage> { 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> { async getLibraryById(libraryId: string): Promise<NormalizedLibrary | null> {
try { const libraries = await this.getLibraries();
const lib = await this.fetch<KomgaLibrary>(`libraries/${libraryId}`, undefined, { return libraries.find((lib) => lib.id === libraryId) ?? null;
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;
}
} }
async getNextBook(bookId: string): Promise<NormalizedBook | null> { async getNextBook(bookId: string): Promise<NormalizedBook | null> {
@@ -398,7 +373,14 @@ export class KomgaProvider implements IMediaProvider {
} }
async getHomeData(): Promise<HomeData> { 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([ const [ongoing, ongoingBooks, recentlyRead, onDeck, latestSeries] = await Promise.all([
this.fetch<LibraryResponse<KomgaSeries>>( this.fetch<LibraryResponse<KomgaSeries>>(
"series/list", "series/list",
@@ -408,7 +390,6 @@ export class KomgaProvider implements IMediaProvider {
body: JSON.stringify({ body: JSON.stringify({
condition: { readStatus: { operator: "is", value: "IN_PROGRESS" } }, condition: { readStatus: { operator: "is", value: "IN_PROGRESS" } },
}), }),
...homeOpts,
} }
).catch(() => ({ content: [] as KomgaSeries[] })), ).catch(() => ({ content: [] as KomgaSeries[] })),
this.fetch<LibraryResponse<KomgaBook>>( this.fetch<LibraryResponse<KomgaBook>>(
@@ -419,23 +400,19 @@ export class KomgaProvider implements IMediaProvider {
body: JSON.stringify({ body: JSON.stringify({
condition: { readStatus: { operator: "is", value: "IN_PROGRESS" } }, condition: { readStatus: { operator: "is", value: "IN_PROGRESS" } },
}), }),
...homeOpts,
} }
).catch(() => ({ content: [] as KomgaBook[] })), ).catch(() => ({ content: [] as KomgaBook[] })),
this.fetch<LibraryResponse<KomgaBook>>( this.fetch<LibraryResponse<KomgaBook>>(
"books/latest", "books/latest",
{ page: "0", size: "10", media_status: "READY" }, { page: "0", size: "10", media_status: "READY" }
{ ...homeOpts }
).catch(() => ({ content: [] as KomgaBook[] })), ).catch(() => ({ content: [] as KomgaBook[] })),
this.fetch<LibraryResponse<KomgaBook>>( this.fetch<LibraryResponse<KomgaBook>>(
"books/ondeck", "books/ondeck",
{ page: "0", size: "10", media_status: "READY" }, { page: "0", size: "10", media_status: "READY" }
{ ...homeOpts }
).catch(() => ({ content: [] as KomgaBook[] })), ).catch(() => ({ content: [] as KomgaBook[] })),
this.fetch<LibraryResponse<KomgaSeries>>( this.fetch<LibraryResponse<KomgaSeries>>(
"series/latest", "series/latest",
{ page: "0", size: "10", media_status: "READY" }, { page: "0", size: "10", media_status: "READY" }
{ ...homeOpts }
).catch(() => ({ content: [] as KomgaSeries[] })), ).catch(() => ({ content: [] as KomgaSeries[] })),
]); ]);
return { return {

View File

@@ -1,5 +1,6 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { getCurrentUser } from "@/lib/auth-utils"; import { getCurrentUser } from "@/lib/auth-utils";
import { getResolvedStripstreamConfig } from "./stripstream/stripstream-config-resolver";
import type { IMediaProvider } from "./provider.interface"; import type { IMediaProvider } from "./provider.interface";
export async function getProvider(): Promise<IMediaProvider | null> { export async function getProvider(): Promise<IMediaProvider | null> {
@@ -13,7 +14,7 @@ export async function getProvider(): Promise<IMediaProvider | null> {
select: { select: {
activeProvider: true, activeProvider: true,
config: { select: { url: true, authHeader: 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"; const activeProvider = dbUser.activeProvider ?? "komga";
if (activeProvider === "stripstream" && dbUser.stripstreamConfig) { if (activeProvider === "stripstream") {
const { StripstreamProvider } = await import("./stripstream/stripstream.provider"); const resolved = await getResolvedStripstreamConfig(userId);
return new StripstreamProvider( if (resolved) {
dbUser.stripstreamConfig.url, const { StripstreamProvider } = await import("./stripstream/stripstream.provider");
dbUser.stripstreamConfig.token return new StripstreamProvider(resolved.url, resolved.token);
); }
} }
if (activeProvider === "komga" || !dbUser.activeProvider) { 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, volume: book.volume ?? null,
pageCount: book.page_count ?? 0, pageCount: book.page_count ?? 0,
thumbnailUrl: `/api/stripstream/images/books/${book.id}/thumbnail`, thumbnailUrl: `/api/stripstream/images/books/${book.id}/thumbnail`,
readProgress: { readProgress:
page: book.reading_current_page ?? null, book.reading_status === "unread" && !book.reading_current_page
completed: book.reading_status === "read", ? null
lastReadAt: book.reading_last_read_at ?? 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, volume: book.volume ?? null,
pageCount: book.page_count ?? 0, pageCount: book.page_count ?? 0,
thumbnailUrl: `/api/stripstream/images/books/${book.id}/thumbnail`, thumbnailUrl: `/api/stripstream/images/books/${book.id}/thumbnail`,
readProgress: { readProgress:
page: book.reading_current_page ?? null, book.reading_status === "unread" && !book.reading_current_page
completed: book.reading_status === "read", ? null
lastReadAt: book.reading_last_read_at ?? 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, bookCount: series.book_count,
booksReadCount: series.books_read_count, booksReadCount: series.books_read_count,
thumbnailUrl: `/api/stripstream/images/books/${series.first_book_id}/thumbnail`, thumbnailUrl: `/api/stripstream/images/books/${series.first_book_id}/thumbnail`,
libraryId: series.library_id,
summary: null, summary: null,
authors: [], authors: [],
genres: [], genres: [],
tags: [], tags: [],
createdAt: null, createdAt: null,
missingCount: series.missing_count ?? null,
}; };
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -3,5 +3,9 @@ declare namespace NodeJS {
NEXT_PUBLIC_APP_URL: string; NEXT_PUBLIC_APP_URL: string;
NEXT_PUBLIC_DEFAULT_KOMGA_URL?: string; NEXT_PUBLIC_DEFAULT_KOMGA_URL?: string;
NEXT_PUBLIC_APP_VERSION: 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"; import type { NormalizedBook, NormalizedSeries } from "@/lib/providers/types";
export interface HomeData { export interface HomeData {
favorites?: NormalizedSeries[];
ongoing: NormalizedSeries[]; ongoing: NormalizedSeries[];
ongoingBooks: NormalizedBook[]; ongoingBooks: NormalizedBook[];
recentlyRead: NormalizedBook[]; recentlyRead: NormalizedBook[];

View File

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

View File

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