Compare commits

...

31 Commits

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 21:11:55 +01:00
11da2335cd fix: use sort=latest for home page books and series queries
All checks were successful
Build, Push & Deploy / deploy (push) Successful in 6m48s
Use the API's sort parameter to explicitly request most recently added
items. Simplify latest series fetch by using /series?sort=latest instead
of N+1 calls per library.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 21:19:20 +01:00
e6eab32473 fix: fetch real book counts in Stripstream getSeriesById to fix greyed covers
All checks were successful
Build, Push & Deploy / deploy (push) Successful in 4m18s
bookCount and booksReadCount were hardcoded to 0, causing all series
covers to appear completed (opacity-50). Now queries the series endpoint
to get actual counts.

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 23:10:31 +01:00
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
e74b02e3a2 feat: add docker push script and DockerHub deployment docs
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 4m24s
2026-03-11 13:33:48 +01:00
7d0f1c4457 feat: add multi-provider support (Komga + Stripstream Librarian)
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
- Introduce provider abstraction layer (IMediaProvider, KomgaProvider, StripstreamProvider)
- Add Stripstream Librarian as second media provider with full feature parity
- Migrate all pages and components from direct Komga services to provider factory
- Remove dead service code (BaseApiService, HomeService, LibraryService, SearchService, TestService)
- Fix library/series page-based pagination for both providers (Komga 0-indexed, Stripstream 1-indexed)
- Fix unread filter and search on library page for both providers
- Fix read progress display for Stripstream (reading_status mapping)
- Fix series read status (books_read_count) for Stripstream
- Add global search with series results for Stripstream (series_hits from Meilisearch)
- Fix thumbnail proxy to return 404 gracefully instead of JSON on upstream error
- Replace duration-based cache debug detection with x-nextjs-cache header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:48:17 +01:00
a1a95775db fix: align book sorting with Komga numberSort
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m19s
2026-03-05 08:45:02 +01:00
3d7ac0c13e feat: add global Komga search autocomplete in header
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m50s
2026-03-04 13:46:02 +01:00
140 changed files with 3735 additions and 1796 deletions

View File

@@ -6,3 +6,7 @@ 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,10 +103,10 @@ cp .env.example .env.local
4. Start the development server 4. Start the development server
```bash ```bash
yarn dev pnpm dev
``` ```
### With Docker ### With Docker (Build Local)
1. Clone the repository and navigate to the folder 1. Clone the repository and navigate to the folder
@@ -121,15 +121,65 @@ cd stripstream
docker-compose up --build docker-compose up --build
``` ```
The application will be accessible at `http://localhost:3020`
### With Docker (DockerHub Image)
You can also use the pre-built image from DockerHub without cloning the repository:
1. Create a `docker-compose.yml` file:
```yaml
services:
app:
image: julienfroidefond32/stripstream:latest
ports:
- "3000:3000"
environment:
# Required
- NEXTAUTH_SECRET=your_secret_here # openssl rand -base64 32
- NEXTAUTH_URL=http://localhost:3000
# Optional — defaults shown
# - NODE_ENV=production
# - DATABASE_URL=file:/app/prisma/data/stripstream.db
# - ADMIN_DEFAULT_PASSWORD=Admin@2025
# - AUTH_TRUST_HOST=true
# - KOMGA_MAX_CONCURRENT_REQUESTS=5
volumes:
- ./data:/app/prisma/data
restart: unless-stopped
```
2. Run the container:
```bash
docker-compose up -d
```
The application will be accessible at `http://localhost:3000` 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 Script
The `docker-push.sh` script automates building and pushing the Docker image to DockerHub:
```bash
# Push with 'latest' tag
./docker-push.sh
# Push with a specific version tag
./docker-push.sh v1.0.0
```
**Prerequisite:** You must be logged in to DockerHub (`docker login`) before running the script.
## 🌐 Komga API ## 🌐 Komga API

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

24
docker-push.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
# Script pour builder et push l'image Docker vers DockerHub
# Usage: ./docker-push.sh [tag]
set -e
DOCKER_USERNAME="julienfroidefond32"
IMAGE_NAME="stripstream"
# Utiliser le tag fourni ou 'latest' par défaut
TAG=${1:-latest}
FULL_IMAGE_NAME="$DOCKER_USERNAME/$IMAGE_NAME:$TAG"
echo "=== Building Docker image: $FULL_IMAGE_NAME ==="
docker build -t $FULL_IMAGE_NAME .
echo ""
echo "=== Pushing to DockerHub: $FULL_IMAGE_NAME ==="
docker push $FULL_IMAGE_NAME
echo ""
echo "=== Successfully pushed: $FULL_IMAGE_NAME ==="

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,2 @@
-- AlterTable
ALTER TABLE "preferences" ADD COLUMN "anonymousMode" BOOLEAN NOT NULL DEFAULT false;

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

@@ -11,18 +11,20 @@ datasource db {
} }
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
email String @unique email String @unique
password String password String
roles Json @default("[\"ROLE_USER\"]") roles Json @default("[\"ROLE_USER\"]")
authenticated Boolean @default(true) authenticated Boolean @default(true)
createdAt DateTime @default(now()) activeProvider String?
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations // Relations
config KomgaConfig? config KomgaConfig?
preferences Preferences? stripstreamConfig StripstreamConfig?
favorites Favorite[] preferences Preferences?
favorites Favorite[]
@@map("users") @@map("users")
} }
@@ -41,6 +43,19 @@ model KomgaConfig {
@@map("komgaconfigs") @@map("komgaconfigs")
} }
model StripstreamConfig {
id Int @id @default(autoincrement())
userId Int @unique
url String
token String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("stripstreamconfigs")
}
model Preferences { model Preferences {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int @unique userId Int @unique
@@ -49,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
@@ -61,12 +77,13 @@ model Favorite {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int userId Int
seriesId String seriesId String
provider String @default("komga")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, seriesId]) @@unique([userId, provider, seriesId])
@@index([userId]) @@index([userId])
@@map("favorites") @@map("favorites")
} }

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

