Compare commits
31 Commits
818fe67c99
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| acb12b946e | |||
| d9ffacc124 | |||
| 8cdbebaafb | |||
| c5da33d6b2 | |||
| a82ce024ee | |||
| f48d894eca | |||
| a1a986f462 | |||
| 894ea7114c | |||
| 32757a8723 | |||
| 11da2335cd | |||
| feceb61e30 | |||
| 701a02b55c | |||
| b2664cce08 | |||
| ff44a781c8 | |||
| d535f9f28e | |||
| 2174579cc1 | |||
| e6eab32473 | |||
| 86b7382a04 | |||
| 53af9db046 | |||
| 1d03cfc177 | |||
| b0d56948a3 | |||
| fc9c220be6 | |||
| 100d8b37e7 | |||
| f9651676a5 | |||
| 539bb34716 | |||
| 8d1f91d636 | |||
| 7e4c48469a | |||
| e74b02e3a2 | |||
| 7d0f1c4457 | |||
| a1a95775db | |||
| 3d7ac0c13e |
@@ -5,4 +5,8 @@ MONGODB_URI=mongodb://admin:password@host.docker.internal:27017/stripstream?auth
|
|||||||
|
|
||||||
NEXTAUTH_SECRET=SECRET
|
NEXTAUTH_SECRET=SECRET
|
||||||
#openssl rand -base64 32
|
#openssl rand -base64 32
|
||||||
NEXTAUTH_URL=http://localhost:3000
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Stripstream Librarian (optionnel : fallback si l'utilisateur n'a pas sauvegardé d'URL/token en base)
|
||||||
|
# STRIPSTREAM_URL=https://librarian.example.com
|
||||||
|
# STRIPSTREAM_TOKEN=stl_xxxx_xxxxxxxx
|
||||||
@@ -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
|
||||||
|
|||||||
23
Dockerfile
@@ -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
|
||||||
|
|||||||
68
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 ==="
|
||||||
@@ -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,
|
||||||
|
|||||||
77
prisma/migrations/20260311203728_init/migration.sql
Normal 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");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "preferences" ADD COLUMN "anonymousMode" BOOLEAN NOT NULL DEFAULT false;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal 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"
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/images/Gemini_Generated_Image_wyfsoiwyfsoiwyfs.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 3.7 MiB After Width: | Height: | Size: 4.3 MiB |
|
Before Width: | Height: | Size: 3.7 MiB After Width: | Height: | Size: 4.4 MiB |
BIN
public/images/splash/splash-1206x2622.png
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 4.0 MiB After Width: | Height: | Size: 4.7 MiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 4.9 MiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 5.0 MiB |
BIN
public/images/splash/splash-1320x2868.png
Normal file
|
After Width: | Height: | Size: 5.1 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.7 MiB |
BIN
public/images/splash/splash-1488x2266.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
|
Before Width: | Height: | Size: 3.8 MiB After Width: | Height: | Size: 4.5 MiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 4.8 MiB |
|
Before Width: | Height: | Size: 4.4 MiB After Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 4.5 MiB After Width: | Height: | Size: 5.3 MiB |
BIN
public/images/splash/splash-1668x2420.png
Normal file
|
After Width: | Height: | Size: 5.3 MiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 3.9 MiB After Width: | Height: | Size: 4.5 MiB |
|
Before Width: | Height: | Size: 5.8 MiB After Width: | Height: | Size: 6.8 MiB |
BIN
public/images/splash/splash-2064x2752.png
Normal file
|
After Width: | Height: | Size: 6.9 MiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 4.8 MiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 4.0 MiB |
BIN
public/images/splash/splash-2266x1488.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
|
Before Width: | Height: | Size: 4.5 MiB After Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 4.6 MiB After Width: | Height: | Size: 5.3 MiB |
BIN
public/images/splash/splash-2420x1668.png
Normal file
|
After Width: | Height: | Size: 5.3 MiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 4.2 MiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 4.2 MiB |
BIN
public/images/splash/splash-2622x1206.png
Normal file
|
After Width: | Height: | Size: 4.4 MiB |
|
Before Width: | Height: | Size: 3.6 MiB After Width: | Height: | Size: 4.6 MiB |
|
Before Width: | Height: | Size: 5.8 MiB After Width: | Height: | Size: 6.7 MiB |
BIN
public/images/splash/splash-2752x2064.png
Normal file
|
After Width: | Height: | Size: 6.8 MiB |
|
Before Width: | Height: | Size: 3.8 MiB After Width: | Height: | Size: 4.8 MiB |
|
Before Width: | Height: | Size: 3.8 MiB After Width: | Height: | Size: 4.9 MiB |
BIN
public/images/splash/splash-2868x1320.png
Normal file
|
After Width: | Height: | Size: 5.0 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.5 MiB |
@@ -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})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
|||||||
@@ -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" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
181
src/app/actions/stripstream-config.ts
Normal 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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/api/provider/search/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/api/stripstream/search/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,10 +61,16 @@ 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">
|
||||||
<ErrorMessage errorCode={errorCode} />
|
<ErrorMessage errorCode={errorCode} />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
208
src/components/layout/GlobalSearch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
// Wait for image to load before resolving promise
|
||||||
}));
|
await new Promise<void>((resolve, reject) => {
|
||||||
};
|
img.onload = () => {
|
||||||
|
if (!isMountedRef.current || controller.signal.aborted) {
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
reject(new Error("Aborted"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
img.onerror = () => {
|
setLoadedImages((prev) => ({
|
||||||
URL.revokeObjectURL(blobUrl);
|
...prev,
|
||||||
};
|
[pageNum]: { width: img.naturalWidth, height: img.naturalHeight },
|
||||||
|
}));
|
||||||
|
|
||||||
img.src = blobUrl;
|
// Store the blob URL for immediate use
|
||||||
} catch {
|
setImageBlobUrls((prev) => ({
|
||||||
// Silently fail prefetch
|
...prev,
|
||||||
} finally {
|
[pageNum]: blobUrl,
|
||||||
// Remove from pending set
|
}));
|
||||||
pendingFetchesRef.current.delete(pageNum);
|
|
||||||
abortControllersRef.current.delete(pageNum);
|
resolve();
|
||||||
}
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
reject(new Error("Image load error"));
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = blobUrl;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Silently fail prefetch
|
||||||
|
} finally {
|
||||||
|
// Remove from pending set and promise map
|
||||||
|
pendingFetchesRef.current.delete(pageNum);
|
||||||
|
abortControllersRef.current.delete(pageNum);
|
||||||
|
loadingPromisesRef.current.delete(pageNum);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Store promise so other calls can await it
|
||||||
|
loadingPromisesRef.current.set(pageNum, promise);
|
||||||
|
|
||||||
|
return promise;
|
||||||
},
|
},
|
||||||
[getPageUrl]
|
[getPageUrl]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Prefetch multiple pages starting from a given page
|
// Prefetch multiple pages starting from a given page
|
||||||
const prefetchPages = useCallback(
|
const prefetchPages = useCallback(
|
||||||
async (startPage: number, count: number = prefetchCount) => {
|
async (
|
||||||
|
startPage: number,
|
||||||
|
count: number = prefetchCount,
|
||||||
|
excludePages: number[] = [],
|
||||||
|
concurrency?: number
|
||||||
|
) => {
|
||||||
const pagesToPrefetch = [];
|
const pagesToPrefetch = [];
|
||||||
|
const excludeSet = new Set(excludePages);
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
const pageNum = startPage + i;
|
const pageNum = startPage + i;
|
||||||
if (pageNum <= _pages.length) {
|
if (pageNum <= _pages.length && !excludeSet.has(pageNum)) {
|
||||||
const hasDimensions = loadedImagesRef.current[pageNum];
|
const hasDimensions = loadedImagesRef.current[pageNum];
|
||||||
const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
|
const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
|
||||||
const isPending = pendingFetchesRef.current.has(pageNum);
|
const isPending = pendingFetchesRef.current.has(pageNum);
|
||||||
@@ -170,10 +198,13 @@ export function useImageLoader({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use provided concurrency or default
|
||||||
|
const effectiveConcurrency = concurrency ?? PREFETCH_CONCURRENCY;
|
||||||
|
|
||||||
// Let all prefetch requests run - the server queue will manage concurrency
|
// Let all prefetch requests run - the server queue will manage concurrency
|
||||||
// The browser cache and our deduplication prevent redundant requests
|
// The browser cache and our deduplication prevent redundant requests
|
||||||
if (pagesToPrefetch.length > 0) {
|
if (pagesToPrefetch.length > 0) {
|
||||||
runWithConcurrency(pagesToPrefetch, prefetchImage).catch(() => {
|
runWithConcurrency(pagesToPrefetch, prefetchImage, effectiveConcurrency).catch(() => {
|
||||||
// Silently fail - prefetch is non-critical
|
// Silently fail - prefetch is non-critical
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||