@@ -1,36 +1,40 @@
"use server"; "use server";
import { BookService } from "@/lib/services/book.service"; import { getProvider } from "@/lib/providers/provider.factory";
import { AppError } from "@/utils/errors"; import { AppError } from "@/utils/errors";
import type { KomgaBook } from "@/types/komga"; import type { NormalizedBook } from "@/lib/providers/types";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
interface BookDataResult { interface BookDataResult {
success: boolean; success: boolean;
data?: { data?: {
book: KomgaBook; book: NormalizedBook;
pages: number[]; pages: number[];
nextBook: KomgaBook | null; nextBook: NormalizedBook | null;
}; };
message?: string; message?: string;
} }
export async function getBookData(bookId: string): Promise<BookDataResult> { export async function getBookData(bookId: string): Promise<BookDataResult> {
try { try {
const data = await BookService.getBook(bookId); const provider = await getProvider();
let nextBook = null; if (!provider) {
return { success: false, message: "KOMGA_MISSING_CONFIG" };
}
const book = await provider.getBook(bookId);
const pages = Array.from({ length: book.pageCount }, (_, i) => i + 1);
let nextBook: NormalizedBook | null = null;
try { try {
nextBook = await BookService.getNextBook(bookId, data.book.seriesId); nextBook = await provider.getNextBook(bookId);
} catch (error) { } catch (error) {
logger.warn({ err: error, bookId }, "Failed to fetch next book in server action"); logger.warn({ err: error, bookId }, "Failed to fetch next book in server action");
} }
return { return {
success: true, success: true,
data: { data: { book, pages, nextBook },
...data,
nextBook,
},
}; };
} catch (error) { } catch (error) {
if (error instanceof AppError) { if (error instanceof AppError) {

View File

@@ -2,8 +2,8 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { ConfigDBService } from "@/lib/services/config-db.service"; import { ConfigDBService } from "@/lib/services/config-db.service";
import { TestService } from "@/lib/services/test.service";
import { AppError } from "@/utils/errors"; import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
import type { KomgaConfig, KomgaConfigData, KomgaLibrary } from "@/types/komga"; import type { KomgaConfig, KomgaConfigData, KomgaLibrary } from "@/types/komga";
interface SaveConfigInput { interface SaveConfigInput {
@@ -13,9 +13,6 @@ interface SaveConfigInput {
authHeader?: string; authHeader?: string;
} }
/**
* Teste la connexion à Komga
*/
export async function testKomgaConnection( export async function testKomgaConnection(
serverUrl: string, serverUrl: string,
username: string, username: string,
@@ -23,27 +20,31 @@ export async function testKomgaConnection(
): Promise<{ success: boolean; message: string }> { ): Promise<{ success: boolean; message: string }> {
try { try {
const authHeader = Buffer.from(`${username}:${password}`).toString("base64"); const authHeader = Buffer.from(`${username}:${password}`).toString("base64");
const url = new URL(`${serverUrl}/api/v1/libraries`).toString();
const { libraries }: { libraries: KomgaLibrary[] } = await TestService.testConnection({ const headers = new Headers({
serverUrl, Authorization: `Basic ${authHeader}`,
authHeader, Accept: "application/json",
}); });
const response = await fetch(url, { headers });
if (!response.ok) {
throw new AppError(ERROR_CODES.KOMGA.CONNECTION_ERROR);
}
const libraries: KomgaLibrary[] = await response.json();
return { return {
success: true, success: true,
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" };
} }
} }
/**
* Sauvegarde la configuration Komga
*/
export async function saveKomgaConfig( export async function saveKomgaConfig(
config: SaveConfigInput config: SaveConfigInput
): Promise<{ success: boolean; message: string; data?: KomgaConfig }> { ): Promise<{ success: boolean; message: string; data?: KomgaConfig }> {
@@ -55,17 +56,10 @@ export async function saveKomgaConfig(
authHeader: config.authHeader || "", authHeader: config.authHeader || "",
}; };
const mongoConfig = await ConfigDBService.saveConfig(configData); const mongoConfig = await ConfigDBService.saveConfig(configData);
// Invalider le cache
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

@@ -1,20 +1,18 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { LibraryService } from "@/lib/services/library.service"; import { getProvider } from "@/lib/providers/provider.factory";
import { BookService } from "@/lib/services/book.service";
import { AppError } from "@/utils/errors"; import { AppError } from "@/utils/errors";
/**
* Lance un scan de bibliothèque
*/
export async function scanLibrary( export async function scanLibrary(
libraryId: string libraryId: string
): Promise<{ success: boolean; message: string }> { ): Promise<{ success: boolean; message: string }> {
try { try {
await LibraryService.scanLibrary(libraryId, false); const provider = await getProvider();
if (!provider) return { success: false, message: "Provider non configuré" };
await provider.scanLibrary(libraryId);
// Invalider le cache de la bibliothèque
revalidatePath(`/libraries/${libraryId}`); revalidatePath(`/libraries/${libraryId}`);
revalidatePath("/libraries"); revalidatePath("/libraries");
@@ -27,9 +25,6 @@ export async function scanLibrary(
} }
} }
/**
* Retourne un livre aléatoire depuis les bibliothèques sélectionnées
*/
export async function getRandomBookFromLibraries( export async function getRandomBookFromLibraries(
libraryIds: string[] libraryIds: string[]
): Promise<{ success: boolean; bookId?: string; message?: string }> { ): Promise<{ success: boolean; bookId?: string; message?: string }> {
@@ -38,13 +33,15 @@ export async function getRandomBookFromLibraries(
return { success: false, message: "Au moins une bibliothèque doit être sélectionnée" }; return { success: false, message: "Au moins une bibliothèque doit être sélectionnée" };
} }
const bookId = await BookService.getRandomBookFromLibraries(libraryIds); const provider = await getProvider();
return { success: true, bookId }; if (!provider) return { success: false, message: "Provider non configuré" };
const bookId = await provider.getRandomBook(libraryIds);
return { success: true, bookId: bookId ?? undefined };
} 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 récupération d'un livre aléatoire" }; return { success: false, message: "Erreur lors de la récupération d'un livre aléatoire" };
} }
} }

View File

@@ -1,27 +1,27 @@
"use server"; "use server";
import { revalidateTag } from "next/cache"; import { revalidateTag } from "next/cache";
import { BookService } from "@/lib/services/book.service"; import { getProvider } from "@/lib/providers/provider.factory";
import { LIBRARY_SERIES_CACHE_TAG } from "@/lib/services/library.service"; import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG, SERIES_BOOKS_CACHE_TAG } from "@/constants/cacheConstants";
import { AppError } from "@/utils/errors"; import { AppError } from "@/utils/errors";
const HOME_CACHE_TAG = "home-data"; function revalidateReadCaches() {
revalidateTag(HOME_CACHE_TAG, "max");
revalidateTag(LIBRARY_SERIES_CACHE_TAG, "max");
revalidateTag(SERIES_BOOKS_CACHE_TAG, "max");
}
/**
* Met à jour la progression de lecture d'un livre
* Note: ne pas utiliser "use server" avec redirect - on gère manuellement
*/
export async function updateReadProgress( export async function updateReadProgress(
bookId: string, bookId: string,
page: number, page: number,
completed: boolean = false completed: boolean = false
): Promise<{ success: boolean; message: string }> { ): Promise<{ success: boolean; message: string }> {
try { try {
await BookService.updateReadProgress(bookId, page, completed); const provider = await getProvider();
if (!provider) return { success: false, message: "Provider non configuré" };
// Invalider le cache home et libraries (statut de lecture des séries) await provider.saveReadProgress(bookId, page, completed);
revalidateTag(HOME_CACHE_TAG, "max"); revalidateReadCaches();
revalidateTag(LIBRARY_SERIES_CACHE_TAG, "max");
return { success: true, message: "Progression mise à jour" }; return { success: true, message: "Progression mise à jour" };
} catch (error) { } catch (error) {
@@ -32,18 +32,15 @@ export async function updateReadProgress(
} }
} }
/**
* Supprime la progression de lecture d'un livre
*/
export async function deleteReadProgress( export async function deleteReadProgress(
bookId: string bookId: string
): Promise<{ success: boolean; message: string }> { ): Promise<{ success: boolean; message: string }> {
try { try {
await BookService.deleteReadProgress(bookId); const provider = await getProvider();
if (!provider) return { success: false, message: "Provider non configuré" };
// Invalider le cache home et libraries (statut de lecture des séries) await provider.resetReadProgress(bookId);
revalidateTag(HOME_CACHE_TAG, "max"); revalidateReadCaches();
revalidateTag(LIBRARY_SERIES_CACHE_TAG, "max");
return { success: true, message: "Progression supprimée" }; return { success: true, message: "Progression supprimée" };
} catch (error) { } catch (error) {

View File

@@ -1,9 +1,7 @@
"use server"; "use server";
import { revalidatePath, revalidateTag } from "next/cache"; import { revalidatePath, revalidateTag } from "next/cache";
import { LIBRARY_SERIES_CACHE_TAG } from "@/lib/services/library.service"; import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG } from "@/constants/cacheConstants";
const HOME_CACHE_TAG = "home-data";
export type RefreshScope = "home" | "library" | "series"; export type RefreshScope = "home" | "library" | "series";

View File

@@ -0,0 +1,181 @@
"use server";
import { revalidatePath } from "next/cache";
import prisma from "@/lib/prisma";
import { getCurrentUser } from "@/lib/auth-utils";
import { StripstreamProvider } from "@/lib/providers/stripstream/stripstream.provider";
import { getResolvedStripstreamConfig } from "@/lib/providers/stripstream/stripstream-config-resolver";
import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
import type { ProviderType } from "@/lib/providers/types";
/**
* Sauvegarde la configuration Stripstream
*/
export async function saveStripstreamConfig(
url: string,
token: string
): Promise<{ success: boolean; message: string }> {
try {
const user = await getCurrentUser();
if (!user) {
return { success: false, message: "Non authentifié" };
}
const userId = parseInt(user.id, 10);
await prisma.stripstreamConfig.upsert({
where: { userId },
update: { url, token },
create: { userId, url, token },
});
revalidatePath("/settings");
return { success: true, message: "Configuration Stripstream sauvegardée" };
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors de la sauvegarde" };
}
}
/**
* Teste la connexion à Stripstream Librarian
*/
export async function testStripstreamConnection(
url: string,
token: string
): Promise<{ success: boolean; message: string }> {
try {
const provider = new StripstreamProvider(url, token);
const result = await provider.testConnection();
if (!result.ok) {
return { success: false, message: result.error ?? "Connexion échouée" };
}
return { success: true, message: "Connexion Stripstream réussie !" };
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors du test de connexion" };
}
}
/**
* Définit le provider actif de l'utilisateur
*/
export async function setActiveProvider(
provider: ProviderType
): Promise<{ success: boolean; message: string }> {
try {
const user = await getCurrentUser();
if (!user) {
return { success: false, message: "Non authentifié" };
}
const userId = parseInt(user.id, 10);
// Vérifier que le provider est configuré avant de l'activer
if (provider === "komga") {
const config = await prisma.komgaConfig.findUnique({ where: { userId } });
if (!config) {
return { success: false, message: "Komga n'est pas encore configuré" };
}
} else if (provider === "stripstream") {
const config = await getResolvedStripstreamConfig(userId);
if (!config) {
return { success: false, message: "Stripstream n'est pas encore configuré" };
}
}
await prisma.user.update({
where: { id: userId },
data: { activeProvider: provider },
});
revalidatePath("/");
revalidatePath("/settings");
return {
success: true,
message: `Provider actif : ${provider === "komga" ? "Komga" : "Stripstream Librarian"}`,
};
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors du changement de provider" };
}
}
/**
* Récupère la configuration Stripstream de l'utilisateur (affichage settings).
* Priorité : config en base, sinon env STRIPSTREAM_URL / STRIPSTREAM_TOKEN.
*/
export async function getStripstreamConfig(): Promise<{
url?: string;
hasToken: boolean;
} | null> {
try {
const user = await getCurrentUser();
if (!user) return null;
const userId = parseInt(user.id, 10);
const resolved = await getResolvedStripstreamConfig(userId);
if (!resolved) return null;
return { url: resolved.url, hasToken: true };
} catch {
return null;
}
}
/**
* Récupère le provider actif de l'utilisateur
*/
export async function getActiveProvider(): Promise<ProviderType> {
try {
const user = await getCurrentUser();
if (!user) return "komga";
const userId = parseInt(user.id, 10);
const dbUser = await prisma.user.findUnique({
where: { id: userId },
select: { activeProvider: true },
});
return (dbUser?.activeProvider as ProviderType) ?? "komga";
} catch {
return "komga";
}
}
/**
* Vérifie quels providers sont configurés
*/
export async function getProvidersStatus(): Promise<{
komgaConfigured: boolean;
stripstreamConfigured: boolean;
activeProvider: ProviderType;
}> {
try {
const user = await getCurrentUser();
if (!user) {
return { komgaConfigured: false, stripstreamConfigured: false, activeProvider: "komga" };
}
const userId = parseInt(user.id, 10);
const [dbUser, komgaConfig, stripstreamResolved] = await Promise.all([
prisma.user.findUnique({ where: { id: userId }, select: { activeProvider: true } }),
prisma.komgaConfig.findUnique({ where: { userId }, select: { id: true } }),
getResolvedStripstreamConfig(userId),
]);
return {
komgaConfigured: !!komgaConfig,
stripstreamConfigured: !!stripstreamResolved,
activeProvider: (dbUser?.activeProvider as ProviderType) ?? "komga",
};
} catch {
return { komgaConfigured: false, stripstreamConfigured: false, activeProvider: "komga" };
}
}

View File

@@ -0,0 +1,44 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getProvider } from "@/lib/providers/provider.factory";
import { AppError, getErrorMessage } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
const MIN_QUERY_LENGTH = 2;
const DEFAULT_LIMIT = 6;
const MAX_LIMIT = 10;
export async function GET(request: NextRequest) {
try {
const query = request.nextUrl.searchParams.get("q")?.trim() ?? "";
const limitParam = request.nextUrl.searchParams.get("limit");
const parsedLimit = limitParam ? Number(limitParam) : Number.NaN;
const limit = Number.isFinite(parsedLimit)
? Math.max(1, Math.min(parsedLimit, MAX_LIMIT))
: DEFAULT_LIMIT;
if (query.length < MIN_QUERY_LENGTH) {
return NextResponse.json([], { headers: { "Cache-Control": "no-store" } });
}
const provider = await getProvider();
if (!provider) {
return NextResponse.json([], { headers: { "Cache-Control": "no-store" } });
}
const results = await provider.search(query, limit);
return NextResponse.json(results, { headers: { "Cache-Control": "no-store" } });
} catch (error) {
if (error instanceof AppError) {
return NextResponse.json(
{ error: { code: error.code, message: getErrorMessage(error.code) } },
{ status: 500 }
);
}
return NextResponse.json(
{ error: { code: ERROR_CODES.CLIENT.FETCH_ERROR, message: "Search error" } },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,61 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth-utils";
import { getResolvedStripstreamConfig } from "@/lib/providers/stripstream/stripstream-config-resolver";
import { StripstreamClient } from "@/lib/providers/stripstream/stripstream.client";
import { ERROR_CODES } from "@/constants/errorCodes";
import { AppError } from "@/utils/errors";
import { getErrorMessage } from "@/utils/errors";
import logger from "@/lib/logger";
export const dynamic = "force-dynamic";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ bookId: string; pageNumber: string }> }
) {
try {
const { bookId, pageNumber } = await params;
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: { code: "AUTH_UNAUTHENTICATED" } }, { status: 401 });
}
const userId = parseInt(user.id, 10);
const config = await getResolvedStripstreamConfig(userId);
if (!config) {
throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG);
}
const queryString = request.nextUrl.search.slice(1); // strip leading '?'
const path = `books/${bookId}/pages/${pageNumber}${queryString ? `?${queryString}` : ""}`;
const client = new StripstreamClient(config.url, config.token);
const response = await client.fetchImage(path);
const contentType = response.headers.get("content-type") ?? "image/jpeg";
const contentLength = response.headers.get("content-length");
const headers: Record<string, string> = {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400",
};
if (contentLength) headers["Content-Length"] = contentLength;
return new NextResponse(response.body, { headers });
} catch (error) {
logger.error({ err: error }, "Stripstream page fetch error");
if (error instanceof AppError) {
return NextResponse.json(
{ error: { code: error.code, message: getErrorMessage(error.code) } },
{ status: 500 }
);
}
return NextResponse.json(
{ error: { code: ERROR_CODES.IMAGE.FETCH_ERROR, message: "Image fetch error" } },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,44 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth-utils";
import { getResolvedStripstreamConfig } from "@/lib/providers/stripstream/stripstream-config-resolver";
import { StripstreamClient } from "@/lib/providers/stripstream/stripstream.client";
import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
import logger from "@/lib/logger";
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ bookId: string }> }
) {
try {
const { bookId } = await params;
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: { code: "AUTH_UNAUTHENTICATED" } }, { status: 401 });
}
const userId = parseInt(user.id, 10);
const config = await getResolvedStripstreamConfig(userId);
if (!config) {
throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG);
}
const client = new StripstreamClient(config.url, config.token);
const response = await client.fetchImage(`books/${bookId}/thumbnail`);
const contentType = response.headers.get("content-type") ?? "image/jpeg";
const buffer = await response.arrayBuffer();
return new NextResponse(buffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=2592000, immutable",
},
});
} catch (error) {
logger.error({ err: error }, "Stripstream thumbnail fetch error");
return new NextResponse(null, { status: 404 });
}
}

View File

@@ -0,0 +1,44 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getProvider } from "@/lib/providers/provider.factory";
import { AppError, getErrorMessage } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
const MIN_QUERY_LENGTH = 2;
const DEFAULT_LIMIT = 6;
const MAX_LIMIT = 10;
export async function GET(request: NextRequest) {
try {
const query = request.nextUrl.searchParams.get("q")?.trim() ?? "";
const limitParam = request.nextUrl.searchParams.get("limit");
const parsedLimit = limitParam ? Number(limitParam) : Number.NaN;
const limit = Number.isFinite(parsedLimit)
? Math.max(1, Math.min(parsedLimit, MAX_LIMIT))
: DEFAULT_LIMIT;
if (query.length < MIN_QUERY_LENGTH) {
return NextResponse.json([], { headers: { "Cache-Control": "no-store" } });
}
const provider = await getProvider();
if (!provider) {
throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG);
}
const results = await provider.search(query, limit);
return NextResponse.json(results, { headers: { "Cache-Control": "no-store" } });
} catch (error) {
if (error instanceof AppError) {
return NextResponse.json(
{ error: { code: error.code, message: getErrorMessage(error.code) } },
{ status: 500 }
);
}
return NextResponse.json(
{ error: { code: ERROR_CODES.CLIENT.FETCH_ERROR, message: "Search error" } },
{ status: 500 }
);
}
}

View File

@@ -1,7 +1,7 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { ClientBookPage } from "@/components/reader/ClientBookPage"; import { ClientBookPage } from "@/components/reader/ClientBookPage";
import { BookSkeleton } from "@/components/skeletons/BookSkeleton"; import { BookSkeleton } from "@/components/skeletons/BookSkeleton";
import { BookService } from "@/lib/services/book.service"; import { getProvider } from "@/lib/providers/provider.factory";
import { ERROR_CODES } from "@/constants/errorCodes"; import { ERROR_CODES } from "@/constants/errorCodes";
import { AppError } from "@/utils/errors"; import { AppError } from "@/utils/errors";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
@@ -11,23 +11,30 @@ export default async function BookPage({ params }: { params: Promise<{ bookId: s
const { bookId } = await params; const { bookId } = await params;
try { try {
// SSR: Fetch directly on server instead of client-side XHR const provider = await getProvider();
const data = await BookService.getBook(bookId); if (!provider) redirect("/settings");
const book = await provider.getBook(bookId);
const pages = Array.from({ length: book.pageCount }, (_, i) => i + 1);
let nextBook = null; let nextBook = null;
try { try {
nextBook = await BookService.getNextBook(bookId, data.book.seriesId); nextBook = await provider.getNextBook(bookId);
} catch (error) { } catch (error) {
logger.warn({ err: error, bookId }, "Failed to fetch next book, continuing without it"); logger.warn({ err: error, bookId }, "Failed to fetch next book, continuing without it");
} }
return ( return (
<Suspense fallback={<BookSkeleton />}> <Suspense fallback={<BookSkeleton />}>
<ClientBookPage bookId={bookId} initialData={{ ...data, nextBook }} /> <ClientBookPage bookId={bookId} initialData={{ book, pages, nextBook }} />
</Suspense> </Suspense>
); );
} catch (error) { } catch (error) {
// If config is missing, redirect to settings // If config is missing, redirect to settings
if (error instanceof AppError && error.code === ERROR_CODES.KOMGA.MISSING_CONFIG) { if (error instanceof AppError && (
error.code === ERROR_CODES.KOMGA.MISSING_CONFIG ||
error.code === ERROR_CODES.STRIPSTREAM.MISSING_CONFIG
)) {
redirect("/settings"); redirect("/settings");
} }

View File

@@ -5,12 +5,13 @@ 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";
import { defaultPreferences } from "@/types/preferences"; import { defaultPreferences } from "@/types/preferences";
import type { UserPreferences } from "@/types/preferences"; import type { UserPreferences } from "@/types/preferences";
import type { KomgaLibrary, KomgaSeries } from "@/types/komga"; import type { NormalizedLibrary, NormalizedSeries } from "@/lib/providers/types";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
const inter = Inter({ const inter = Inter({
@@ -77,8 +78,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
let preferences: UserPreferences = defaultPreferences; let preferences: UserPreferences = defaultPreferences;
let userIsAdmin = false; let userIsAdmin = false;
let libraries: KomgaLibrary[] = []; let libraries: NormalizedLibrary[] = [];
let favorites: KomgaSeries[] = []; let favorites: NormalizedSeries[] = [];
try { try {
const currentUser = await import("@/lib/auth-utils").then((m) => m.getCurrentUser()); const currentUser = await import("@/lib/auth-utils").then((m) => m.getCurrentUser());
@@ -86,7 +87,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
if (currentUser) { if (currentUser) {
const [preferencesData, librariesData, favoritesData] = await Promise.allSettled([ const [preferencesData, librariesData, favoritesData] = await Promise.allSettled([
PreferencesService.getPreferences(), PreferencesService.getPreferences(),
import("@/lib/services/library.service").then((m) => m.LibraryService.getLibraries()), import("@/lib/providers/provider.factory")
.then((m) => m.getProvider())
.then((provider) => provider?.getLibraries() ?? []),
import("@/lib/services/favorites.service").then((m) => import("@/lib/services/favorites.service").then((m) =>
m.FavoritesService.getFavorites({ requestPath, requestPathname }) m.FavoritesService.getFavorites({ requestPath, requestPathname })
), ),
@@ -246,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(
@@ -256,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

@@ -1,14 +1,12 @@
import { LibraryHeader } from "@/components/library/LibraryHeader"; import { LibraryHeader } from "@/components/library/LibraryHeader";
import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid"; import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid";
import { Container } from "@/components/ui/container"; import { Container } from "@/components/ui/container";
import type { KomgaLibrary } from "@/types/komga"; import type { NormalizedLibrary, NormalizedSeriesPage } from "@/lib/providers/types";
import type { LibraryResponse } from "@/types/library";
import type { Series } from "@/types/series";
import type { UserPreferences } from "@/types/preferences"; import type { UserPreferences } from "@/types/preferences";
interface LibraryContentProps { interface LibraryContentProps {
library: KomgaLibrary; library: NormalizedLibrary;
series: LibraryResponse<Series>; series: NormalizedSeriesPage;
currentPage: number; currentPage: number;
preferences: UserPreferences; preferences: UserPreferences;
unreadOnly: boolean; unreadOnly: boolean;
@@ -28,15 +26,15 @@ export function LibraryContent({
<> <>
<LibraryHeader <LibraryHeader
library={library} library={library}
seriesCount={series.totalElements} seriesCount={series.totalElements ?? series.items.length}
series={series.content || []} series={series.items}
/> />
<Container> <Container>
<PaginatedSeriesGrid <PaginatedSeriesGrid
series={series.content || []} series={series.items}
currentPage={currentPage} currentPage={currentPage}
totalPages={series.totalPages} totalPages={series.totalPages ?? 1}
totalElements={series.totalElements} totalElements={series.totalElements ?? series.items.length}
defaultShowOnlyUnread={preferences.showOnlyUnread} defaultShowOnlyUnread={preferences.showOnlyUnread}
showOnlyUnread={unreadOnly} showOnlyUnread={unreadOnly}
pageSize={pageSize} pageSize={pageSize}

View File

@@ -1,11 +1,12 @@
import { PreferencesService } from "@/lib/services/preferences.service"; import { PreferencesService } from "@/lib/services/preferences.service";
import { LibraryService } from "@/lib/services/library.service"; import { getProvider } from "@/lib/providers/provider.factory";
import { LibraryClientWrapper } from "./LibraryClientWrapper"; import { LibraryClientWrapper } from "./LibraryClientWrapper";
import { LibraryContent } from "./LibraryContent"; import { LibraryContent } from "./LibraryContent";
import { ErrorMessage } from "@/components/ui/ErrorMessage"; import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { AppError } from "@/utils/errors"; import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes"; import { ERROR_CODES } from "@/constants/errorCodes";
import type { UserPreferences } from "@/types/preferences"; import type { UserPreferences } from "@/types/preferences";
import { redirect } from "next/navigation";
interface PageProps { interface PageProps {
params: Promise<{ libraryId: string }>; params: Promise<{ libraryId: string }>;
@@ -17,9 +18,9 @@ const DEFAULT_PAGE_SIZE = 20;
export default async function LibraryPage({ params, searchParams }: PageProps) { export default async function LibraryPage({ params, searchParams }: PageProps) {
const libraryId = (await params).libraryId; const libraryId = (await params).libraryId;
const unread = (await searchParams).unread; const unread = (await searchParams).unread;
const search = (await searchParams).search;
const page = (await searchParams).page; const page = (await searchParams).page;
const size = (await searchParams).size; const size = (await searchParams).size;
const search = (await searchParams).search;
const currentPage = page ? parseInt(page) : 1; const currentPage = page ? parseInt(page) : 1;
const preferences: UserPreferences = await PreferencesService.getPreferences(); const preferences: UserPreferences = await PreferencesService.getPreferences();
@@ -31,31 +32,36 @@ export default async function LibraryPage({ params, searchParams }: PageProps) {
: preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE; : preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
try { try {
const [series, library] = await Promise.all([ const provider = await getProvider();
LibraryService.getLibrarySeries( if (!provider) redirect("/settings");
libraryId,
currentPage - 1, const [seriesPage, library] = await Promise.all([
effectivePageSize, provider.getSeries(libraryId, String(currentPage), effectivePageSize, unreadOnly, search),
unreadOnly, provider.getLibraryById(libraryId),
search
),
LibraryService.getLibrary(libraryId),
]); ]);
if (!library) throw new AppError(ERROR_CODES.LIBRARY.NOT_FOUND);
return ( return (
<LibraryClientWrapper libraryId={libraryId}> <LibraryClientWrapper libraryId={libraryId}>
<LibraryContent <LibraryContent
library={library} library={library}
series={series} series={seriesPage}
currentPage={currentPage} currentPage={currentPage}
preferences={preferences} preferences={preferences}
unreadOnly={unreadOnly} unreadOnly={unreadOnly}
search={search}
pageSize={effectivePageSize} pageSize={effectivePageSize}
/> />
</LibraryClientWrapper> </LibraryClientWrapper>
); );
} catch (error) { } catch (error) {
if (error instanceof AppError && (
error.code === ERROR_CODES.KOMGA.MISSING_CONFIG ||
error.code === ERROR_CODES.STRIPSTREAM.MISSING_CONFIG
)) {
redirect("/settings");
}
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.SERIES.FETCH_ERROR; const errorCode = error instanceof AppError ? error.code : ERROR_CODES.SERIES.FETCH_ERROR;
return ( return (

View File

@@ -1,28 +1,40 @@
import { HomeService } from "@/lib/services/home.service"; import { getProvider } from "@/lib/providers/provider.factory";
import { HomeContent } from "@/components/home/HomeContent"; import { HomeContent } from "@/components/home/HomeContent";
import { HomeClientWrapper } from "@/components/home/HomeClientWrapper"; 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() {
try { try {
const data = await HomeService.getHomeData(); const provider = await getProvider();
if (!provider) redirect("/settings");
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) {
// Si la config Komga est manquante, rediriger vers les settings if (error instanceof AppError && (
if (error instanceof AppError && error.code === ERROR_CODES.KOMGA.MISSING_CONFIG) { error.code === ERROR_CODES.KOMGA.MISSING_CONFIG ||
error.code === ERROR_CODES.STRIPSTREAM.MISSING_CONFIG
)) {
redirect("/settings"); redirect("/settings");
} }
// Afficher une erreur pour les autres cas const errorCode = error instanceof AppError ? error.code : ERROR_CODES.HOME.FETCH_ERROR;
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.KOMGA.SERVER_UNREACHABLE;
return ( return (
<main className="container mx-auto px-4 py-8"> <main className="container mx-auto px-4 py-8">

View File

@@ -4,13 +4,12 @@ import { PaginatedBookGrid } from "@/components/series/PaginatedBookGrid";
import { SeriesHeader } from "@/components/series/SeriesHeader"; import { SeriesHeader } from "@/components/series/SeriesHeader";
import { Container } from "@/components/ui/container"; import { Container } from "@/components/ui/container";
import { useRefresh } from "@/contexts/RefreshContext"; import { useRefresh } from "@/contexts/RefreshContext";
import type { LibraryResponse } from "@/types/library"; import type { NormalizedBooksPage, NormalizedSeries } from "@/lib/providers/types";
import type { KomgaBook, KomgaSeries } from "@/types/komga";
import type { UserPreferences } from "@/types/preferences"; import type { UserPreferences } from "@/types/preferences";
interface SeriesContentProps { interface SeriesContentProps {
series: KomgaSeries; series: NormalizedSeries;
books: LibraryResponse<KomgaBook>; books: NormalizedBooksPage;
currentPage: number; currentPage: number;
preferences: UserPreferences; preferences: UserPreferences;
unreadOnly: boolean; unreadOnly: boolean;
@@ -37,10 +36,10 @@ export function SeriesContent({
/> />
<Container> <Container>
<PaginatedBookGrid <PaginatedBookGrid
books={books.content || []} books={books.items}
currentPage={currentPage} currentPage={currentPage}
totalPages={books.totalPages} totalPages={books.totalPages ?? 1}
totalElements={books.totalElements} totalElements={books.totalElements ?? books.items.length}
defaultShowOnlyUnread={preferences.showOnlyUnread} defaultShowOnlyUnread={preferences.showOnlyUnread}
showOnlyUnread={unreadOnly} showOnlyUnread={unreadOnly}
/> />

View File

@@ -1,5 +1,6 @@
import { PreferencesService } from "@/lib/services/preferences.service"; import { PreferencesService } from "@/lib/services/preferences.service";
import { SeriesService } from "@/lib/services/series.service"; import { getProvider } from "@/lib/providers/provider.factory";
import { FavoriteService } from "@/lib/services/favorite.service"; import { FavoriteService } from "@/lib/services/favorite.service";
import { SeriesClientWrapper } from "./SeriesClientWrapper"; import { SeriesClientWrapper } from "./SeriesClientWrapper";
import { SeriesContent } from "./SeriesContent"; import { SeriesContent } from "./SeriesContent";
@@ -7,6 +8,7 @@ import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { AppError } from "@/utils/errors"; import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes"; import { ERROR_CODES } from "@/constants/errorCodes";
import type { UserPreferences } from "@/types/preferences"; import type { UserPreferences } from "@/types/preferences";
import { redirect } from "next/navigation";
interface PageProps { interface PageProps {
params: Promise<{ seriesId: string }>; params: Promise<{ seriesId: string }>;
@@ -18,28 +20,38 @@ const DEFAULT_PAGE_SIZE = 20;
export default async function SeriesPage({ params, searchParams }: PageProps) { export default async function SeriesPage({ params, searchParams }: PageProps) {
const seriesId = (await params).seriesId; const seriesId = (await params).seriesId;
const page = (await searchParams).page; const page = (await searchParams).page;
const unread = (await searchParams).unread;
const size = (await searchParams).size; const size = (await searchParams).size;
const unread = (await searchParams).unread;
const currentPage = page ? parseInt(page) : 1; const currentPage = page ? parseInt(page) : 1;
const preferences: UserPreferences = await PreferencesService.getPreferences(); const preferences: UserPreferences = await PreferencesService.getPreferences();
// Utiliser le paramètre d'URL s'il existe, sinon utiliser la préférence utilisateur
const unreadOnly = unread !== undefined ? unread === "true" : preferences.showOnlyUnread; const unreadOnly = unread !== undefined ? unread === "true" : preferences.showOnlyUnread;
const effectivePageSize = size ? parseInt(size) : preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE; const effectivePageSize = size
? parseInt(size)
: preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
try { try {
const [books, series, isFavorite] = await Promise.all([ const provider = await getProvider();
SeriesService.getSeriesBooks(seriesId, currentPage - 1, effectivePageSize, unreadOnly), if (!provider) redirect("/settings");
SeriesService.getSeries(seriesId),
const [booksPage, series, isFavorite] = await Promise.all([
provider.getBooks({
seriesName: seriesId,
cursor: String(currentPage),
limit: effectivePageSize,
unreadOnly,
}),
provider.getSeriesById(seriesId),
FavoriteService.isFavorite(seriesId), FavoriteService.isFavorite(seriesId),
]); ]);
if (!series) throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR);
return ( return (
<SeriesClientWrapper seriesId={seriesId}> <SeriesClientWrapper seriesId={seriesId}>
<SeriesContent <SeriesContent
series={series} series={series}
books={books} books={booksPage}
currentPage={currentPage} currentPage={currentPage}
preferences={preferences} preferences={preferences}
unreadOnly={unreadOnly} unreadOnly={unreadOnly}
@@ -49,9 +61,15 @@ export default async function SeriesPage({ params, searchParams }: PageProps) {
</SeriesClientWrapper> </SeriesClientWrapper>
); );
} catch (error) { } catch (error) {
const errorCode = error instanceof AppError if (
? error.code error instanceof AppError &&
: ERROR_CODES.BOOK.PAGES_FETCH_ERROR; (error.code === ERROR_CODES.KOMGA.MISSING_CONFIG ||
error.code === ERROR_CODES.STRIPSTREAM.MISSING_CONFIG)
) {
redirect("/settings");
}
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.BOOK.PAGES_FETCH_ERROR;
return ( return (
<main className="container mx-auto px-4 py-8"> <main className="container mx-auto px-4 py-8">

View File

@@ -1,8 +1,10 @@
import { ConfigDBService } from "@/lib/services/config-db.service"; import { ConfigDBService } from "@/lib/services/config-db.service";
import { LibraryService } from "@/lib/services/library.service";
import { ClientSettings } from "@/components/settings/ClientSettings"; import { ClientSettings } from "@/components/settings/ClientSettings";
import { getProvider } from "@/lib/providers/provider.factory";
import { getStripstreamConfig, getProvidersStatus } from "@/app/actions/stripstream-config";
import type { Metadata } from "next"; import type { Metadata } from "next";
import type { KomgaConfig, KomgaLibrary } from "@/types/komga"; import type { KomgaConfig } from "@/types/komga";
import type { NormalizedLibrary } from "@/lib/providers/types";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -14,10 +16,15 @@ export const metadata: Metadata = {
export default async function SettingsPage() { export default async function SettingsPage() {
let config: KomgaConfig | null = null; let config: KomgaConfig | null = null;
let libraries: KomgaLibrary[] = []; let libraries: NormalizedLibrary[] = [];
let stripstreamConfig: { url?: string; hasToken: boolean } | null = null;
let providersStatus: {
komgaConfigured: boolean;
stripstreamConfigured: boolean;
activeProvider: "komga" | "stripstream";
} | undefined = undefined;
try { try {
// Récupérer la configuration Komga
const mongoConfig: KomgaConfig | null = await ConfigDBService.getConfig(); const mongoConfig: KomgaConfig | null = await ConfigDBService.getConfig();
if (mongoConfig) { if (mongoConfig) {
config = { config = {
@@ -29,11 +36,31 @@ export default async function SettingsPage() {
}; };
} }
libraries = await LibraryService.getLibraries(); const [provider, stConfig, status] = await Promise.allSettled([
getProvider().then((p) => p?.getLibraries() ?? []),
getStripstreamConfig(),
getProvidersStatus(),
]);
if (provider.status === "fulfilled") {
libraries = provider.value;
}
if (stConfig.status === "fulfilled") {
stripstreamConfig = stConfig.value;
}
if (status.status === "fulfilled") {
providersStatus = status.value;
}
} catch (error) { } catch (error) {
logger.error({ err: error }, "Erreur lors de la récupération de la configuration:"); logger.error({ err: error }, "Erreur lors de la récupération de la configuration:");
// On ne fait rien si la config n'existe pas, on laissera le composant client gérer l'état initial
} }
return <ClientSettings initialConfig={config} initialLibraries={libraries} />; return (
<ClientSettings
initialConfig={config}
initialLibraries={libraries}
stripstreamConfig={stripstreamConfig}
providersStatus={providersStatus}
/>
);
} }

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

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import type { KomgaBook } from "@/types/komga"; import type { NormalizedBook } from "@/lib/providers/types";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -26,7 +26,7 @@ interface BookDownloadStatus {
} }
interface DownloadedBook { interface DownloadedBook {
book: KomgaBook; book: NormalizedBook;
status: BookDownloadStatus; status: BookDownloadStatus;
} }
@@ -112,11 +112,11 @@ export function DownloadManager() {
}; };
}, [loadDownloadedBooks, updateBookStatuses]); }, [loadDownloadedBooks, updateBookStatuses]);
const handleDeleteBook = async (book: KomgaBook) => { const handleDeleteBook = async (book: NormalizedBook) => {
try { try {
const cache = await caches.open("stripstream-books"); const cache = await caches.open("stripstream-books");
await cache.delete(`/api/komga/images/books/${book.id}/pages`); await cache.delete(`/api/komga/images/books/${book.id}/pages`);
for (let i = 1; i <= book.media.pagesCount; i++) { for (let i = 1; i <= book.pageCount; i++) {
await cache.delete(`/api/komga/images/books/${book.id}/pages/${i}`); await cache.delete(`/api/komga/images/books/${book.id}/pages/${i}`);
} }
localStorage.removeItem(getStorageKey(book.id)); localStorage.removeItem(getStorageKey(book.id));
@@ -135,7 +135,7 @@ export function DownloadManager() {
} }
}; };
const handleRetryDownload = async (book: KomgaBook) => { const handleRetryDownload = async (book: NormalizedBook) => {
localStorage.removeItem(getStorageKey(book.id)); localStorage.removeItem(getStorageKey(book.id));
setDownloadedBooks((prev) => prev.filter((b) => b.book.id !== book.id)); setDownloadedBooks((prev) => prev.filter((b) => b.book.id !== book.id));
toast({ toast({
@@ -279,7 +279,7 @@ export function DownloadManager() {
} }
interface BookDownloadCardProps { interface BookDownloadCardProps {
book: KomgaBook; book: NormalizedBook;
status: BookDownloadStatus; status: BookDownloadStatus;
onDelete: () => void; onDelete: () => void;
onRetry: () => void; onRetry: () => void;
@@ -315,8 +315,8 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="relative w-16 aspect-[2/3] bg-muted rounded overflow-hidden flex-shrink-0"> <div className="relative w-16 aspect-[2/3] bg-muted rounded overflow-hidden flex-shrink-0">
<Image <Image
src={`/api/komga/images/books/${book.id}/thumbnail`} src={book.thumbnailUrl}
alt={t("books.coverAlt", { title: book.metadata?.title })} alt={t("books.coverAlt", { title: book.title })}
className="object-cover" className="object-cover"
fill fill
sizes="64px" sizes="64px"
@@ -330,19 +330,17 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
className="hover:underline hover:text-primary transition-colors" className="hover:underline hover:text-primary transition-colors"
> >
<h3 className="font-medium truncate"> <h3 className="font-medium truncate">
{book.metadata?.title || t("books.title", { number: book.metadata?.number })} {book.title || t("books.title", { number: book.number ?? "" })}
</h3> </h3>
</Link> </Link>
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{formatSize(book.sizeBytes)}</span>
<span></span>
<span> <span>
{status.status === "downloading" {status.status === "downloading"
? t("downloads.info.pages", { ? t("downloads.info.pages", {
current: Math.floor((status.progress * book.media.pagesCount) / 100), current: Math.floor((status.progress * book.pageCount) / 100),
total: book.media.pagesCount, total: book.pageCount,
}) })
: t("downloads.info.totalPages", { count: book.media.pagesCount })} : t("downloads.info.totalPages", { count: book.pageCount })}
</span> </span>
</div> </div>
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">

View File

@@ -2,39 +2,27 @@
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 type { KomgaSeries } from "@/types/komga"; import type { NormalizedSeries } from "@/lib/providers/types";
interface OptimizedHeroSeries {
id: string;
metadata: {
title: string;
};
}
interface HeroSectionProps { interface HeroSectionProps {
series: OptimizedHeroSeries[]; series: NormalizedSeries[];
} }
export function HeroSection({ series }: HeroSectionProps) { export function HeroSection({ series }: HeroSectionProps) {
const { t } = useTranslate(); const { t } = useTranslate();
// logger.info("HeroSection - Séries reçues:", {
// count: series?.length || 0,
// firstSeries: series?.[0],
// });
return ( return (
<div className="relative h-[300px] sm:h-[400px] lg:h-[500px] -mx-4 sm:-mx-8 overflow-hidden"> <div className="relative h-[300px] sm:h-[400px] lg:h-[500px] -mx-4 sm:-mx-8 overflow-hidden">
{/* Grille de couvertures en arrière-plan */} {/* Grille de couvertures en arrière-plan */}
<div className="absolute inset-0 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2 sm:gap-4 p-4 opacity-10"> <div className="absolute inset-0 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2 sm:gap-4 p-4 opacity-10">
{series?.map((series) => ( {series?.map((s) => (
<div <div
key={series.id} key={s.id}
className="relative aspect-[2/3] bg-muted/80 backdrop-blur-md rounded-lg overflow-hidden" className="relative aspect-[2/3] bg-muted/80 backdrop-blur-md rounded-lg overflow-hidden"
> >
<SeriesCover <SeriesCover
series={series as KomgaSeries} series={s}
alt={t("home.hero.coverAlt", { title: series.metadata.title })} alt={t("home.hero.coverAlt", { title: s.name })}
showProgressUi={false} showProgressUi={false}
/> />
</div> </div>

View File

@@ -1,64 +1,55 @@
import { MediaRow } from "./MediaRow"; import { MediaRow } from "./MediaRow";
import type { KomgaBook, KomgaSeries } from "@/types/komga";
import type { HomeData } from "@/types/home"; import type { HomeData } from "@/types/home";
interface HomeContentProps { interface HomeContentProps {
data: HomeData; data: HomeData;
isAnonymous?: boolean;
} }
const optimizeSeriesData = (series: KomgaSeries[]) => { export function HomeContent({ data, isAnonymous = false }: HomeContentProps) {
return series.map(({ id, metadata, booksCount, booksReadCount }) => ({ // Merge onDeck (next unread per series) and ongoingBooks (currently reading),
id, // deduplicate by id, onDeck first
metadata: { title: metadata.title }, const continueReading = (() => {
booksCount, const items = [...(data.onDeck ?? []), ...(data.ongoingBooks ?? [])];
booksReadCount, const seen = new Set<string>();
})); return items.filter((item) => {
}; if (seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
})();
const optimizeBookData = (books: KomgaBook[]) => {
return books.map(({ id, metadata, readProgress, media }) => ({
id,
metadata: {
title: metadata.title,
number: metadata.number,
},
readProgress: readProgress || { page: 0 },
media,
}));
};
export function HomeContent({ data }: HomeContentProps) {
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={optimizeBookData(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={optimizeSeriesData(data.ongoing)} items={data.ongoing}
iconName="LibraryBig" iconName="LibraryBig"
/> />
)} )}
{data.onDeck && data.onDeck.length > 0 && ( {data.favorites && data.favorites.length > 0 && (
<MediaRow <MediaRow
titleKey="home.sections.up_next" titleKey="home.sections.favorites"
items={optimizeBookData(data.onDeck)} items={data.favorites}
iconName="Clock" iconName="Heart"
/> />
)} )}
{data.latestSeries && data.latestSeries.length > 0 && ( {data.latestSeries && data.latestSeries.length > 0 && (
<MediaRow <MediaRow
titleKey="home.sections.latest_series" titleKey="home.sections.latest_series"
items={optimizeSeriesData(data.latestSeries)} items={data.latestSeries}
iconName="Sparkles" iconName="Sparkles"
/> />
)} )}
@@ -66,7 +57,7 @@ export function HomeContent({ data }: HomeContentProps) {
{data.recentlyRead && data.recentlyRead.length > 0 && ( {data.recentlyRead && data.recentlyRead.length > 0 && (
<MediaRow <MediaRow
titleKey="home.sections.recently_added" titleKey="home.sections.recently_added"
items={optimizeBookData(data.recentlyRead)} items={data.recentlyRead}
iconName="History" iconName="History"
/> />
)} )}

View File

@@ -1,45 +1,21 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { KomgaBook, KomgaSeries } from "@/types/komga"; import type { NormalizedBook, NormalizedSeries } from "@/lib/providers/types";
import { BookCover } from "../ui/book-cover"; import { BookCover } from "../ui/book-cover";
import { SeriesCover } from "../ui/series-cover"; 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 BaseItem {
id: string;
metadata: {
title: string;
};
}
interface OptimizedSeries extends BaseItem {
booksCount: number;
booksReadCount: number;
}
interface OptimizedBook extends BaseItem {
readProgress: {
page: number;
};
media: {
pagesCount: number;
};
metadata: {
title: string;
number?: string;
};
}
interface MediaRowProps { interface MediaRowProps {
titleKey: string; titleKey: string;
items: (OptimizedSeries | OptimizedBook)[]; items: (NormalizedSeries | NormalizedBook)[];
iconName?: string; iconName?: string;
featuredHeader?: boolean; featuredHeader?: boolean;
} }
@@ -50,15 +26,20 @@ const iconMap = {
Clock, Clock,
Sparkles, Sparkles,
History, History,
Heart,
}; };
function isSeries(item: NormalizedSeries | NormalizedBook): item is NormalizedSeries {
return "bookCount" in item;
}
export function MediaRow({ titleKey, items, iconName, featuredHeader = false }: MediaRowProps) { export function MediaRow({ titleKey, items, iconName, featuredHeader = false }: MediaRowProps) {
const router = useRouter(); const router = useRouter();
const { t } = useTranslate(); const { t } = useTranslate();
const icon = iconName ? iconMap[iconName as keyof typeof iconMap] : undefined; const icon = iconName ? iconMap[iconName as keyof typeof iconMap] : undefined;
const onItemClick = (item: OptimizedSeries | OptimizedBook) => { const onItemClick = (item: NormalizedSeries | NormalizedBook) => {
const path = "booksCount" in item ? `/series/${item.id}` : `/books/${item.id}`; const path = isSeries(item) ? `/series/${item.id}` : `/books/${item.id}`;
router.push(path); router.push(path);
}; };
@@ -92,24 +73,25 @@ export function MediaRow({ titleKey, items, iconName, featuredHeader = false }:
} }
interface MediaCardProps { interface MediaCardProps {
item: OptimizedSeries | OptimizedBook; item: NormalizedSeries | NormalizedBook;
onClick?: () => void; onClick?: () => void;
} }
function MediaCard({ item, onClick }: MediaCardProps) { function MediaCard({ item, onClick }: MediaCardProps) {
const { t } = useTranslate(); const { t } = useTranslate();
const isSeries = "booksCount" in item; const { isAnonymous } = useAnonymous();
const { isAccessible } = useBookOfflineStatus(isSeries ? "" : item.id); const isSeriesItem = isSeries(item);
const { isAccessible } = useBookOfflineStatus(isSeriesItem ? "" : item.id);
const title = isSeries const title = isSeriesItem
? item.metadata.title ? item.name
: item.metadata.title || : item.title ||
(item.metadata.number ? t("navigation.volume", { number: item.metadata.number }) : ""); (item.number ? t("navigation.volume", { number: item.number }) : "");
const handleClick = () => { const handleClick = () => {
// Pour les séries, toujours autoriser le clic // Pour les séries, toujours autoriser le clic
// Pour les livres, vérifier si accessible // Pour les livres, vérifier si accessible
if (isSeries || isAccessible) { if (isSeriesItem || isAccessible) {
onClick?.(); onClick?.();
} }
}; };
@@ -119,24 +101,24 @@ function MediaCard({ item, onClick }: MediaCardProps) {
onClick={handleClick} onClick={handleClick}
className={cn( className={cn(
"relative flex w-[188px] flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border/60 bg-card/85 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:bg-card hover:shadow-md sm:w-[200px]", "relative flex w-[188px] flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border/60 bg-card/85 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:bg-card hover:shadow-md sm:w-[200px]",
!isSeries && !isAccessible ? "cursor-not-allowed" : "cursor-pointer" !isSeriesItem && !isAccessible ? "cursor-not-allowed" : "cursor-pointer"
)} )}
> >
<div className="relative aspect-[2/3] bg-muted"> <div className="relative aspect-[2/3] bg-muted">
{isSeries ? ( {isSeriesItem ? (
<> <>
<SeriesCover series={item as KomgaSeries} 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">
{t("series.books", { count: item.booksCount })} {t("series.books", { count: item.bookCount })}
</p> </p>
</div> </div>
</> </>
) : ( ) : (
<> <>
<BookCover <BookCover
book={item as KomgaBook} book={item}
alt={`Couverture de ${title}`} alt={`Couverture de ${title}`}
showControls={false} showControls={false}
overlayVariant="home" overlayVariant="home"

View File

@@ -10,7 +10,7 @@ import { usePathname } from "next/navigation";
import { NetworkStatus } from "../ui/NetworkStatus"; import { NetworkStatus } from "../ui/NetworkStatus";
import { usePreferences } from "@/contexts/PreferencesContext"; import { usePreferences } from "@/contexts/PreferencesContext";
import { ServiceWorkerProvider } from "@/contexts/ServiceWorkerContext"; import { ServiceWorkerProvider } from "@/contexts/ServiceWorkerContext";
import type { KomgaLibrary, KomgaSeries } from "@/types/komga"; import type { NormalizedLibrary, NormalizedSeries } from "@/lib/providers/types";
import { defaultPreferences } from "@/types/preferences"; import { defaultPreferences } from "@/types/preferences";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
import { getRandomBookFromLibraries } from "@/app/actions/library"; import { getRandomBookFromLibraries } from "@/app/actions/library";
@@ -20,8 +20,8 @@ const publicRoutes = ["/login", "/register"];
interface ClientLayoutProps { interface ClientLayoutProps {
children: React.ReactNode; children: React.ReactNode;
initialLibraries: KomgaLibrary[]; initialLibraries: NormalizedLibrary[];
initialFavorites: KomgaSeries[]; initialFavorites: NormalizedSeries[];
userIsAdmin?: boolean; userIsAdmin?: boolean;
} }

View File

@@ -0,0 +1,208 @@
"use client";
import { Input } from "@/components/ui/input";
import { Search, BookOpen, Library } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { useTranslate } from "@/hooks/useTranslate";
import type { NormalizedSearchResult } from "@/lib/providers/types";
const MIN_QUERY_LENGTH = 2;
export function GlobalSearch() {
const { t } = useTranslate();
const router = useRouter();
const containerRef = useRef<HTMLDivElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [results, setResults] = useState<NormalizedSearchResult[]>([]);
const seriesResults = results.filter((r) => r.type === "series");
const bookResults = results.filter((r) => r.type === "book");
const hasResults = results.length > 0;
const firstResultHref = useMemo(() => {
return results[0]?.href ?? null;
}, [results]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
useEffect(() => {
return () => {
abortControllerRef.current?.abort();
};
}, []);
useEffect(() => {
const trimmedQuery = query.trim();
if (trimmedQuery.length < MIN_QUERY_LENGTH) {
setResults([]);
setIsLoading(false);
return;
}
const timeoutId = setTimeout(async () => {
try {
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setIsLoading(true);
const response = await fetch(`/api/provider/search?q=${encodeURIComponent(trimmedQuery)}`, {
method: "GET",
signal: controller.signal,
cache: "no-store",
});
if (!response.ok) {
throw new Error("Search request failed");
}
const data = (await response.json()) as NormalizedSearchResult[];
setResults(Array.isArray(data) ? data : []);
setIsOpen(true);
} catch (error) {
if ((error as Error).name !== "AbortError") {
setResults([]);
}
} finally {
setIsLoading(false);
}
}, 250);
return () => {
clearTimeout(timeoutId);
};
}, [query]);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (firstResultHref) {
setIsOpen(false);
router.push(firstResultHref);
}
};
return (
<div ref={containerRef} className="relative w-full">
<form onSubmit={handleSubmit}>
<Search className="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
value={query}
onFocus={() => {
if (query.trim().length >= MIN_QUERY_LENGTH) {
setIsOpen(true);
}
}}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
setIsOpen(false);
}
}}
placeholder={t("header.search.placeholder")}
aria-label={t("header.search.placeholder")}
className="h-10 rounded-full border-border/60 bg-background/65 pl-10 pr-10 text-sm shadow-sm focus-visible:ring-primary/40"
/>
{isLoading && (
<div className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2">
<div className="h-4 w-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
</div>
)}
</form>
{isOpen && query.trim().length >= MIN_QUERY_LENGTH && (
<div className="absolute left-0 right-0 top-[calc(100%+0.5rem)] z-50 overflow-hidden rounded-2xl border border-border/70 bg-background/95 shadow-xl backdrop-blur-xl">
<div className="max-h-[26rem] overflow-y-auto p-2">
{seriesResults.length > 0 && (
<div className="mb-2">
<div className="px-2 pb-1 pt-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{t("header.search.series")}
</div>
{seriesResults.map((item) => (
<Link
key={item.id}
href={item.href}
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 rounded-xl px-3 py-2.5 transition-colors hover:bg-accent"
aria-label={t("header.search.openSeries", { title: item.title })}
>
<img
src={item.coverUrl}
alt={item.title}
loading="lazy"
className="h-14 w-10 shrink-0 rounded object-cover bg-muted"
onError={(e) => { e.currentTarget.style.display = "none"; }}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-base font-medium">{item.title}</p>
<p className="mt-0.5 flex items-center gap-1 text-sm text-muted-foreground">
<Library className="h-3 w-3" />
{item.bookCount !== undefined && t("series.books", { count: item.bookCount })}
</p>
</div>
</Link>
))}
</div>
)}
{bookResults.length > 0 && (
<div>
<div className="px-2 pb-1 pt-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{t("header.search.books")}
</div>
{bookResults.map((item) => (
<Link
key={item.id}
href={item.href}
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 rounded-xl px-3 py-2.5 transition-colors hover:bg-accent"
aria-label={t("header.search.openBook", { title: item.title })}
>
<img
src={item.coverUrl}
alt={item.title}
loading="lazy"
className="h-14 w-10 shrink-0 rounded object-cover bg-muted"
onError={(e) => { e.currentTarget.style.display = "none"; }}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-base font-medium">{item.title}</p>
<p className="mt-0.5 flex items-center gap-1 text-sm text-muted-foreground">
<BookOpen className="h-3 w-3" />
<span className="truncate">{item.seriesTitle}</span>
</p>
</div>
</Link>
))}
</div>
)}
{!isLoading && !hasResults && (
<p className="px-3 py-4 text-sm text-muted-foreground">{t("header.search.empty")}</p>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,9 +1,11 @@
import { Menu, Moon, Sun, RefreshCw } 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 { useAnonymous } from "@/contexts/AnonymousContext";
interface HeaderProps { interface HeaderProps {
onToggleSidebar: () => void; onToggleSidebar: () => void;
@@ -18,7 +20,9 @@ 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 toggleTheme = () => { const toggleTheme = () => {
setTheme(theme === "dark" ? "light" : "dark"); setTheme(theme === "dark" ? "light" : "dark");
@@ -33,7 +37,7 @@ export function Header({
}; };
return ( return (
<header className="sticky top-0 z-50 w-full border-b border-primary/30 bg-background/70 shadow-sm backdrop-blur-xl supports-[backdrop-filter]:bg-background/65 pt-safe relative overflow-hidden"> <header className="sticky top-0 z-50 w-full border-b border-primary/30 bg-background/70 shadow-sm backdrop-blur-xl supports-[backdrop-filter]:bg-background/65 pt-safe relative overflow-visible">
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(112deg,hsl(var(--primary)/0.24)_0%,hsl(192_85%_55%/0.2)_30%,transparent_56%),linear-gradient(248deg,hsl(338_82%_62%/0.16)_0%,transparent_46%),repeating-linear-gradient(135deg,hsl(var(--foreground)/0.03)_0_1px,transparent_1px_11px)]" /> <div className="pointer-events-none absolute inset-0 bg-[linear-gradient(112deg,hsl(var(--primary)/0.24)_0%,hsl(192_85%_55%/0.2)_30%,transparent_56%),linear-gradient(248deg,hsl(338_82%_62%/0.16)_0%,transparent_46%),repeating-linear-gradient(135deg,hsl(var(--foreground)/0.03)_0_1px,transparent_1px_11px)]" />
<div className="container relative flex h-16 max-w-screen-2xl items-center"> <div className="container relative flex h-16 max-w-screen-2xl items-center">
<IconButton <IconButton
@@ -48,20 +52,21 @@ 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 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:hidden"> <span className="inline-flex flex-col leading-none">
Strip <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]">
</span>
<span className="hidden sm:inline-flex flex-col leading-none">
<span className="bg-gradient-to-r from-primary via-cyan-500 to-fuchsia-500 bg-clip-text text-lg font-bold tracking-[0.08em] text-transparent">
StripStream StripStream
</span> </span>
<span className="mt-1 text-[10px] font-medium uppercase tracking-[0.22em] text-foreground/70"> <span className="mt-1 hidden text-[10px] font-medium uppercase tracking-[0.22em] text-foreground/70 sm:inline">
comic reader comic reader
</span> </span>
</span> </span>
</a> </a>
</div> </div>
<div className="hidden min-w-0 flex-1 px-1 sm:block sm:px-3">
<GlobalSearch />
</div>
<div className="ml-auto flex items-center"> <div className="ml-auto flex items-center">
<nav className="flex items-center gap-1 rounded-full border border-border/60 bg-background/45 px-1 py-1 shadow-[0_4px_18px_-14px_rgba(0,0,0,0.65)] backdrop-blur-md"> <nav className="flex items-center gap-1 rounded-full border border-border/60 bg-background/45 px-1 py-1 shadow-[0_4px_18px_-14px_rgba(0,0,0,0.65)] backdrop-blur-md">
{showRefreshBackground && ( {showRefreshBackground && (
@@ -76,6 +81,22 @@ export function Header({
tooltip="Rafraîchir l'image de fond" tooltip="Rafraîchir l'image de fond"
/> />
)} )}
<IconButton
onClick={() => setIsMobileSearchOpen((value) => !value)}
variant="ghost"
size="icon"
icon={Search}
className="h-9 w-9 rounded-full sm:hidden"
tooltip={t("header.search.placeholder")}
/>
<IconButton
onClick={toggleAnonymous}
variant="ghost"
size="icon"
icon={isAnonymous ? EyeOff : Eye}
className={`h-9 w-9 rounded-full ${isAnonymous ? "text-yellow-500 hover:text-yellow-400" : ""}`}
tooltip={t(isAnonymous ? "header.anonymousModeOn" : "header.anonymousModeOff")}
/>
<LanguageSelector /> <LanguageSelector />
<button <button
onClick={toggleTheme} onClick={toggleTheme}
@@ -91,6 +112,11 @@ export function Header({
</nav> </nav>
</div> </div>
</div> </div>
{isMobileSearchOpen && (
<div className="border-t border-border/50 bg-background/90 px-3 pb-3 pt-2 backdrop-blur-xl sm:hidden">
<GlobalSearch />
</div>
)}
</header> </header>
); );
} }

View File

@@ -15,7 +15,7 @@ import { usePathname, useRouter } from "next/navigation";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import type { KomgaLibrary, KomgaSeries } from "@/types/komga"; import type { NormalizedLibrary, NormalizedSeries } from "@/lib/providers/types";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { NavButton } from "@/components/ui/nav-button"; import { NavButton } from "@/components/ui/nav-button";
@@ -25,8 +25,8 @@ import logger from "@/lib/logger";
interface SidebarProps { interface SidebarProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
initialLibraries: KomgaLibrary[]; initialLibraries: NormalizedLibrary[];
initialFavorites: KomgaSeries[]; initialFavorites: NormalizedSeries[];
userIsAdmin?: boolean; userIsAdmin?: boolean;
} }
@@ -40,8 +40,8 @@ export function Sidebar({
const { t } = useTranslate(); const { t } = useTranslate();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const [libraries, setLibraries] = useState<KomgaLibrary[]>(initialLibraries || []); const [libraries, setLibraries] = useState<NormalizedLibrary[]>(initialLibraries || []);
const [favorites, setFavorites] = useState<KomgaSeries[]>(initialFavorites || []); const [favorites, setFavorites] = useState<NormalizedSeries[]>(initialFavorites || []);
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
@@ -60,7 +60,7 @@ export function Sidebar({
const customEvent = event as CustomEvent<{ const customEvent = event as CustomEvent<{
seriesId?: string; seriesId?: string;
action?: "add" | "remove"; action?: "add" | "remove";
series?: KomgaSeries; series?: NormalizedSeries;
}>; }>;
// Si on a les détails de l'action, faire une mise à jour optimiste locale // Si on a les détails de l'action, faire une mise à jour optimiste locale
@@ -207,7 +207,7 @@ export function Sidebar({
<NavButton <NavButton
key={series.id} key={series.id}
icon={Star} icon={Star}
label={series.metadata.title} label={series.name}
active={pathname === `/series/${series.id}`} active={pathname === `/series/${series.id}`}
onClick={() => handleLinkClick(`/series/${series.id}`)} onClick={() => handleLinkClick(`/series/${series.id}`)}
className="[&_svg]:fill-yellow-400 [&_svg]:text-yellow-400" className="[&_svg]:fill-yellow-400 [&_svg]:text-yellow-400"

View File

@@ -1,17 +1,17 @@
import { Library } from "lucide-react"; import { Library } from "lucide-react";
import type { KomgaLibrary, KomgaSeries } from "@/types/komga"; import type { NormalizedLibrary, NormalizedSeries } from "@/lib/providers/types";
import { RefreshButton } from "./RefreshButton"; import { RefreshButton } from "./RefreshButton";
import { ScanButton } from "./ScanButton"; import { ScanButton } from "./ScanButton";
import { StatusBadge } from "@/components/ui/status-badge"; import { StatusBadge } from "@/components/ui/status-badge";
import { SeriesCover } from "@/components/ui/series-cover"; import { SeriesCover } from "@/components/ui/series-cover";
interface LibraryHeaderProps { interface LibraryHeaderProps {
library: KomgaLibrary; library: NormalizedLibrary;
seriesCount: number; seriesCount: number;
series: KomgaSeries[]; series: NormalizedSeries[];
} }
const getHeaderSeries = (series: KomgaSeries[]) => { const getHeaderSeries = (series: NormalizedSeries[]) => {
if (series.length === 0) { if (series.length === 0) {
return { featured: null, background: null }; return { featured: null, background: null };
} }
@@ -84,8 +84,6 @@ export function LibraryHeader({
<RefreshButton libraryId={library.id} /> <RefreshButton libraryId={library.id} />
<ScanButton libraryId={library.id} /> <ScanButton libraryId={library.id} />
</div> </div>
{library.unavailable && <p className="text-sm text-destructive mt-2">Bibliotheque indisponible</p>}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -5,7 +5,7 @@ import { SeriesList } from "./SeriesList";
import { Pagination } from "@/components/ui/Pagination"; import { Pagination } from "@/components/ui/Pagination";
import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import type { KomgaSeries } from "@/types/komga"; import type { NormalizedSeries } from "@/lib/providers/types";
import { SearchInput } from "./SearchInput"; import { SearchInput } from "./SearchInput";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { PageSizeSelect } from "@/components/common/PageSizeSelect"; import { PageSizeSelect } from "@/components/common/PageSizeSelect";
@@ -15,7 +15,7 @@ import { UnreadFilterButton } from "@/components/common/UnreadFilterButton";
import { updatePreferences as updatePreferencesAction } from "@/app/actions/preferences"; import { updatePreferences as updatePreferencesAction } from "@/app/actions/preferences";
interface PaginatedSeriesGridProps { interface PaginatedSeriesGridProps {
series: KomgaSeries[]; series: NormalizedSeries[];
currentPage: number; currentPage: number;
totalPages: number; totalPages: number;
totalElements: number; totalElements: number;
@@ -108,19 +108,13 @@ export function PaginatedSeriesGrid({
const handleUnreadFilter = async () => { const handleUnreadFilter = async () => {
const newUnreadState = !showOnlyUnread; const newUnreadState = !showOnlyUnread;
setShowOnlyUnread(newUnreadState); setShowOnlyUnread(newUnreadState);
await updateUrlParams({ await updateUrlParams({ page: "1", unread: newUnreadState ? "true" : "false" });
page: "1",
unread: newUnreadState ? "true" : "false",
});
await persistPreferences({ showOnlyUnread: newUnreadState }); await persistPreferences({ showOnlyUnread: newUnreadState });
}; };
const handlePageSizeChange = async (size: number) => { const handlePageSizeChange = async (size: number) => {
setCurrentPageSize(size); setCurrentPageSize(size);
await updateUrlParams({ await updateUrlParams({ page: "1", size: size.toString() });
page: "1",
size: size.toString(),
});
await persistPreferences({ await persistPreferences({
displayMode: { displayMode: {

View File

@@ -1,29 +1,30 @@
"use client"; "use client";
import type { KomgaSeries } from "@/types/komga"; import type { NormalizedSeries } from "@/lib/providers/types";
import { useRouter } from "next/navigation"; 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: KomgaSeries[]; series: NormalizedSeries[];
isCompact?: boolean; isCompact?: boolean;
} }
// Utility function to get reading status info // Utility function to get reading status info
const getReadingStatusInfo = ( const getReadingStatusInfo = (
series: KomgaSeries, series: NormalizedSeries,
t: (key: string, options?: { [key: string]: string | number }) => string t: (key: string, options?: { [key: string]: string | number }) => string
) => { ) => {
if (series.booksCount === 0) { if (series.bookCount === 0) {
return { return {
label: t("series.status.noBooks"), label: t("series.status.noBooks"),
className: "bg-yellow-500/10 text-yellow-500", className: "bg-yellow-500/10 text-yellow-500",
}; };
} }
if (series.booksCount === series.booksReadCount) { if (series.bookCount === series.booksReadCount) {
return { return {
label: t("series.status.read"), label: t("series.status.read"),
className: "bg-green-500/10 text-green-500", className: "bg-green-500/10 text-green-500",
@@ -34,7 +35,7 @@ const getReadingStatusInfo = (
return { return {
label: t("series.status.progress", { label: t("series.status.progress", {
read: series.booksReadCount, read: series.booksReadCount,
total: series.booksCount, total: series.bookCount,
}), }),
className: "bg-primary/15 text-primary", className: "bg-primary/15 text-primary",
}; };
@@ -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 (
@@ -67,32 +69,35 @@ export function SeriesGrid({ series, isCompact = false }: SeriesGridProps) {
: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5" : "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5"
)} )}
> >
{series.map((series) => ( {series.map((seriesItem) => (
<button <button
key={series.id} key={seriesItem.id}
onClick={() => router.push(`/series/${series.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",
series.booksCount === series.booksReadCount && "opacity-50", !isAnonymous && seriesItem.bookCount === seriesItem.booksReadCount && "opacity-50",
isCompact && "aspect-[3/4]" isCompact && "aspect-[3/4]"
)} )}
> >
<SeriesCover <SeriesCover
series={series as KomgaSeries} series={seriesItem}
alt={t("series.coverAlt", { title: series.metadata.title })} 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">{series.metadata.title}</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(series, t).className className={`px-2 py-0.5 rounded-full text-xs ${
}`} getReadingStatusInfo(seriesItem, t).className
> }`}
{getReadingStatusInfo(series, 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: series.booksCount })} {t("series.books", { count: seriesItem.bookCount })}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import type { KomgaSeries } from "@/types/komga"; import type { NormalizedSeries } from "@/lib/providers/types";
import { SeriesCover } from "@/components/ui/series-cover"; import { SeriesCover } from "@/components/ui/series-cover";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
@@ -8,30 +8,31 @@ 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: KomgaSeries[]; series: NormalizedSeries[];
isCompact?: boolean; isCompact?: boolean;
} }
interface SeriesListItemProps { interface SeriesListItemProps {
series: KomgaSeries; series: NormalizedSeries;
isCompact?: boolean; isCompact?: boolean;
} }
// Utility function to get reading status info // Utility function to get reading status info
const getReadingStatusInfo = ( const getReadingStatusInfo = (
series: KomgaSeries, series: NormalizedSeries,
t: (key: string, options?: { [key: string]: string | number }) => string t: (key: string, options?: { [key: string]: string | number }) => string
) => { ) => {
if (series.booksCount === 0) { if (series.bookCount === 0) {
return { return {
label: t("series.status.noBooks"), label: t("series.status.noBooks"),
className: "bg-yellow-500/10 text-yellow-500", className: "bg-yellow-500/10 text-yellow-500",
}; };
} }
if (series.booksCount === series.booksReadCount) { if (series.bookCount === series.booksReadCount) {
return { return {
label: t("series.status.read"), label: t("series.status.read"),
className: "bg-green-500/10 text-green-500", className: "bg-green-500/10 text-green-500",
@@ -42,7 +43,7 @@ const getReadingStatusInfo = (
return { return {
label: t("series.status.progress", { label: t("series.status.progress", {
read: series.booksReadCount, read: series.booksReadCount,
total: series.booksCount, total: series.bookCount,
}), }),
className: "bg-primary/15 text-primary", className: "bg-primary/15 text-primary",
}; };
@@ -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.booksCount === series.booksReadCount; const isCompleted = isAnonymous ? false : series.bookCount === series.booksReadCount;
const progressPercentage = const progressPercentage =
series.booksCount > 0 ? (series.booksReadCount / series.booksCount) * 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 (
@@ -81,8 +83,9 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<div className="relative w-12 h-16 sm:w-14 sm:h-20 flex-shrink-0 rounded overflow-hidden bg-muted"> <div className="relative w-12 h-16 sm:w-14 sm:h-20 flex-shrink-0 rounded overflow-hidden bg-muted">
<SeriesCover <SeriesCover
series={series} series={series}
alt={t("series.coverAlt", { title: series.metadata.title })} alt={t("series.coverAlt", { title: series.name })}
className="w-full h-full" className="w-full h-full"
isAnonymous={isAnonymous}
/> />
</div> </div>
@@ -91,16 +94,18 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
{/* Titre et statut */} {/* Titre et statut */}
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<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.metadata.title} {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 */}
@@ -108,15 +113,15 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<BookOpen className="h-3 w-3" /> <BookOpen className="h-3 w-3" />
<span> <span>
{series.booksCount === 1 {series.bookCount === 1
? t("series.book", { count: 1 }) ? t("series.book", { count: 1 })
: t("series.books", { count: series.booksCount })} : t("series.books", { count: series.bookCount })}
</span> </span>
</div> </div>
{series.booksMetadata?.authors && series.booksMetadata.authors.length > 0 && ( {series.authors && series.authors.length > 0 && (
<div className="flex items-center gap-1 hidden sm:flex"> <div className="flex items-center gap-1 hidden sm:flex">
<User className="h-3 w-3" /> <User className="h-3 w-3" />
<span className="line-clamp-1">{series.booksMetadata.authors[0].name}</span> <span className="line-clamp-1">{series.authors[0].name}</span>
</div> </div>
)} )}
</div> </div>
@@ -137,8 +142,9 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<div className="relative w-20 h-28 sm:w-24 sm:h-36 flex-shrink-0 rounded overflow-hidden bg-muted"> <div className="relative w-20 h-28 sm:w-24 sm:h-36 flex-shrink-0 rounded overflow-hidden bg-muted">
<SeriesCover <SeriesCover
series={series} series={series}
alt={t("series.coverAlt", { title: series.metadata.title })} alt={t("series.coverAlt", { title: series.name })}
className="w-full h-full" className="w-full h-full"
isAnonymous={isAnonymous}
/> />
</div> </div>
@@ -148,25 +154,27 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-semibold text-base sm:text-lg line-clamp-2 hover:text-primary transition-colors"> <h3 className="font-semibold text-base sm:text-lg line-clamp-2 hover:text-primary transition-colors">
{series.metadata.title} {series.name}
</h3> </h3>
</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é */}
{series.metadata.summary && ( {series.summary && (
<p className="text-sm text-muted-foreground line-clamp-2 hidden sm:block"> <p className="text-sm text-muted-foreground line-clamp-2 hidden sm:block">
{series.metadata.summary} {series.summary}
</p> </p>
)} )}
@@ -176,55 +184,55 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<BookOpen className="h-3 w-3" /> <BookOpen className="h-3 w-3" />
<span> <span>
{series.booksCount === 1 {series.bookCount === 1
? t("series.book", { count: 1 }) ? t("series.book", { count: 1 })
: t("series.books", { count: series.booksCount })} : t("series.books", { count: series.bookCount })}
</span> </span>
</div> </div>
{/* Auteurs */} {/* Auteurs */}
{series.booksMetadata?.authors && series.booksMetadata.authors.length > 0 && ( {series.authors && series.authors.length > 0 && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<User className="h-3 w-3" /> <User className="h-3 w-3" />
<span className="line-clamp-1"> <span className="line-clamp-1">
{series.booksMetadata.authors.map((a) => a.name).join(", ")} {series.authors.map((a) => a.name).join(", ")}
</span> </span>
</div> </div>
)} )}
{/* Date de création */} {/* Date de création */}
{series.created && ( {series.createdAt && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />
<span>{formatDate(series.created)}</span> <span>{formatDate(series.createdAt)}</span>
</div> </div>
)} )}
{/* Genres */} {/* Genres */}
{series.metadata.genres && series.metadata.genres.length > 0 && ( {series.genres && series.genres.length > 0 && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Tag className="h-3 w-3" /> <Tag className="h-3 w-3" />
<span className="line-clamp-1"> <span className="line-clamp-1">
{series.metadata.genres.slice(0, 3).join(", ")} {series.genres.slice(0, 3).join(", ")}
{series.metadata.genres.length > 3 && ` +${series.metadata.genres.length - 3}`} {series.genres.length > 3 && ` +${series.genres.length - 3}`}
</span> </span>
</div> </div>
)} )}
{/* Tags */} {/* Tags */}
{series.metadata.tags && series.metadata.tags.length > 0 && ( {series.tags && series.tags.length > 0 && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Tag className="h-3 w-3" /> <Tag className="h-3 w-3" />
<span className="line-clamp-1"> <span className="line-clamp-1">
{series.metadata.tags.slice(0, 3).join(", ")} {series.tags.slice(0, 3).join(", ")}
{series.metadata.tags.length > 3 && ` +${series.metadata.tags.length - 3}`} {series.tags.length > 3 && ` +${series.tags.length - 3}`}
</span> </span>
</div> </div>
)} )}
</div> </div>
{/* Barre de progression */} {/* Barre de progression */}
{series.booksCount > 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

@@ -5,16 +5,16 @@ import { ClientBookWrapper } from "./ClientBookWrapper";
import { BookSkeleton } from "@/components/skeletons/BookSkeleton"; import { BookSkeleton } from "@/components/skeletons/BookSkeleton";
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 type { KomgaBook } from "@/types/komga"; import type { NormalizedBook } from "@/lib/providers/types";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
import { getBookData } from "@/app/actions/books"; import { getBookData } from "@/app/actions/books";
interface ClientBookPageProps { interface ClientBookPageProps {
bookId: string; bookId: string;
initialData?: { initialData?: {
book: KomgaBook; book: NormalizedBook;
pages: number[]; pages: number[];
nextBook: KomgaBook | null; nextBook: NormalizedBook | null;
}; };
initialError?: string; initialError?: string;
} }
@@ -23,9 +23,9 @@ export function ClientBookPage({ bookId, initialData, initialError }: ClientBook
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<{ const [data, setData] = useState<{
book: KomgaBook; book: NormalizedBook;
pages: number[]; pages: number[];
nextBook: KomgaBook | null; nextBook: NormalizedBook | null;
} | null>(null); } | null>(null);
// Use SSR data if available // Use SSR data if available

View File

@@ -2,12 +2,12 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { KomgaBook } from "@/types/komga"; import type { NormalizedBook } from "@/lib/providers/types";
import { PhotoswipeReader } from "./PhotoswipeReader"; import { PhotoswipeReader } from "./PhotoswipeReader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
interface ClientBookReaderProps { interface ClientBookReaderProps {
book: KomgaBook; book: NormalizedBook;
pages: number[]; pages: number[];
} }

View File

@@ -1,32 +1,26 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import type { KomgaBook } from "@/types/komga"; import type { NormalizedBook } from "@/lib/providers/types";
import { PhotoswipeReader } from "./PhotoswipeReader"; import { PhotoswipeReader } from "./PhotoswipeReader";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service"; import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
interface ClientBookWrapperProps { interface ClientBookWrapperProps {
book: KomgaBook; book: NormalizedBook;
pages: number[]; pages: number[];
nextBook: KomgaBook | null; nextBook: NormalizedBook | null;
} }
export function ClientBookWrapper({ book, pages, nextBook }: ClientBookWrapperProps) { export function ClientBookWrapper({ book, pages, nextBook }: ClientBookWrapperProps) {
const router = useRouter(); const router = useRouter();
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const [targetPath, setTargetPath] = useState<string | null>(null);
useEffect(() => {
if (!isClosing || !targetPath) return;
router.push(targetPath);
}, [isClosing, targetPath, router]);
const handleCloseReader = (currentPage: number) => { const handleCloseReader = (currentPage: number) => {
ClientOfflineBookService.setCurrentPage(book, currentPage); ClientOfflineBookService.setCurrentPage(book, currentPage);
setTargetPath(`/series/${book.seriesId}`);
setIsClosing(true); setIsClosing(true);
router.back();
}; };
if (isClosing) { if (isClosing) {

View File

@@ -24,6 +24,17 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
const lastClickTimeRef = useRef<number>(0); const lastClickTimeRef = useRef<number>(0);
const clickTimeoutRef = useRef<NodeJS.Timeout | null>(null); const clickTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Derive page URL builder from book.thumbnailUrl (provider-agnostic)
const bookPageUrlBuilder = useCallback(
(pageNum: number) => book.thumbnailUrl.replace("/thumbnail", `/pages/${pageNum}`),
[book.thumbnailUrl]
);
const nextBookPageUrlBuilder = useCallback(
(pageNum: number) =>
nextBook ? nextBook.thumbnailUrl.replace("/thumbnail", `/pages/${pageNum}`) : "",
[nextBook]
);
// Hooks // Hooks
const { direction, toggleDirection, isRTL } = useReadingDirection(); const { direction, toggleDirection, isRTL } = useReadingDirection();
const { isFullscreen, toggleFullscreen } = useFullscreen(); const { isFullscreen, toggleFullscreen } = useFullscreen();
@@ -31,17 +42,19 @@ 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({
bookId: book.id, pageUrlBuilder: bookPageUrlBuilder,
pages, pages,
prefetchCount: preferences.readerPrefetchCount, prefetchCount: preferences.readerPrefetchCount,
nextBook: nextBook ? { id: nextBook.id, pages: [] } : null, nextBook: nextBook ? { getPageUrl: nextBookPageUrlBuilder, pages: [] } : null,
}); });
const { currentPage, showEndMessage, navigateToPage, handlePreviousPage, handleNextPage } = const { currentPage, showEndMessage, navigateToPage, handlePreviousPage, handleNextPage } =
usePageNavigation({ usePageNavigation({
@@ -63,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 (
@@ -85,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
@@ -97,6 +145,7 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
currentPage, currentPage,
isDoublePage, isDoublePage,
shouldShowDoublePage, shouldShowDoublePage,
prefetchImage,
prefetchPages, prefetchPages,
prefetchNextBook, prefetchNextBook,
prefetchCount, prefetchCount,
@@ -216,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

@@ -9,14 +9,14 @@ interface ImageDimensions {
type ImageKey = number | string; // Support both numeric pages and prefixed keys like "next-1" type ImageKey = number | string; // Support both numeric pages and prefixed keys like "next-1"
interface UseImageLoaderProps { interface UseImageLoaderProps {
bookId: string; pageUrlBuilder: (pageNum: number) => string;
pages: number[]; pages: number[];
prefetchCount?: number; // Nombre de pages à précharger (défaut: 5) prefetchCount?: number; // Nombre de pages à précharger (défaut: 5)
nextBook?: { id: string; pages: number[] } | null; // Livre suivant pour prefetch nextBook?: { getPageUrl: (pageNum: number) => string; pages: number[] } | null; // Livre suivant pour prefetch
} }
export function useImageLoader({ export function useImageLoader({
bookId, pageUrlBuilder,
pages: _pages, pages: _pages,
prefetchCount = 5, prefetchCount = 5,
nextBook, nextBook,
@@ -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(
@@ -73,8 +78,8 @@ export function useImageLoader({
); );
const getPageUrl = useCallback( const getPageUrl = useCallback(
(pageNum: number) => `/api/komga/books/${bookId}/pages/${pageNum}`, (pageNum: number) => pageUrlBuilder(pageNum),
[bookId] [pageUrlBuilder]
); );
// Prefetch image and store dimensions // Prefetch image and store dimensions
@@ -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,
}));
};
img.onerror = () => { // Wait for image to load before resolving promise
URL.revokeObjectURL(blobUrl); await new Promise<void>((resolve, reject) => {
}; img.onload = () => {
if (!isMountedRef.current || controller.signal.aborted) {
URL.revokeObjectURL(blobUrl);
reject(new Error("Aborted"));
return;
}
img.src = blobUrl; setLoadedImages((prev) => ({
} catch { ...prev,
// Silently fail prefetch [pageNum]: { width: img.naturalWidth, height: img.naturalHeight },
} finally { }));
// Remove from pending set
pendingFetchesRef.current.delete(pageNum); // Store the blob URL for immediate use
abortControllersRef.current.delete(pageNum); setImageBlobUrls((prev) => ({
} ...prev,
[pageNum]: blobUrl,
}));
resolve();
};
img.onerror = () => {
URL.revokeObjectURL(blobUrl);
reject(new Error("Image load error"));
};
img.src = blobUrl;
});
} catch {
// Silently fail prefetch
} finally {
// Remove from pending set and promise map
pendingFetchesRef.current.delete(pageNum);
abortControllersRef.current.delete(pageNum);
loadingPromisesRef.current.delete(pageNum);
}
})();
// Store promise so other calls can await it
loadingPromisesRef.current.set(pageNum, promise);
return promise;
}, },
[getPageUrl] [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
}); });
} }
@@ -216,7 +247,7 @@ export function useImageLoader({
abortControllersRef.current.set(nextBookPageKey, controller); abortControllersRef.current.set(nextBookPageKey, controller);
try { try {
const response = await fetch(`/api/komga/books/${nextBook.id}/pages/${pageNum}`, { const response = await fetch(nextBook.getPageUrl(pageNum), {
cache: "default", // Respect Cache-Control headers from server cache: "default", // Respect Cache-Control headers from server
signal: controller.signal, signal: controller.signal,
}); });
@@ -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

@@ -1,17 +1,18 @@
import { useState, useCallback, useRef, useEffect } from "react"; import { useState, useCallback, useRef, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service"; import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
import type { KomgaBook } from "@/types/komga"; 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: KomgaBook; book: NormalizedBook;
pages: number[]; pages: number[];
isDoublePage: boolean; isDoublePage: boolean;
shouldShowDoublePage: (page: number) => boolean; shouldShowDoublePage: (page: number) => boolean;
onClose?: (currentPage: number) => void; onClose?: (currentPage: number) => void;
nextBook?: KomgaBook | null; nextBook?: NormalizedBook | null;
} }
export function usePageNavigation({ export function usePageNavigation({
@@ -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,8 +1,8 @@
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect } from "react";
import type { KomgaBook } from "@/types/komga"; import type { NormalizedBook } from "@/lib/providers/types";
interface UseThumbnailsProps { interface UseThumbnailsProps {
book: KomgaBook; book: NormalizedBook;
currentPage: number; currentPage: number;
} }
@@ -16,9 +16,13 @@ export const useThumbnails = ({ book, currentPage }: UseThumbnailsProps) => {
const getThumbnailUrl = useCallback( const getThumbnailUrl = useCallback(
(pageNumber: number) => { (pageNumber: number) => {
// Derive page URL from the book's thumbnailUrl provider pattern
if (book.thumbnailUrl.startsWith("/api/stripstream/")) {
return `/api/stripstream/images/books/${book.id}/pages/${pageNumber}`;
}
return `/api/komga/images/books/${book.id}/pages/${pageNumber}/thumbnail?zero_based=true`; return `/api/komga/images/books/${book.id}/pages/${pageNumber}/thumbnail?zero_based=true`;
}, },
[book.id] [book.id, book.thumbnailUrl]
); );
// Mettre à jour les thumbnails visibles autour de la page courante // Mettre à jour les thumbnails visibles autour de la page courante

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

@@ -1,4 +1,4 @@
import type { KomgaBook } from "@/types/komga"; import type { NormalizedBook } from "@/lib/providers/types";
export interface PageCache { export interface PageCache {
[pageNumber: number]: { [pageNumber: number]: {
@@ -10,10 +10,10 @@ export interface PageCache {
} }
export interface BookReaderProps { export interface BookReaderProps {
book: KomgaBook; book: NormalizedBook;
pages: number[]; pages: number[];
onClose?: (currentPage: number) => void; onClose?: (currentPage: number) => void;
nextBook?: KomgaBook | null; nextBook?: NormalizedBook | null;
} }
export interface ThumbnailProps { export interface ThumbnailProps {
@@ -32,7 +32,7 @@ export interface NavigationBarProps {
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
showControls: boolean; showControls: boolean;
showThumbnails: boolean; showThumbnails: boolean;
book: KomgaBook; book: NormalizedBook;
} }
export interface ControlButtonsProps { export interface ControlButtonsProps {
@@ -57,7 +57,7 @@ export interface ControlButtonsProps {
} }
export interface UsePageNavigationProps { export interface UsePageNavigationProps {
book: KomgaBook; book: NormalizedBook;
pages: number[]; pages: number[];
isDoublePage: boolean; isDoublePage: boolean;
onClose?: () => void; onClose?: () => void;

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import type { KomgaBook } from "@/types/komga"; import type { NormalizedBook } from "@/lib/providers/types";
import { BookCover } from "@/components/ui/book-cover"; import { BookCover } from "@/components/ui/book-cover";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
@@ -8,16 +8,16 @@ import { cn } from "@/lib/utils";
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus"; import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
interface BookGridProps { interface BookGridProps {
books: KomgaBook[]; books: NormalizedBook[];
onBookClick: (book: KomgaBook) => void; onBookClick: (book: NormalizedBook) => void;
isCompact?: boolean; isCompact?: boolean;
onRefresh?: () => void; onRefresh?: () => void;
} }
interface BookCardProps { interface BookCardProps {
book: KomgaBook; book: NormalizedBook;
onBookClick: (book: KomgaBook) => void; onBookClick: (book: NormalizedBook) => void;
onSuccess: (book: KomgaBook, action: "read" | "unread") => void; onSuccess: (book: NormalizedBook, action: "read" | "unread") => void;
isCompact: boolean; isCompact: boolean;
} }
@@ -50,9 +50,9 @@ function BookCard({ book, onBookClick, onSuccess, isCompact }: BookCardProps) {
book={book} book={book}
alt={t("books.coverAlt", { alt={t("books.coverAlt", {
title: title:
book.metadata.title || book.title ||
(book.metadata.number (book.number
? t("navigation.volume", { number: book.metadata.number }) ? t("navigation.volume", { number: book.number })
: ""), : ""),
})} })}
onSuccess={(book, action) => onSuccess(book, action)} onSuccess={(book, action) => onSuccess(book, action)}
@@ -84,7 +84,7 @@ export function BookGrid({ books, onBookClick, isCompact = false, onRefresh }: B
); );
} }
const handleOnSuccess = (book: KomgaBook, action: "read" | "unread") => { const handleOnSuccess = (book: NormalizedBook, action: "read" | "unread") => {
if (action === "read") { if (action === "read") {
setLocalBooks( setLocalBooks(
localBooks.map((previousBook) => localBooks.map((previousBook) =>
@@ -93,10 +93,8 @@ export function BookGrid({ books, onBookClick, isCompact = false, onRefresh }: B
...previousBook, ...previousBook,
readProgress: { readProgress: {
completed: true, completed: true,
page: previousBook.media.pagesCount, page: previousBook.pageCount,
readDate: new Date().toISOString(), lastReadAt: new Date().toISOString(),
created: new Date().toISOString(),
lastModified: new Date().toISOString(),
}, },
} }
: previousBook : previousBook

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import type { KomgaBook } from "@/types/komga"; import type { NormalizedBook } from "@/lib/providers/types";
import { BookCover } from "@/components/ui/book-cover"; import { BookCover } from "@/components/ui/book-cover";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
@@ -9,27 +9,29 @@ import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service"; import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.service";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { Calendar, FileText, User, Tag } from "lucide-react"; 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: KomgaBook[]; books: NormalizedBook[];
onBookClick: (book: KomgaBook) => void; onBookClick: (book: NormalizedBook) => void;
isCompact?: boolean; isCompact?: boolean;
onRefresh?: () => void; onRefresh?: () => void;
} }
interface BookListItemProps { interface BookListItemProps {
book: KomgaBook; book: NormalizedBook;
onBookClick: (book: KomgaBook) => void; onBookClick: (book: NormalizedBook) => void;
onSuccess: (book: KomgaBook, action: "read" | "unread") => void; onSuccess: (book: NormalizedBook, action: "read" | "unread") => void;
isCompact?: boolean; isCompact?: boolean;
} }
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,10 +39,10 @@ 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.media.pagesCount; const totalPages = book.pageCount;
const progressPercentage = totalPages > 0 ? (currentPage / totalPages) * 100 : 0; const progressPercentage = totalPages > 0 ? (currentPage / totalPages) * 100 : 0;
const getStatusInfo = () => { const getStatusInfo = () => {
@@ -52,7 +54,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
} }
if (book.readProgress.completed) { if (book.readProgress.completed) {
const readDate = book.readProgress.readDate ? formatDate(book.readProgress.readDate) : null; const readDate = book.readProgress.lastReadAt ? formatDate(book.readProgress.lastReadAt) : null;
return { return {
label: readDate ? t("books.status.readDate", { date: readDate }) : t("books.status.read"), label: readDate ? t("books.status.readDate", { date: readDate }) : t("books.status.read"),
className: "bg-green-500/10 text-green-500", className: "bg-green-500/10 text-green-500",
@@ -77,8 +79,8 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
const statusInfo = getStatusInfo(); const statusInfo = getStatusInfo();
const title = const title =
book.metadata.title || book.title ||
(book.metadata.number ? t("navigation.volume", { number: book.metadata.number }) : book.name); (book.number ? t("navigation.volume", { number: book.number }) : "");
if (isCompact) { if (isCompact) {
return ( return (
@@ -118,20 +120,22 @@ 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 */}
<div className="flex items-center gap-3 text-xs text-muted-foreground"> <div className="flex items-center gap-3 text-xs text-muted-foreground">
{book.metadata.number && ( {book.number && (
<span>{t("navigation.volume", { number: book.metadata.number })}</span> <span>{t("navigation.volume", { number: book.number })}</span>
)} )}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<FileText className="h-3 w-3" /> <FileText className="h-3 w-3" />
@@ -139,12 +143,6 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
{totalPages} {totalPages > 1 ? t("books.pages_plural") : t("books.pages")} {totalPages} {totalPages > 1 ? t("books.pages_plural") : t("books.pages")}
</span> </span>
</div> </div>
{book.metadata.authors && book.metadata.authors.length > 0 && (
<div className="flex items-center gap-1 hidden sm:flex">
<User className="h-3 w-3" />
<span className="line-clamp-1">{book.metadata.authors[0].name}</span>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -189,31 +187,26 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
> >
{title} {title}
</h3> </h3>
{book.metadata.number && ( {book.number && (
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
{t("navigation.volume", { number: book.metadata.number })} {t("navigation.volume", { number: book.number })}
</p> </p>
)} )}
</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>
{/* Résumé */}
{book.metadata.summary && (
<p className="text-sm text-muted-foreground line-clamp-2 hidden sm:block">
{book.metadata.summary}
</p>
)}
{/* Métadonnées */} {/* Métadonnées */}
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
{/* Pages */} {/* Pages */}
@@ -223,35 +216,6 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
{totalPages} {totalPages > 1 ? t("books.pages_plural") : t("books.pages")} {totalPages} {totalPages > 1 ? t("books.pages_plural") : t("books.pages")}
</span> </span>
</div> </div>
{/* Auteurs */}
{book.metadata.authors && book.metadata.authors.length > 0 && (
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
<span className="line-clamp-1">
{book.metadata.authors.map((a) => a.name).join(", ")}
</span>
</div>
)}
{/* Date de sortie */}
{book.metadata.releaseDate && (
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>{formatDate(book.metadata.releaseDate)}</span>
</div>
)}
{/* Tags */}
{book.metadata.tags && book.metadata.tags.length > 0 && (
<div className="flex items-center gap-1">
<Tag className="h-3 w-3" />
<span className="line-clamp-1">
{book.metadata.tags.slice(0, 3).join(", ")}
{book.metadata.tags.length > 3 && ` +${book.metadata.tags.length - 3}`}
</span>
</div>
)}
</div> </div>
{/* Barre de progression */} {/* Barre de progression */}
@@ -266,16 +230,16 @@ 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.media.pagesCount} pagesCount={book.pageCount}
isRead={isRead} isRead={isRead}
onSuccess={() => onSuccess(book, "read")} onSuccess={() => onSuccess(book, "read")}
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")}
@@ -311,7 +275,7 @@ export function BookList({ books, onBookClick, isCompact = false, onRefresh }: B
); );
} }
const handleOnSuccess = (book: KomgaBook, action: "read" | "unread") => { const handleOnSuccess = (book: NormalizedBook, action: "read" | "unread") => {
if (action === "read") { if (action === "read") {
setLocalBooks( setLocalBooks(
localBooks.map((previousBook) => localBooks.map((previousBook) =>
@@ -320,10 +284,8 @@ export function BookList({ books, onBookClick, isCompact = false, onRefresh }: B
...previousBook, ...previousBook,
readProgress: { readProgress: {
completed: true, completed: true,
page: previousBook.media.pagesCount, page: previousBook.pageCount,
readDate: new Date().toISOString(), lastReadAt: new Date().toISOString(),
created: new Date().toISOString(),
lastModified: new Date().toISOString(),
}, },
} }
: previousBook : previousBook

View File

@@ -5,7 +5,7 @@ import { BookList } from "./BookList";
import { Pagination } from "@/components/ui/Pagination"; import { Pagination } from "@/components/ui/Pagination";
import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import type { KomgaBook } from "@/types/komga"; import type { NormalizedBook } from "@/lib/providers/types";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { useDisplayPreferences } from "@/hooks/useDisplayPreferences"; import { useDisplayPreferences } from "@/hooks/useDisplayPreferences";
import { usePreferences } from "@/contexts/PreferencesContext"; import { usePreferences } from "@/contexts/PreferencesContext";
@@ -15,7 +15,7 @@ import { ViewModeButton } from "@/components/common/ViewModeButton";
import { UnreadFilterButton } from "@/components/common/UnreadFilterButton"; import { UnreadFilterButton } from "@/components/common/UnreadFilterButton";
interface PaginatedBookGridProps { interface PaginatedBookGridProps {
books: KomgaBook[]; books: NormalizedBook[];
currentPage: number; currentPage: number;
totalPages: number; totalPages: number;
totalElements: number; totalElements: number;
@@ -95,13 +95,10 @@ export function PaginatedBookGrid({
}; };
const handlePageSizeChange = async (size: number) => { const handlePageSizeChange = async (size: number) => {
await updateUrlParams({ await updateUrlParams({ page: "1", size: size.toString() });
page: "1",
size: size.toString(),
});
}; };
const handleBookClick = (book: KomgaBook) => { const handleBookClick = (book: NormalizedBook) => {
router.push(`/books/${book.id}`); router.push(`/books/${book.id}`);
}; };

View File

@@ -1,7 +1,7 @@
"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 { KomgaSeries } from "@/types/komga"; 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";
import { RefreshButton } from "@/components/library/RefreshButton"; import { RefreshButton } from "@/components/library/RefreshButton";
@@ -14,16 +14,19 @@ 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: KomgaSeries; series: NormalizedSeries;
refreshSeries: (seriesId: string) => Promise<{ success: boolean; error?: string }>; refreshSeries: (seriesId: string) => Promise<{ success: boolean; error?: string }>;
initialIsFavorite: boolean; initialIsFavorite: boolean;
} }
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(() => {
@@ -48,7 +51,7 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
window.dispatchEvent(event); window.dispatchEvent(event);
toast({ toast({
title: t(isFavorite ? "series.header.favorite.remove" : "series.header.favorite.add"), title: t(isFavorite ? "series.header.favorite.remove" : "series.header.favorite.add"),
description: series.metadata.title, description: series.name,
}); });
} else { } else {
throw new AppError( throw new AppError(
@@ -69,10 +72,11 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
}; };
const getReadingStatusInfo = () => { const getReadingStatusInfo = () => {
const { booksCount, booksReadCount, booksUnreadCount } = series; const { bookCount, booksReadCount } = series;
const booksInProgressCount = booksCount - (booksReadCount + booksUnreadCount); const booksUnreadCount = bookCount - booksReadCount;
const booksInProgressCount = bookCount - (booksReadCount + booksUnreadCount);
if (booksReadCount === booksCount) { if (booksReadCount === bookCount) {
return { return {
label: t("series.header.status.read"), label: t("series.header.status.read"),
status: "success" as const, status: "success" as const,
@@ -80,11 +84,11 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
}; };
} }
if (booksInProgressCount > 0 || (booksReadCount > 0 && booksReadCount < booksCount)) { if (booksInProgressCount > 0 || (booksReadCount > 0 && booksReadCount < bookCount)) {
return { return {
label: t("series.header.status.progress", { label: t("series.header.status.progress", {
read: booksReadCount, read: booksReadCount,
total: booksCount, total: bookCount,
}), }),
status: "reading" as const, status: "reading" as const,
icon: BookOpen, icon: BookOpen,
@@ -98,15 +102,18 @@ 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
series={series as KomgaSeries} series={series}
alt={t("series.header.coverAlt", { title: series.metadata.title })} alt={t("series.header.coverAlt", { title: series.name })}
className="blur-sm scale-105 brightness-50" className="blur-sm scale-105 brightness-50"
showProgressUi={false} showProgressUi={false}
/> />
@@ -118,29 +125,50 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
{/* Image principale */} {/* Image principale */}
<div className="relative w-[180px] aspect-[2/3] rounded-lg overflow-hidden shadow-lg bg-muted/80 backdrop-blur-md flex-shrink-0"> <div className="relative w-[180px] aspect-[2/3] rounded-lg overflow-hidden shadow-lg bg-muted/80 backdrop-blur-md flex-shrink-0">
<SeriesCover <SeriesCover
series={series as KomgaSeries} series={series}
alt={t("series.header.coverAlt", { title: series.metadata.title })} alt={t("series.header.coverAlt", { title: series.name })}
showProgressUi={false} showProgressUi={false}
/> />
</div> </div>
{/* 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.metadata.title}</h1> <h1 className="text-2xl md:text-3xl font-bold">{series.name}</h1>
{series.metadata.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.metadata.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.booksCount === 1 {series.bookCount === 1
? t("series.header.books", { count: series.booksCount }) ? t("series.header.books", { count: series.bookCount })
: t("series.header.books_plural", { count: series.booksCount })} : 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"
@@ -157,6 +185,7 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
}; };

View File

@@ -14,11 +14,11 @@ import { Check } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { SliderControl } from "@/components/ui/slider-control"; import { SliderControl } from "@/components/ui/slider-control";
import type { KomgaLibrary } from "@/types/komga"; import type { NormalizedLibrary } from "@/lib/providers/types";
import logger from "@/lib/logger"; import logger from "@/lib/logger";
interface BackgroundSettingsProps { interface BackgroundSettingsProps {
initialLibraries: KomgaLibrary[]; initialLibraries: NormalizedLibrary[];
} }
export function BackgroundSettings({ initialLibraries }: BackgroundSettingsProps) { export function BackgroundSettings({ initialLibraries }: BackgroundSettingsProps) {
@@ -27,7 +27,7 @@ export function BackgroundSettings({ initialLibraries }: BackgroundSettingsProps
const { preferences, updatePreferences } = usePreferences(); const { preferences, updatePreferences } = usePreferences();
const [customImageUrl, setCustomImageUrl] = useState(preferences.background.imageUrl || ""); const [customImageUrl, setCustomImageUrl] = useState(preferences.background.imageUrl || "");
const [komgaConfigValid, setKomgaConfigValid] = useState(false); const [komgaConfigValid, setKomgaConfigValid] = useState(false);
const [libraries, setLibraries] = useState<KomgaLibrary[]>(initialLibraries || []); const [libraries, setLibraries] = useState<NormalizedLibrary[]>(initialLibraries || []);
const [selectedLibraries, setSelectedLibraries] = useState<string[]>( const [selectedLibraries, setSelectedLibraries] = useState<string[]>(
preferences.background.komgaLibraries || [] preferences.background.komgaLibraries || []
); );
@@ -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.booksCount} livres) {library.name}
</Label> </Label>
</div> </div>
))} ))}

Some files were not shown because too many files have changed in this diff Show More