Compare commits
26 Commits
e74b02e3a2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -5,4 +5,8 @@ MONGODB_URI=mongodb://admin:password@host.docker.internal:27017/stripstream?auth
|
||||
|
||||
NEXTAUTH_SECRET=SECRET
|
||||
#openssl rand -base64 32
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
|
||||
# Stripstream Librarian (optionnel : fallback si l'utilisateur n'a pas sauvegardé d'URL/token en base)
|
||||
# STRIPSTREAM_URL=https://librarian.example.com
|
||||
# STRIPSTREAM_TOKEN=stl_xxxx_xxxxxxxx
|
||||
@@ -1,26 +1,33 @@
|
||||
name: Deploy with Docker Compose
|
||||
name: Build, Push & Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main # adapte la branche que tu veux déployer
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: mac-orbstack-runner # le nom que tu as donné au runner
|
||||
runs-on: mac-orbstack-runner
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy stack
|
||||
- name: Login to DockerHub
|
||||
run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Build Docker image
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
COMPOSE_DOCKER_CLI_BUILD: 1
|
||||
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL }}
|
||||
ADMIN_DEFAULT_PASSWORD: ${{ secrets.ADMIN_DEFAULT_PASSWORD }}
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
PRISMA_DATA_PATH: ${{ vars.PRISMA_DATA_PATH }}
|
||||
NODE_ENV: production
|
||||
run: docker build -t julienfroidefond32/stripstream:latest .
|
||||
|
||||
- name: Push to DockerHub
|
||||
run: docker push julienfroidefond32/stripstream:latest
|
||||
|
||||
- name: Pull new image and restart container
|
||||
run: |
|
||||
docker compose up -d --build
|
||||
docker pull julienfroidefond32/stripstream:latest
|
||||
cd /Users/julienfroidefond/Sites/docker-stack
|
||||
./scripts/stack.sh up stripstream
|
||||
|
||||
- name: Cleanup old images
|
||||
run: docker image prune -f
|
||||
|
||||
23
Dockerfile
@@ -17,7 +17,7 @@ COPY package.json pnpm-lock.yaml ./
|
||||
COPY prisma ./prisma
|
||||
|
||||
# Copy configuration files
|
||||
COPY tsconfig.json .eslintrc.json ./
|
||||
COPY tsconfig.json .eslintrc.json next.config.js ./
|
||||
COPY tailwind.config.ts postcss.config.js ./
|
||||
|
||||
# Install dependencies with pnpm using cache mount for store
|
||||
@@ -43,22 +43,20 @@ WORKDIR /app
|
||||
# Install OpenSSL (required by Prisma)
|
||||
RUN apk add --no-cache openssl libc6-compat
|
||||
|
||||
# Copy package files and prisma schema
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY prisma ./prisma
|
||||
# Copy standalone output (server.js + minimal node_modules)
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
|
||||
# Enable pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
|
||||
# Copy static assets and public directory
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Copy the entire node_modules from builder (includes Prisma Client)
|
||||
# Copy full node_modules for Prisma CLI (pnpm symlinks prevent cherry-picking)
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/next-env.d.ts ./
|
||||
COPY --from=builder /app/tailwind.config.ts ./
|
||||
# Copy prisma schema and init scripts
|
||||
COPY prisma ./prisma
|
||||
COPY --from=builder /app/scripts ./scripts
|
||||
COPY package.json ./
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY docker-entrypoint.sh ./
|
||||
@@ -76,6 +74,7 @@ USER nextjs
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 3000
|
||||
|
||||
33
README.md
@@ -74,7 +74,7 @@ A modern web application for reading digital comics, built with Next.js 14 and t
|
||||
## 🛠 Prerequisites
|
||||
|
||||
- Node.js 20.x or higher
|
||||
- Yarn 1.22.x or higher
|
||||
- pnpm 9.x or higher
|
||||
- Docker and Docker Compose (optional)
|
||||
|
||||
## 📦 Installation
|
||||
@@ -91,7 +91,7 @@ cd stripstream
|
||||
2. Install dependencies
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. Copy the example environment file and adjust it to your needs
|
||||
@@ -103,7 +103,7 @@ cp .env.example .env.local
|
||||
4. Start the development server
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### With Docker (Build Local)
|
||||
@@ -121,7 +121,7 @@ cd stripstream
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
The application will be accessible at `http://localhost:3000`
|
||||
The application will be accessible at `http://localhost:3020`
|
||||
|
||||
### With Docker (DockerHub Image)
|
||||
|
||||
@@ -130,18 +130,24 @@ You can also use the pre-built image from DockerHub without cloning the reposito
|
||||
1. Create a `docker-compose.yml` file:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
image: julienfroidefond32/stripstream:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
# Add your environment variables here or use an .env file
|
||||
# Required
|
||||
- NEXTAUTH_SECRET=your_secret_here # openssl rand -base64 32
|
||||
- NEXTAUTH_URL=http://localhost:3000
|
||||
|
||||
# Optional — defaults shown
|
||||
# - NODE_ENV=production
|
||||
# - DATABASE_URL=file:/app/prisma/data/stripstream.db
|
||||
# - ADMIN_DEFAULT_PASSWORD=Admin@2025
|
||||
# - AUTH_TRUST_HOST=true
|
||||
# - KOMGA_MAX_CONCURRENT_REQUESTS=5
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./data:/app/prisma/data
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
@@ -155,11 +161,10 @@ The application will be accessible at `http://localhost:3000`
|
||||
|
||||
## 🔧 Available Scripts
|
||||
|
||||
- `yarn dev` - Starts the development server
|
||||
- `yarn build` - Creates a production build
|
||||
- `yarn start` - Runs the production version
|
||||
- `yarn lint` - Checks code with ESLint
|
||||
- `yarn format` - Formats code with Prettier
|
||||
- `pnpm dev` - Starts the development server
|
||||
- `pnpm build` - Creates a production build
|
||||
- `pnpm start` - Runs the production version
|
||||
- `pnpm lint` - Checks code with ESLint
|
||||
- `./docker-push.sh [tag]` - Build and push Docker image to DockerHub (default tag: `latest`)
|
||||
|
||||
### Docker Push Script
|
||||
|
||||
@@ -2,6 +2,7 @@ version: "3.8"
|
||||
|
||||
services:
|
||||
stripstream-app:
|
||||
image: julienfroidefond32/stripstream:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "📁 Ensuring data directory exists..."
|
||||
mkdir -p /app/data
|
||||
|
||||
echo "🔄 Pushing Prisma schema to database..."
|
||||
npx prisma db push --skip-generate --accept-data-loss
|
||||
echo "🔄 Applying database migrations..."
|
||||
./node_modules/.bin/prisma migrate deploy
|
||||
|
||||
echo "🔧 Initializing database..."
|
||||
node scripts/init-db.mjs
|
||||
|
||||
echo "🚀 Starting application..."
|
||||
exec pnpm start
|
||||
|
||||
exec node server.js
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
webpack: (config) => {
|
||||
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");
|
||||
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"
|
||||
@@ -64,6 +64,7 @@ model Preferences {
|
||||
displayMode Json
|
||||
background Json
|
||||
readerPrefetchCount Int @default(5)
|
||||
anonymousMode Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
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 faviconPath = path.join(__dirname, "../public/favicon.png");
|
||||
|
||||
// Source pour les splash screens
|
||||
const splashSource = path.join(__dirname, "../public/images/Gemini_Generated_Image_wyfsoiwyfsoiwyfs.png");
|
||||
|
||||
// Configuration des splashscreens pour différents appareils
|
||||
const splashScreens = [
|
||||
// iPad (portrait + landscape)
|
||||
@@ -16,8 +19,14 @@ const splashScreens = [
|
||||
{ width: 2732, height: 2048, name: "iPad Pro 12.9 landscape" },
|
||||
{ width: 1668, height: 2388, name: "iPad Pro 11 portrait" },
|
||||
{ width: 2388, height: 1668, name: "iPad Pro 11 landscape" },
|
||||
{ width: 1668, height: 2420, name: "iPad Pro 11 M4 portrait" },
|
||||
{ width: 2420, height: 1668, name: "iPad Pro 11 M4 landscape" },
|
||||
{ width: 2064, height: 2752, name: "iPad Pro 13 M4 portrait" },
|
||||
{ width: 2752, height: 2064, name: "iPad Pro 13 M4 landscape" },
|
||||
{ width: 1536, height: 2048, name: "iPad Mini/Air portrait" },
|
||||
{ width: 2048, height: 1536, name: "iPad Mini/Air landscape" },
|
||||
{ width: 1488, height: 2266, name: "iPad Mini 6 portrait" },
|
||||
{ width: 2266, height: 1488, name: "iPad Mini 6 landscape" },
|
||||
{ width: 1620, height: 2160, name: "iPad 10.2 portrait" },
|
||||
{ width: 2160, height: 1620, name: "iPad 10.2 landscape" },
|
||||
{ width: 1640, height: 2360, name: "iPad Air 10.9 portrait" },
|
||||
@@ -40,39 +49,36 @@ const splashScreens = [
|
||||
{ width: 2532, height: 1170, name: "iPhone 12/13/14 landscape" },
|
||||
{ width: 1284, height: 2778, name: "iPhone 12/13/14 Pro Max portrait" },
|
||||
{ width: 2778, height: 1284, name: "iPhone 12/13/14 Pro Max landscape" },
|
||||
{ width: 1179, height: 2556, name: "iPhone 14 Pro portrait" },
|
||||
{ width: 2556, height: 1179, name: "iPhone 14 Pro landscape" },
|
||||
{ width: 1179, height: 2556, name: "iPhone 14 Pro/15 portrait" },
|
||||
{ width: 2556, height: 1179, name: "iPhone 14 Pro/15 landscape" },
|
||||
{ width: 1290, height: 2796, name: "iPhone 14/15 Pro Max portrait" },
|
||||
{ width: 2796, height: 1290, name: "iPhone 14/15 Pro Max landscape" },
|
||||
{ width: 1179, height: 2556, name: "iPhone 15 portrait" },
|
||||
{ width: 2556, height: 1179, name: "iPhone 15 landscape" },
|
||||
{ width: 1206, height: 2622, name: "iPhone 16 Pro portrait" },
|
||||
{ width: 2622, height: 1206, name: "iPhone 16 Pro landscape" },
|
||||
{ width: 1320, height: 2868, name: "iPhone 16 Pro Max portrait" },
|
||||
{ width: 2868, height: 1320, name: "iPhone 16 Pro Max landscape" },
|
||||
{ width: 1170, height: 2532, name: "iPhone 16/16e portrait" },
|
||||
{ width: 2532, height: 1170, name: "iPhone 16/16e landscape" },
|
||||
];
|
||||
|
||||
async function generateSplashScreens() {
|
||||
await fs.mkdir(splashDir, { recursive: true });
|
||||
console.log(`\n📱 Génération des splash screens...`);
|
||||
|
||||
for (const screen of splashScreens) {
|
||||
const outputPath = path.join(splashDir, `splash-${screen.width}x${screen.height}.png`);
|
||||
const darkOverlay = Buffer.from(
|
||||
`<svg width="${screen.width}" height="${screen.height}" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100%" height="100%" fill="rgba(4, 8, 20, 0.22)" />
|
||||
</svg>`
|
||||
);
|
||||
|
||||
await sharp(sourceLogo)
|
||||
await sharp(splashSource)
|
||||
.resize(screen.width, screen.height, {
|
||||
fit: "cover",
|
||||
position: "center",
|
||||
})
|
||||
.composite([{ input: darkOverlay, blend: "over" }])
|
||||
.png({
|
||||
compressionLevel: 9,
|
||||
})
|
||||
.toFile(outputPath);
|
||||
|
||||
console.log(`✓ Splashscreen ${screen.name} (${screen.width}x${screen.height}) générée`);
|
||||
console.log(` ✓ ${screen.name} (${screen.width}x${screen.height})`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function testKomgaConnection(
|
||||
message: `Connexion réussie ! ${libraries.length} bibliothèque${libraries.length > 1 ? "s" : ""} trouvée${libraries.length > 1 ? "s" : ""}`,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
if (error instanceof AppError) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
return { success: false, message: "Erreur lors de la connexion" };
|
||||
@@ -59,7 +59,7 @@ export async function saveKomgaConfig(
|
||||
revalidatePath("/settings");
|
||||
return { success: true, message: "Configuration sauvegardée", data: mongoConfig };
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
if (error instanceof AppError) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
return { success: false, message: "Erreur lors de la sauvegarde" };
|
||||
|
||||
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentUser } from "@/lib/auth-utils";
|
||||
import { StripstreamProvider } from "@/lib/providers/stripstream/stripstream.provider";
|
||||
import { getResolvedStripstreamConfig } from "@/lib/providers/stripstream/stripstream-config-resolver";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import type { ProviderType } from "@/lib/providers/types";
|
||||
@@ -81,8 +82,8 @@ export async function setActiveProvider(
|
||||
if (!config) {
|
||||
return { success: false, message: "Komga n'est pas encore configuré" };
|
||||
}
|
||||
} else if (provider === "stripstream") {
|
||||
const config = await prisma.stripstreamConfig.findUnique({ where: { userId } });
|
||||
} else if (provider === "stripstream") {
|
||||
const config = await getResolvedStripstreamConfig(userId);
|
||||
if (!config) {
|
||||
return { success: false, message: "Stripstream n'est pas encore configuré" };
|
||||
}
|
||||
@@ -108,7 +109,8 @@ export async function setActiveProvider(
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la configuration Stripstream de l'utilisateur
|
||||
* Récupère la configuration Stripstream de l'utilisateur (affichage settings).
|
||||
* Priorité : config en base, sinon env STRIPSTREAM_URL / STRIPSTREAM_TOKEN.
|
||||
*/
|
||||
export async function getStripstreamConfig(): Promise<{
|
||||
url?: string;
|
||||
@@ -119,13 +121,9 @@ export async function getStripstreamConfig(): Promise<{
|
||||
if (!user) return null;
|
||||
const userId = parseInt(user.id, 10);
|
||||
|
||||
const config = await prisma.stripstreamConfig.findUnique({
|
||||
where: { userId },
|
||||
select: { url: true },
|
||||
});
|
||||
|
||||
if (!config) return null;
|
||||
return { url: config.url, hasToken: true };
|
||||
const resolved = await getResolvedStripstreamConfig(userId);
|
||||
if (!resolved) return null;
|
||||
return { url: resolved.url, hasToken: true };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -166,15 +164,15 @@ export async function getProvidersStatus(): Promise<{
|
||||
}
|
||||
const userId = parseInt(user.id, 10);
|
||||
|
||||
const [dbUser, komgaConfig, stripstreamConfig] = await Promise.all([
|
||||
const [dbUser, komgaConfig, stripstreamResolved] = await Promise.all([
|
||||
prisma.user.findUnique({ where: { id: userId }, select: { activeProvider: true } }),
|
||||
prisma.komgaConfig.findUnique({ where: { userId }, select: { id: true } }),
|
||||
prisma.stripstreamConfig.findUnique({ where: { userId }, select: { id: true } }),
|
||||
getResolvedStripstreamConfig(userId),
|
||||
]);
|
||||
|
||||
return {
|
||||
komgaConfigured: !!komgaConfig,
|
||||
stripstreamConfigured: !!stripstreamConfig,
|
||||
stripstreamConfigured: !!stripstreamResolved,
|
||||
activeProvider: (dbUser?.activeProvider as ProviderType) ?? "komga",
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth-utils";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getResolvedStripstreamConfig } from "@/lib/providers/stripstream/stripstream-config-resolver";
|
||||
import { StripstreamClient } from "@/lib/providers/stripstream/stripstream.client";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import { AppError } from "@/utils/errors";
|
||||
@@ -23,7 +23,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
const userId = parseInt(user.id, 10);
|
||||
const config = await prisma.stripstreamConfig.findUnique({ where: { userId } });
|
||||
const config = await getResolvedStripstreamConfig(userId);
|
||||
if (!config) {
|
||||
throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG);
|
||||
}
|
||||
@@ -35,14 +35,15 @@ export async function GET(
|
||||
const response = await client.fetchImage(path);
|
||||
|
||||
const contentType = response.headers.get("content-type") ?? "image/jpeg";
|
||||
const buffer = await response.arrayBuffer();
|
||||
const contentLength = response.headers.get("content-length");
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
});
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
};
|
||||
if (contentLength) headers["Content-Length"] = contentLength;
|
||||
|
||||
return new NextResponse(response.body, { headers });
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Stripstream page fetch error");
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth-utils";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getResolvedStripstreamConfig } from "@/lib/providers/stripstream/stripstream-config-resolver";
|
||||
import { StripstreamClient } from "@/lib/providers/stripstream/stripstream.client";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
@@ -20,7 +20,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
const userId = parseInt(user.id, 10);
|
||||
const config = await prisma.stripstreamConfig.findUnique({ where: { userId } });
|
||||
const config = await getResolvedStripstreamConfig(userId);
|
||||
if (!config) {
|
||||
throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { cn } from "@/lib/utils";
|
||||
import ClientLayout from "@/components/layout/ClientLayout";
|
||||
import { PreferencesService } from "@/lib/services/preferences.service";
|
||||
import { PreferencesProvider } from "@/contexts/PreferencesContext";
|
||||
import { AnonymousProvider } from "@/contexts/AnonymousContext";
|
||||
import { I18nProvider } from "@/components/providers/I18nProvider";
|
||||
import { AuthProvider } from "@/components/providers/AuthProvider";
|
||||
import { cookies, headers } from "next/headers";
|
||||
@@ -248,6 +249,61 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
href="/images/splash/splash-2796x1290.png"
|
||||
media="(device-width: 932px) and (device-height: 430px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
/>
|
||||
{/* iPad Mini 6 */}
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/images/splash/splash-1488x2266.png"
|
||||
media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/images/splash/splash-2266x1488.png"
|
||||
media="(device-width: 1133px) and (device-height: 744px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
{/* iPad Pro 11" M4 */}
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/images/splash/splash-1668x2420.png"
|
||||
media="(device-width: 834px) and (device-height: 1210px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/images/splash/splash-2420x1668.png"
|
||||
media="(device-width: 1210px) and (device-height: 834px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
{/* iPad Pro 13" M4 */}
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/images/splash/splash-2064x2752.png"
|
||||
media="(device-width: 1032px) and (device-height: 1376px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/images/splash/splash-2752x2064.png"
|
||||
media="(device-width: 1376px) and (device-height: 1032px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||
/>
|
||||
{/* iPhone 16 Pro */}
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/images/splash/splash-1206x2622.png"
|
||||
media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/images/splash/splash-2622x1206.png"
|
||||
media="(device-width: 874px) and (device-height: 402px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
/>
|
||||
{/* iPhone 16 Pro Max */}
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/images/splash/splash-1320x2868.png"
|
||||
media="(device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-startup-image"
|
||||
href="/images/splash/splash-2868x1320.png"
|
||||
media="(device-width: 956px) and (device-height: 440px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={cn(
|
||||
@@ -258,13 +314,15 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<AuthProvider>
|
||||
<I18nProvider locale={locale}>
|
||||
<PreferencesProvider initialPreferences={preferences}>
|
||||
<ClientLayout
|
||||
initialLibraries={libraries}
|
||||
initialFavorites={favorites}
|
||||
userIsAdmin={userIsAdmin}
|
||||
>
|
||||
{children}
|
||||
</ClientLayout>
|
||||
<AnonymousProvider>
|
||||
<ClientLayout
|
||||
initialLibraries={libraries}
|
||||
initialFavorites={favorites}
|
||||
userIsAdmin={userIsAdmin}
|
||||
>
|
||||
{children}
|
||||
</ClientLayout>
|
||||
</AnonymousProvider>
|
||||
</PreferencesProvider>
|
||||
</I18nProvider>
|
||||
</AuthProvider>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { HomeClientWrapper } from "@/components/home/HomeClientWrapper";
|
||||
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import { FavoritesService } from "@/lib/services/favorites.service";
|
||||
import { PreferencesService } from "@/lib/services/preferences.service";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function HomePage() {
|
||||
@@ -11,11 +13,17 @@ export default async function HomePage() {
|
||||
const provider = await getProvider();
|
||||
if (!provider) redirect("/settings");
|
||||
|
||||
const data = await provider.getHomeData();
|
||||
const [homeData, favorites, preferences] = await Promise.all([
|
||||
provider.getHomeData(),
|
||||
FavoritesService.getFavorites(),
|
||||
PreferencesService.getPreferences().catch(() => null),
|
||||
]);
|
||||
|
||||
const data = { ...homeData, favorites };
|
||||
|
||||
return (
|
||||
<HomeClientWrapper>
|
||||
<HomeContent data={data} />
|
||||
<HomeContent data={data} isAnonymous={preferences?.anonymousMode ?? false} />
|
||||
</HomeClientWrapper>
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
@@ -16,7 +15,6 @@ interface LoginFormProps {
|
||||
}
|
||||
|
||||
export function LoginForm({ from }: LoginFormProps) {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<AppErrorType | null>(null);
|
||||
const { t } = useTranslate();
|
||||
@@ -57,8 +55,7 @@ export function LoginForm({ from }: LoginFormProps) {
|
||||
}
|
||||
|
||||
const redirectPath = getSafeRedirectPath(from);
|
||||
window.location.assign(redirectPath);
|
||||
router.refresh();
|
||||
window.location.href = redirectPath;
|
||||
} catch {
|
||||
setError({
|
||||
code: "AUTH_FETCH_ERROR",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
@@ -16,7 +15,6 @@ interface RegisterFormProps {
|
||||
}
|
||||
|
||||
export function RegisterForm({ from }: RegisterFormProps) {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<AppErrorType | null>(null);
|
||||
const { t } = useTranslate();
|
||||
@@ -77,8 +75,7 @@ export function RegisterForm({ from }: RegisterFormProps) {
|
||||
});
|
||||
} else {
|
||||
const redirectPath = getSafeRedirectPath(from);
|
||||
window.location.assign(redirectPath);
|
||||
router.refresh();
|
||||
window.location.href = redirectPath;
|
||||
}
|
||||
} catch {
|
||||
setError({
|
||||
|
||||
@@ -3,21 +3,34 @@ import type { HomeData } from "@/types/home";
|
||||
|
||||
interface HomeContentProps {
|
||||
data: HomeData;
|
||||
isAnonymous?: boolean;
|
||||
}
|
||||
|
||||
export function HomeContent({ data }: HomeContentProps) {
|
||||
export function HomeContent({ data, isAnonymous = false }: HomeContentProps) {
|
||||
// Merge onDeck (next unread per series) and ongoingBooks (currently reading),
|
||||
// deduplicate by id, onDeck first
|
||||
const continueReading = (() => {
|
||||
const items = [...(data.onDeck ?? []), ...(data.ongoingBooks ?? [])];
|
||||
const seen = new Set<string>();
|
||||
return items.filter((item) => {
|
||||
if (seen.has(item.id)) return false;
|
||||
seen.add(item.id);
|
||||
return true;
|
||||
});
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="space-y-10 pb-2">
|
||||
{data.ongoingBooks && data.ongoingBooks.length > 0 && (
|
||||
{!isAnonymous && continueReading.length > 0 && (
|
||||
<MediaRow
|
||||
titleKey="home.sections.continue_reading"
|
||||
items={data.ongoingBooks}
|
||||
items={continueReading}
|
||||
iconName="BookOpen"
|
||||
featuredHeader
|
||||
/>
|
||||
)}
|
||||
|
||||
{data.ongoing && data.ongoing.length > 0 && (
|
||||
{!isAnonymous && data.ongoing && data.ongoing.length > 0 && (
|
||||
<MediaRow
|
||||
titleKey="home.sections.continue_series"
|
||||
items={data.ongoing}
|
||||
@@ -25,11 +38,11 @@ export function HomeContent({ data }: HomeContentProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{data.onDeck && data.onDeck.length > 0 && (
|
||||
{data.favorites && data.favorites.length > 0 && (
|
||||
<MediaRow
|
||||
titleKey="home.sections.up_next"
|
||||
items={data.onDeck}
|
||||
iconName="Clock"
|
||||
titleKey="home.sections.favorites"
|
||||
items={data.favorites}
|
||||
iconName="Heart"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -7,10 +7,11 @@ import { SeriesCover } from "../ui/series-cover";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
import { ScrollContainer } from "@/components/ui/scroll-container";
|
||||
import { Section } from "@/components/ui/section";
|
||||
import { History, Sparkles, Clock, LibraryBig, BookOpen } from "lucide-react";
|
||||
import { History, Sparkles, Clock, LibraryBig, BookOpen, Heart } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAnonymous } from "@/contexts/AnonymousContext";
|
||||
|
||||
interface MediaRowProps {
|
||||
titleKey: string;
|
||||
@@ -25,6 +26,7 @@ const iconMap = {
|
||||
Clock,
|
||||
Sparkles,
|
||||
History,
|
||||
Heart,
|
||||
};
|
||||
|
||||
function isSeries(item: NormalizedSeries | NormalizedBook): item is NormalizedSeries {
|
||||
@@ -77,6 +79,7 @@ interface MediaCardProps {
|
||||
|
||||
function MediaCard({ item, onClick }: MediaCardProps) {
|
||||
const { t } = useTranslate();
|
||||
const { isAnonymous } = useAnonymous();
|
||||
const isSeriesItem = isSeries(item);
|
||||
const { isAccessible } = useBookOfflineStatus(isSeriesItem ? "" : item.id);
|
||||
|
||||
@@ -104,7 +107,7 @@ function MediaCard({ item, onClick }: MediaCardProps) {
|
||||
<div className="relative aspect-[2/3] bg-muted">
|
||||
{isSeriesItem ? (
|
||||
<>
|
||||
<SeriesCover series={item} alt={`Couverture de ${title}`} />
|
||||
<SeriesCover series={item} alt={`Couverture de ${title}`} isAnonymous={isAnonymous} />
|
||||
<div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/75 via-black/30 to-transparent p-3 opacity-0 transition-opacity duration-200 hover:opacity-100">
|
||||
<h3 className="font-medium text-sm text-white line-clamp-2">{title}</h3>
|
||||
<p className="text-xs text-white/80 mt-1">
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Menu, Moon, Sun, RefreshCw, Search } from "lucide-react";
|
||||
import { Menu, Moon, Sun, RefreshCw, Search, EyeOff, Eye } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import LanguageSelector from "@/components/LanguageSelector";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { useState } from "react";
|
||||
import { GlobalSearch } from "@/components/layout/GlobalSearch";
|
||||
import { useAnonymous } from "@/contexts/AnonymousContext";
|
||||
|
||||
interface HeaderProps {
|
||||
onToggleSidebar: () => void;
|
||||
@@ -19,6 +20,7 @@ export function Header({
|
||||
}: HeaderProps) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { isAnonymous, toggleAnonymous } = useAnonymous();
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
|
||||
|
||||
@@ -51,7 +53,7 @@ export function Header({
|
||||
<div className="mr-2 flex items-center md:mr-4">
|
||||
<a className="mr-2 flex items-center md:mr-6" href="/">
|
||||
<span className="inline-flex flex-col leading-none">
|
||||
<span className="bg-gradient-to-r from-primary via-cyan-500 to-fuchsia-500 bg-clip-text text-sm font-bold tracking-[0.06em] text-transparent sm:text-lg sm:tracking-[0.08em]">
|
||||
<span className="bg-gradient-to-r from-primary via-cyan-500 to-fuchsia-500 bg-clip-text text-base font-bold tracking-[0.06em] text-transparent sm:text-lg sm:tracking-[0.08em]">
|
||||
StripStream
|
||||
</span>
|
||||
<span className="mt-1 hidden text-[10px] font-medium uppercase tracking-[0.22em] text-foreground/70 sm:inline">
|
||||
@@ -87,6 +89,14 @@ export function Header({
|
||||
className="h-9 w-9 rounded-full sm:hidden"
|
||||
tooltip={t("header.search.placeholder")}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={toggleAnonymous}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
icon={isAnonymous ? EyeOff : Eye}
|
||||
className={`h-9 w-9 rounded-full ${isAnonymous ? "text-yellow-500 hover:text-yellow-400" : ""}`}
|
||||
tooltip={t(isAnonymous ? "header.anonymousModeOn" : "header.anonymousModeOff")}
|
||||
/>
|
||||
<LanguageSelector />
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SeriesCover } from "@/components/ui/series-cover";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
import { useAnonymous } from "@/contexts/AnonymousContext";
|
||||
|
||||
interface SeriesGridProps {
|
||||
series: NormalizedSeries[];
|
||||
@@ -49,6 +50,7 @@ const getReadingStatusInfo = (
|
||||
export function SeriesGrid({ series, isCompact = false }: SeriesGridProps) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslate();
|
||||
const { isAnonymous } = useAnonymous();
|
||||
|
||||
if (!series.length) {
|
||||
return (
|
||||
@@ -73,24 +75,27 @@ export function SeriesGrid({ series, isCompact = false }: SeriesGridProps) {
|
||||
onClick={() => router.push(`/series/${seriesItem.id}`)}
|
||||
className={cn(
|
||||
"group relative aspect-[2/3] overflow-hidden rounded-xl border border-border/60 bg-card/80 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md",
|
||||
seriesItem.bookCount === seriesItem.booksReadCount && "opacity-50",
|
||||
!isAnonymous && seriesItem.bookCount === seriesItem.booksReadCount && "opacity-50",
|
||||
isCompact && "aspect-[3/4]"
|
||||
)}
|
||||
>
|
||||
<SeriesCover
|
||||
series={seriesItem}
|
||||
alt={t("series.coverAlt", { title: seriesItem.name })}
|
||||
isAnonymous={isAnonymous}
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 translate-y-full space-y-2 bg-gradient-to-t from-black/75 via-black/25 to-transparent p-4 transition-transform duration-200 group-hover:translate-y-0">
|
||||
<h3 className="font-medium text-sm text-white line-clamp-2">{seriesItem.name}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
getReadingStatusInfo(seriesItem, t).className
|
||||
}`}
|
||||
>
|
||||
{getReadingStatusInfo(seriesItem, t).label}
|
||||
</span>
|
||||
{!isAnonymous && (
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
getReadingStatusInfo(seriesItem, t).className
|
||||
}`}
|
||||
>
|
||||
{getReadingStatusInfo(seriesItem, t).label}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-white/80">
|
||||
{t("series.books", { count: seriesItem.bookCount })}
|
||||
</span>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { BookOpen, Calendar, Tag, User } from "lucide-react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { useAnonymous } from "@/contexts/AnonymousContext";
|
||||
|
||||
interface SeriesListProps {
|
||||
series: NormalizedSeries[];
|
||||
@@ -57,16 +58,17 @@ const getReadingStatusInfo = (
|
||||
function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslate();
|
||||
const { isAnonymous } = useAnonymous();
|
||||
|
||||
const handleClick = () => {
|
||||
router.push(`/series/${series.id}`);
|
||||
};
|
||||
|
||||
const isCompleted = series.bookCount === series.booksReadCount;
|
||||
const isCompleted = isAnonymous ? false : series.bookCount === series.booksReadCount;
|
||||
const progressPercentage =
|
||||
series.bookCount > 0 ? (series.booksReadCount / series.bookCount) * 100 : 0;
|
||||
|
||||
const statusInfo = getReadingStatusInfo(series, t);
|
||||
const statusInfo = isAnonymous ? null : getReadingStatusInfo(series, t);
|
||||
|
||||
if (isCompact) {
|
||||
return (
|
||||
@@ -83,6 +85,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
||||
series={series}
|
||||
alt={t("series.coverAlt", { title: series.name })}
|
||||
className="w-full h-full"
|
||||
isAnonymous={isAnonymous}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -93,14 +96,16 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
||||
<h3 className="font-medium text-sm sm:text-base line-clamp-1 hover:text-primary transition-colors flex-1 min-w-0">
|
||||
{series.name}
|
||||
</h3>
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
|
||||
statusInfo.className
|
||||
)}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
{statusInfo && (
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
|
||||
statusInfo.className
|
||||
)}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Métadonnées minimales */}
|
||||
@@ -139,6 +144,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
||||
series={series}
|
||||
alt={t("series.coverAlt", { title: series.name })}
|
||||
className="w-full h-full"
|
||||
isAnonymous={isAnonymous}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -153,14 +159,16 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
||||
</div>
|
||||
|
||||
{/* Badge de statut */}
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
|
||||
statusInfo.className
|
||||
)}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
{statusInfo && (
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
|
||||
statusInfo.className
|
||||
)}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Résumé */}
|
||||
@@ -224,7 +232,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
||||
</div>
|
||||
|
||||
{/* Barre de progression */}
|
||||
{series.bookCount > 0 && !isCompleted && series.booksReadCount > 0 && (
|
||||
{!isAnonymous && series.bookCount > 0 && !isCompleted && series.booksReadCount > 0 && (
|
||||
<div className="space-y-1">
|
||||
<Progress value={progressPercentage} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -42,12 +42,14 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
||||
const {
|
||||
loadedImages,
|
||||
imageBlobUrls,
|
||||
prefetchImage,
|
||||
prefetchPages,
|
||||
prefetchNextBook,
|
||||
cancelAllPrefetches,
|
||||
handleForceReload,
|
||||
getPageUrl,
|
||||
prefetchCount,
|
||||
isPageLoading,
|
||||
} = useImageLoader({
|
||||
pageUrlBuilder: bookPageUrlBuilder,
|
||||
pages,
|
||||
@@ -74,21 +76,56 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
||||
onPreviousPage: handlePreviousPage,
|
||||
onNextPage: handleNextPage,
|
||||
pswpRef,
|
||||
isRTL,
|
||||
});
|
||||
|
||||
// Activer le zoom dans le reader en enlevant la classe no-pinch-zoom
|
||||
// et reset le zoom lors des changements d'orientation (iOS applique un zoom automatique)
|
||||
useEffect(() => {
|
||||
document.body.classList.remove("no-pinch-zoom");
|
||||
|
||||
const handleOrientationChange = () => {
|
||||
const viewport = document.querySelector('meta[name="viewport"]');
|
||||
if (viewport) {
|
||||
const original = viewport.getAttribute("content") || "";
|
||||
viewport.setAttribute("content", original + ", maximum-scale=1");
|
||||
// Restaurer après que iOS ait appliqué le nouveau layout
|
||||
requestAnimationFrame(() => {
|
||||
viewport.setAttribute("content", original);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("orientationchange", handleOrientationChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("orientationchange", handleOrientationChange);
|
||||
document.body.classList.add("no-pinch-zoom");
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Prefetch current and next pages
|
||||
useEffect(() => {
|
||||
// Prefetch pages starting from current page
|
||||
prefetchPages(currentPage, prefetchCount);
|
||||
// Determine visible pages that need to be loaded immediately
|
||||
const visiblePages: number[] = [];
|
||||
if (isDoublePage && shouldShowDoublePage(currentPage, pages.length)) {
|
||||
visiblePages.push(currentPage, currentPage + 1);
|
||||
} else {
|
||||
visiblePages.push(currentPage);
|
||||
}
|
||||
|
||||
// Load visible pages first (priority) to avoid duplicate requests from <img> tags
|
||||
// These will populate imageBlobUrls so <img> tags use blob URLs instead of making HTTP requests
|
||||
const loadVisiblePages = async () => {
|
||||
await Promise.all(visiblePages.map((page) => prefetchImage(page)));
|
||||
};
|
||||
loadVisiblePages().catch(() => {
|
||||
// Silently fail - will fallback to direct HTTP requests
|
||||
});
|
||||
|
||||
// Then prefetch other pages, excluding visible ones to avoid duplicates
|
||||
const concurrency = isDoublePage && shouldShowDoublePage(currentPage, pages.length) ? 2 : 4;
|
||||
prefetchPages(currentPage, prefetchCount, visiblePages, concurrency);
|
||||
|
||||
// If double page mode, also prefetch additional pages for smooth double page navigation
|
||||
if (
|
||||
@@ -96,7 +133,7 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
||||
shouldShowDoublePage(currentPage, pages.length) &&
|
||||
currentPage + prefetchCount < pages.length
|
||||
) {
|
||||
prefetchPages(currentPage + prefetchCount, 1);
|
||||
prefetchPages(currentPage + prefetchCount, 1, visiblePages, concurrency);
|
||||
}
|
||||
|
||||
// If we're near the end of the book, prefetch the next book
|
||||
@@ -108,6 +145,7 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
||||
currentPage,
|
||||
isDoublePage,
|
||||
shouldShowDoublePage,
|
||||
prefetchImage,
|
||||
prefetchPages,
|
||||
prefetchNextBook,
|
||||
prefetchCount,
|
||||
@@ -227,7 +265,6 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
||||
isDoublePage={isDoublePage}
|
||||
shouldShowDoublePage={(page) => shouldShowDoublePage(page, pages.length)}
|
||||
imageBlobUrls={imageBlobUrls}
|
||||
getPageUrl={getPageUrl}
|
||||
isRTL={isRTL}
|
||||
/>
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ export const ControlButtons = ({
|
||||
icon={ChevronLeft}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPreviousPage();
|
||||
direction === "rtl" ? onNextPage() : onPreviousPage();
|
||||
}}
|
||||
tooltip={t("reader.controls.previousPage")}
|
||||
iconClassName="h-8 w-8"
|
||||
@@ -193,7 +193,7 @@ export const ControlButtons = ({
|
||||
icon={ChevronRight}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNextPage();
|
||||
direction === "rtl" ? onPreviousPage() : onNextPage();
|
||||
}}
|
||||
tooltip={t("reader.controls.nextPage")}
|
||||
iconClassName="h-8 w-8"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PageDisplayProps {
|
||||
@@ -7,7 +7,6 @@ interface PageDisplayProps {
|
||||
isDoublePage: boolean;
|
||||
shouldShowDoublePage: (page: number) => boolean;
|
||||
imageBlobUrls: Record<number, string>;
|
||||
getPageUrl: (pageNum: number) => string;
|
||||
isRTL: boolean;
|
||||
}
|
||||
|
||||
@@ -17,13 +16,14 @@ export function PageDisplay({
|
||||
isDoublePage,
|
||||
shouldShowDoublePage,
|
||||
imageBlobUrls,
|
||||
getPageUrl,
|
||||
isRTL,
|
||||
}: PageDisplayProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [secondPageLoading, setSecondPageLoading] = useState(true);
|
||||
const [secondPageHasError, setSecondPageHasError] = useState(false);
|
||||
const imageBlobUrlsRef = useRef(imageBlobUrls);
|
||||
imageBlobUrlsRef.current = imageBlobUrls;
|
||||
|
||||
const handleImageLoad = useCallback(() => {
|
||||
setIsLoading(false);
|
||||
@@ -43,14 +43,29 @@ export function PageDisplay({
|
||||
setSecondPageHasError(true);
|
||||
}, []);
|
||||
|
||||
// Reset loading when page changes
|
||||
// Reset loading when page changes, but skip if blob URL is already available
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
setIsLoading(!imageBlobUrlsRef.current[currentPage]);
|
||||
setHasError(false);
|
||||
setSecondPageLoading(true);
|
||||
setSecondPageLoading(!imageBlobUrlsRef.current[currentPage + 1]);
|
||||
setSecondPageHasError(false);
|
||||
}, [currentPage, isDoublePage]);
|
||||
|
||||
// Reset error state when blob URL becomes available
|
||||
useEffect(() => {
|
||||
if (imageBlobUrls[currentPage] && hasError) {
|
||||
setHasError(false);
|
||||
setIsLoading(true);
|
||||
}
|
||||
}, [imageBlobUrls[currentPage], currentPage, hasError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (imageBlobUrls[currentPage + 1] && secondPageHasError) {
|
||||
setSecondPageHasError(false);
|
||||
setSecondPageLoading(true);
|
||||
}
|
||||
}, [imageBlobUrls[currentPage + 1], currentPage, secondPageHasError]);
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full flex-1 items-center justify-center overflow-hidden">
|
||||
<div className="relative flex h-[calc(100vh-2.5rem)] w-full items-center justify-center px-2 sm:px-4">
|
||||
@@ -97,12 +112,12 @@ export function PageDisplay({
|
||||
</svg>
|
||||
<span className="text-sm opacity-60">Image non disponible</span>
|
||||
</div>
|
||||
) : (
|
||||
) : imageBlobUrls[currentPage] ? (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
key={`page-${currentPage}-${imageBlobUrls[currentPage] || ""}`}
|
||||
src={imageBlobUrls[currentPage] || getPageUrl(currentPage)}
|
||||
key={`page-${currentPage}-${imageBlobUrls[currentPage]}`}
|
||||
src={imageBlobUrls[currentPage]}
|
||||
alt={`Page ${currentPage}`}
|
||||
className={cn(
|
||||
"max-h-full max-w-full cursor-pointer object-contain transition-opacity",
|
||||
@@ -119,7 +134,7 @@ export function PageDisplay({
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Page 2 (double page) */}
|
||||
@@ -161,12 +176,12 @@ export function PageDisplay({
|
||||
</svg>
|
||||
<span className="text-sm opacity-60">Image non disponible</span>
|
||||
</div>
|
||||
) : (
|
||||
) : imageBlobUrls[currentPage + 1] ? (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
key={`page-${currentPage + 1}-${imageBlobUrls[currentPage + 1] || ""}`}
|
||||
src={imageBlobUrls[currentPage + 1] || getPageUrl(currentPage + 1)}
|
||||
key={`page-${currentPage + 1}-${imageBlobUrls[currentPage + 1]}`}
|
||||
src={imageBlobUrls[currentPage + 1]}
|
||||
alt={`Page ${currentPage + 1}`}
|
||||
className={cn(
|
||||
"max-h-full max-w-full cursor-pointer object-contain transition-opacity",
|
||||
@@ -183,7 +198,7 @@ export function PageDisplay({
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,8 @@ export function useImageLoader({
|
||||
// Track ongoing fetch requests to prevent duplicates
|
||||
const pendingFetchesRef = useRef<Set<ImageKey>>(new Set());
|
||||
const abortControllersRef = useRef<Map<ImageKey, AbortController>>(new Map());
|
||||
// Track promises for pages being loaded so we can await them
|
||||
const loadingPromisesRef = useRef<Map<ImageKey, Promise<void>>>(new Map());
|
||||
|
||||
// Keep refs in sync with state
|
||||
useEffect(() => {
|
||||
@@ -44,12 +46,14 @@ export function useImageLoader({
|
||||
isMountedRef.current = true;
|
||||
const abortControllers = abortControllersRef.current;
|
||||
const pendingFetches = pendingFetchesRef.current;
|
||||
const loadingPromises = loadingPromisesRef.current;
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
abortControllers.forEach((controller) => controller.abort());
|
||||
abortControllers.clear();
|
||||
pendingFetches.clear();
|
||||
loadingPromises.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -57,6 +61,7 @@ export function useImageLoader({
|
||||
abortControllersRef.current.forEach((controller) => controller.abort());
|
||||
abortControllersRef.current.clear();
|
||||
pendingFetchesRef.current.clear();
|
||||
loadingPromisesRef.current.clear();
|
||||
}, []);
|
||||
|
||||
const runWithConcurrency = useCallback(
|
||||
@@ -92,73 +97,96 @@ export function useImageLoader({
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this page is already being fetched
|
||||
if (pendingFetchesRef.current.has(pageNum)) {
|
||||
return;
|
||||
// Check if this page is already being fetched - if so, wait for it
|
||||
const existingPromise = loadingPromisesRef.current.get(pageNum);
|
||||
if (existingPromise) {
|
||||
return existingPromise;
|
||||
}
|
||||
|
||||
// Mark as pending
|
||||
// Mark as pending and create promise
|
||||
pendingFetchesRef.current.add(pageNum);
|
||||
const controller = new AbortController();
|
||||
abortControllersRef.current.set(pageNum, controller);
|
||||
|
||||
try {
|
||||
// Use browser cache if available - the server sets Cache-Control headers
|
||||
const response = await fetch(getPageUrl(pageNum), {
|
||||
cache: "default", // Respect Cache-Control headers from server
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// Create image to get dimensions
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (!isMountedRef.current || controller.signal.aborted) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
const promise = (async () => {
|
||||
try {
|
||||
// Use browser cache if available - the server sets Cache-Control headers
|
||||
const response = await fetch(getPageUrl(pageNum), {
|
||||
cache: "default", // Respect Cache-Control headers from server
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadedImages((prev) => ({
|
||||
...prev,
|
||||
[pageNum]: { width: img.naturalWidth, height: img.naturalHeight },
|
||||
}));
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// Store the blob URL for immediate use
|
||||
setImageBlobUrls((prev) => ({
|
||||
...prev,
|
||||
[pageNum]: blobUrl,
|
||||
}));
|
||||
};
|
||||
// Create image to get dimensions
|
||||
const img = new Image();
|
||||
|
||||
// Wait for image to load before resolving promise
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => {
|
||||
if (!isMountedRef.current || controller.signal.aborted) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
reject(new Error("Aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
};
|
||||
setLoadedImages((prev) => ({
|
||||
...prev,
|
||||
[pageNum]: { width: img.naturalWidth, height: img.naturalHeight },
|
||||
}));
|
||||
|
||||
img.src = blobUrl;
|
||||
} catch {
|
||||
// Silently fail prefetch
|
||||
} finally {
|
||||
// Remove from pending set
|
||||
pendingFetchesRef.current.delete(pageNum);
|
||||
abortControllersRef.current.delete(pageNum);
|
||||
}
|
||||
// Store the blob URL for immediate use
|
||||
setImageBlobUrls((prev) => ({
|
||||
...prev,
|
||||
[pageNum]: blobUrl,
|
||||
}));
|
||||
|
||||
resolve();
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
reject(new Error("Image load error"));
|
||||
};
|
||||
|
||||
img.src = blobUrl;
|
||||
});
|
||||
} catch {
|
||||
// Silently fail prefetch
|
||||
} finally {
|
||||
// Remove from pending set and promise map
|
||||
pendingFetchesRef.current.delete(pageNum);
|
||||
abortControllersRef.current.delete(pageNum);
|
||||
loadingPromisesRef.current.delete(pageNum);
|
||||
}
|
||||
})();
|
||||
|
||||
// Store promise so other calls can await it
|
||||
loadingPromisesRef.current.set(pageNum, promise);
|
||||
|
||||
return promise;
|
||||
},
|
||||
[getPageUrl]
|
||||
);
|
||||
|
||||
// Prefetch multiple pages starting from a given page
|
||||
const prefetchPages = useCallback(
|
||||
async (startPage: number, count: number = prefetchCount) => {
|
||||
async (
|
||||
startPage: number,
|
||||
count: number = prefetchCount,
|
||||
excludePages: number[] = [],
|
||||
concurrency?: number
|
||||
) => {
|
||||
const pagesToPrefetch = [];
|
||||
const excludeSet = new Set(excludePages);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const pageNum = startPage + i;
|
||||
if (pageNum <= _pages.length) {
|
||||
if (pageNum <= _pages.length && !excludeSet.has(pageNum)) {
|
||||
const hasDimensions = loadedImagesRef.current[pageNum];
|
||||
const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
|
||||
const isPending = pendingFetchesRef.current.has(pageNum);
|
||||
@@ -170,10 +198,13 @@ export function useImageLoader({
|
||||
}
|
||||
}
|
||||
|
||||
// Use provided concurrency or default
|
||||
const effectiveConcurrency = concurrency ?? PREFETCH_CONCURRENCY;
|
||||
|
||||
// Let all prefetch requests run - the server queue will manage concurrency
|
||||
// The browser cache and our deduplication prevent redundant requests
|
||||
if (pagesToPrefetch.length > 0) {
|
||||
runWithConcurrency(pagesToPrefetch, prefetchImage).catch(() => {
|
||||
runWithConcurrency(pagesToPrefetch, prefetchImage, effectiveConcurrency).catch(() => {
|
||||
// Silently fail - prefetch is non-critical
|
||||
});
|
||||
}
|
||||
@@ -340,6 +371,14 @@ export function useImageLoader({
|
||||
};
|
||||
}, []); // Empty dependency array - only cleanup on unmount
|
||||
|
||||
// Check if a page is currently being loaded
|
||||
const isPageLoading = useCallback(
|
||||
(pageNum: number) => {
|
||||
return pendingFetchesRef.current.has(pageNum);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
loadedImages,
|
||||
imageBlobUrls,
|
||||
@@ -350,5 +389,6 @@ export function useImageLoader({
|
||||
handleForceReload,
|
||||
getPageUrl,
|
||||
prefetchCount,
|
||||
isPageLoading,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.serv
|
||||
import type { NormalizedBook } from "@/lib/providers/types";
|
||||
import logger from "@/lib/logger";
|
||||
import { updateReadProgress } from "@/app/actions/read-progress";
|
||||
import { useAnonymous } from "@/contexts/AnonymousContext";
|
||||
|
||||
interface UsePageNavigationProps {
|
||||
book: NormalizedBook;
|
||||
@@ -23,6 +24,13 @@ export function usePageNavigation({
|
||||
nextBook,
|
||||
}: UsePageNavigationProps) {
|
||||
const router = useRouter();
|
||||
const { isAnonymous } = useAnonymous();
|
||||
const isAnonymousRef = useRef(isAnonymous);
|
||||
|
||||
useEffect(() => {
|
||||
isAnonymousRef.current = isAnonymous;
|
||||
}, [isAnonymous]);
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(() => {
|
||||
const saved = ClientOfflineBookService.getCurrentPage(book);
|
||||
return saved < 1 ? 1 : saved;
|
||||
@@ -48,8 +56,10 @@ export function usePageNavigation({
|
||||
async (page: number) => {
|
||||
try {
|
||||
ClientOfflineBookService.setCurrentPage(bookRef.current, page);
|
||||
const completed = page === pagesLengthRef.current;
|
||||
await updateReadProgress(bookRef.current.id, page, completed);
|
||||
if (!isAnonymousRef.current) {
|
||||
const completed = page === pagesLengthRef.current;
|
||||
await updateReadProgress(bookRef.current.id, page, completed);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Sync error:");
|
||||
}
|
||||
@@ -89,7 +99,7 @@ export function usePageNavigation({
|
||||
const handleNextPage = useCallback(() => {
|
||||
if (currentPage === pages.length) {
|
||||
if (nextBook) {
|
||||
router.push(`/books/${nextBook.id}`);
|
||||
router.replace(`/books/${nextBook.id}`);
|
||||
return;
|
||||
}
|
||||
setShowEndMessage(true);
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
import { useCallback, useRef, useEffect } from "react";
|
||||
import { useReadingDirection } from "./useReadingDirection";
|
||||
|
||||
interface UseTouchNavigationProps {
|
||||
onPreviousPage: () => void;
|
||||
onNextPage: () => void;
|
||||
pswpRef: React.MutableRefObject<unknown>;
|
||||
isRTL: boolean;
|
||||
}
|
||||
|
||||
export function useTouchNavigation({
|
||||
onPreviousPage,
|
||||
onNextPage,
|
||||
pswpRef,
|
||||
isRTL,
|
||||
}: UseTouchNavigationProps) {
|
||||
const { isRTL } = useReadingDirection();
|
||||
const touchStartXRef = useRef<number | null>(null);
|
||||
const touchStartYRef = useRef<number | null>(null);
|
||||
const isPinchingRef = useRef(false);
|
||||
|
||||
// Helper pour vérifier si la page est zoomée (zoom natif du navigateur)
|
||||
const isZoomed = useCallback(() => {
|
||||
// Utiliser visualViewport.scale pour détecter le zoom natif
|
||||
// Si scale > 1, la page est zoomée
|
||||
if (window.visualViewport) {
|
||||
return window.visualViewport.scale > 1;
|
||||
return window.visualViewport.scale > 1.05;
|
||||
}
|
||||
// Fallback pour les navigateurs qui ne supportent pas visualViewport
|
||||
// Comparer la taille de la fenêtre avec la taille réelle
|
||||
return window.innerWidth !== window.screen.width;
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { FileText } from "lucide-react";
|
||||
import { MarkAsReadButton } from "@/components/ui/mark-as-read-button";
|
||||
import { MarkAsUnreadButton } from "@/components/ui/mark-as-unread-button";
|
||||
import { BookOfflineButton } from "@/components/ui/book-offline-button";
|
||||
import { useAnonymous } from "@/contexts/AnonymousContext";
|
||||
|
||||
interface BookListProps {
|
||||
books: NormalizedBook[];
|
||||
@@ -30,6 +31,7 @@ interface BookListItemProps {
|
||||
|
||||
function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookListItemProps) {
|
||||
const { t } = useTranslate();
|
||||
const { isAnonymous } = useAnonymous();
|
||||
const { isAccessible } = useBookOfflineStatus(book.id);
|
||||
|
||||
const handleClick = () => {
|
||||
@@ -37,9 +39,9 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
||||
onBookClick(book);
|
||||
};
|
||||
|
||||
const isRead = book.readProgress?.completed || false;
|
||||
const hasReadProgress = book.readProgress !== null;
|
||||
const currentPage = ClientOfflineBookService.getCurrentPage(book);
|
||||
const isRead = isAnonymous ? false : (book.readProgress?.completed || false);
|
||||
const hasReadProgress = isAnonymous ? false : book.readProgress !== null;
|
||||
const currentPage = isAnonymous ? 0 : ClientOfflineBookService.getCurrentPage(book);
|
||||
const totalPages = book.pageCount;
|
||||
const progressPercentage = totalPages > 0 ? (currentPage / totalPages) * 100 : 0;
|
||||
|
||||
@@ -118,14 +120,16 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
|
||||
statusInfo.className
|
||||
)}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
{!isAnonymous && (
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
|
||||
statusInfo.className
|
||||
)}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Métadonnées minimales */}
|
||||
@@ -191,14 +195,16 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
||||
</div>
|
||||
|
||||
{/* Badge de statut */}
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
|
||||
statusInfo.className
|
||||
)}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
{!isAnonymous && (
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
|
||||
statusInfo.className
|
||||
)}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Métadonnées */}
|
||||
@@ -224,7 +230,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 mt-auto pt-2">
|
||||
{!isRead && (
|
||||
{!isAnonymous && !isRead && (
|
||||
<MarkAsReadButton
|
||||
bookId={book.id}
|
||||
pagesCount={book.pageCount}
|
||||
@@ -233,7 +239,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
||||
className="text-xs"
|
||||
/>
|
||||
)}
|
||||
{hasReadProgress && (
|
||||
{!isAnonymous && hasReadProgress && (
|
||||
<MarkAsUnreadButton
|
||||
bookId={book.id}
|
||||
onSuccess={() => onSuccess(book, "unread")}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Book, BookOpen, BookMarked, Star, StarOff } from "lucide-react";
|
||||
import { Book, BookOpen, BookMarked, BookX, Star, StarOff, User } from "lucide-react";
|
||||
import type { NormalizedSeries } from "@/lib/providers/types";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
@@ -14,6 +14,7 @@ import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import logger from "@/lib/logger";
|
||||
import { addToFavorites, removeFromFavorites } from "@/app/actions/favorites";
|
||||
import { useAnonymous } from "@/contexts/AnonymousContext";
|
||||
|
||||
interface SeriesHeaderProps {
|
||||
series: NormalizedSeries;
|
||||
@@ -23,7 +24,9 @@ interface SeriesHeaderProps {
|
||||
|
||||
export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: SeriesHeaderProps) => {
|
||||
const { toast } = useToast();
|
||||
const { isAnonymous } = useAnonymous();
|
||||
const [isFavorite, setIsFavorite] = useState(initialIsFavorite);
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||
const { t } = useTranslate();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -99,10 +102,13 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
|
||||
};
|
||||
};
|
||||
|
||||
const statusInfo = getReadingStatusInfo();
|
||||
const statusInfo = isAnonymous ? null : getReadingStatusInfo();
|
||||
const authorsText = series.authors?.length
|
||||
? series.authors.map((a) => a.name).join(", ")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="relative min-h-[300px] md:h-[300px] w-screen -ml-[calc((100vw-100%)/2)] overflow-hidden">
|
||||
<div className="relative min-h-[300px] w-screen -ml-[calc((100vw-100%)/2)] overflow-hidden">
|
||||
{/* Image de fond */}
|
||||
<div className="absolute inset-0">
|
||||
<SeriesCover
|
||||
@@ -128,20 +134,41 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
|
||||
{/* Informations */}
|
||||
<div className="flex-1 text-white space-y-2 text-center md:text-left">
|
||||
<h1 className="text-2xl md:text-3xl font-bold">{series.name}</h1>
|
||||
{series.summary && (
|
||||
<p className="text-white/80 line-clamp-3 text-sm md:text-base">
|
||||
{series.summary}
|
||||
{authorsText && (
|
||||
<p className="text-white/70 text-sm flex items-center gap-1 justify-center md:justify-start">
|
||||
<User className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
{authorsText}
|
||||
</p>
|
||||
)}
|
||||
{series.summary && (
|
||||
<div>
|
||||
<p className={`text-white/80 text-sm md:text-base ${isDescriptionExpanded ? "max-h-[200px] overflow-y-auto" : "line-clamp-3"}`}>
|
||||
{series.summary}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setIsDescriptionExpanded(!isDescriptionExpanded)}
|
||||
className="text-white/60 hover:text-white/90 text-xs mt-1 transition-colors"
|
||||
>
|
||||
{t(isDescriptionExpanded ? "series.header.showLess" : "series.header.showMore")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-4 mt-4 justify-center md:justify-start flex-wrap">
|
||||
<StatusBadge status={statusInfo.status} icon={statusInfo.icon}>
|
||||
{statusInfo.label}
|
||||
</StatusBadge>
|
||||
{statusInfo && (
|
||||
<StatusBadge status={statusInfo.status} icon={statusInfo.icon}>
|
||||
{statusInfo.label}
|
||||
</StatusBadge>
|
||||
)}
|
||||
<span className="text-sm text-white/80">
|
||||
{series.bookCount === 1
|
||||
? t("series.header.books", { count: series.bookCount })
|
||||
: t("series.header.books_plural", { count: series.bookCount })}
|
||||
</span>
|
||||
{series.missingCount != null && series.missingCount > 0 && (
|
||||
<StatusBadge status="warning" icon={BookX}>
|
||||
{t("series.header.missing", { count: series.missingCount })}
|
||||
</StatusBadge>
|
||||
)}
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -158,6 +185,7 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -278,7 +278,7 @@ export function BackgroundSettings({ initialLibraries }: BackgroundSettingsProps
|
||||
htmlFor={`lib-${library.id}`}
|
||||
className="cursor-pointer font-normal text-sm"
|
||||
>
|
||||
{library.name} ({library.bookCount} livres)
|
||||
{library.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -34,14 +34,8 @@ export function KomgaSettings({ initialConfig }: KomgaSettingsProps) {
|
||||
const handleTest = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const form = document.querySelector("form") as HTMLFormElement;
|
||||
const formData = new FormData(form);
|
||||
const serverUrl = formData.get("serverUrl") as string;
|
||||
const username = formData.get("username") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
try {
|
||||
const result = await testKomgaConnection(serverUrl.trim(), username, password || config.password);
|
||||
const result = await testKomgaConnection(config.serverUrl.trim(), config.username, config.password);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message);
|
||||
@@ -55,8 +49,8 @@ export function KomgaSettings({ initialConfig }: KomgaSettingsProps) {
|
||||
logger.error({ err: error }, "Erreur:");
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("settings.komga.error.title"),
|
||||
description: t("settings.komga.error.message"),
|
||||
title: t("settings.komga.error.connectionTitle"),
|
||||
description: t("settings.komga.error.connectionMessage"),
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useTranslate } from "@/hooks/useTranslate";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
||||
import { WifiOff } from "lucide-react";
|
||||
import { useAnonymous } from "@/contexts/AnonymousContext";
|
||||
|
||||
// Fonction utilitaire pour obtenir les informations de statut de lecture
|
||||
const getReadingStatusInfo = (
|
||||
@@ -60,17 +61,18 @@ export function BookCover({
|
||||
overlayVariant = "default",
|
||||
}: BookCoverProps) {
|
||||
const { t } = useTranslate();
|
||||
const { isAnonymous } = useAnonymous();
|
||||
const { isAccessible } = useBookOfflineStatus(book.id);
|
||||
|
||||
const isCompleted = book.readProgress?.completed || false;
|
||||
const isCompleted = isAnonymous ? false : (book.readProgress?.completed || false);
|
||||
|
||||
const currentPage = ClientOfflineBookService.getCurrentPage(book);
|
||||
const currentPage = isAnonymous ? 0 : ClientOfflineBookService.getCurrentPage(book);
|
||||
const totalPages = book.pageCount;
|
||||
const showProgress = Boolean(showProgressUi && totalPages > 0 && currentPage > 0 && !isCompleted);
|
||||
const showProgress = Boolean(!isAnonymous && showProgressUi && totalPages > 0 && currentPage > 0 && !isCompleted);
|
||||
|
||||
const statusInfo = getReadingStatusInfo(book, t);
|
||||
const isRead = book.readProgress?.completed || false;
|
||||
const hasReadProgress = book.readProgress !== null || currentPage > 0;
|
||||
const statusInfo = isAnonymous ? { label: "", className: "" } : getReadingStatusInfo(book, t);
|
||||
const isRead = isAnonymous ? false : (book.readProgress?.completed || false);
|
||||
const hasReadProgress = isAnonymous ? false : (book.readProgress !== null || currentPage > 0);
|
||||
|
||||
// Détermine si le livre doit être grisé (non accessible hors ligne)
|
||||
const isUnavailable = !isAccessible;
|
||||
@@ -115,7 +117,7 @@ export function BookCover({
|
||||
{showControls && (
|
||||
// Boutons en haut à droite avec un petit décalage
|
||||
<div className="absolute top-2 right-2 pointer-events-auto flex gap-1">
|
||||
{!isRead && (
|
||||
{!isAnonymous && !isRead && (
|
||||
<MarkAsReadButton
|
||||
bookId={book.id}
|
||||
pagesCount={book.pageCount}
|
||||
@@ -124,7 +126,7 @@ export function BookCover({
|
||||
className="bg-white/90 hover:bg-white text-black shadow-sm"
|
||||
/>
|
||||
)}
|
||||
{hasReadProgress && (
|
||||
{!isAnonymous && hasReadProgress && (
|
||||
<MarkAsUnreadButton
|
||||
bookId={book.id}
|
||||
onSuccess={() => handleMarkAsUnread()}
|
||||
@@ -145,11 +147,13 @@ export function BookCover({
|
||||
? t("navigation.volume", { number: book.number })
|
||||
: "")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${statusInfo.className}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
{!isAnonymous && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${statusInfo.className}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -162,12 +166,14 @@ export function BookCover({
|
||||
? t("navigation.volume", { number: book.number })
|
||||
: "")}
|
||||
</h3>
|
||||
<p className="text-xs text-white/80 mt-1">
|
||||
{t("books.status.progress", {
|
||||
current: currentPage,
|
||||
total: book.pageCount,
|
||||
})}
|
||||
</p>
|
||||
{!isAnonymous && (
|
||||
<p className="text-xs text-white/80 mt-1">
|
||||
{t("books.status.progress", {
|
||||
current: currentPage,
|
||||
total: book.pageCount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -18,4 +18,5 @@ export interface BookCoverProps extends BaseCoverProps {
|
||||
|
||||
export interface SeriesCoverProps extends BaseCoverProps {
|
||||
series: NormalizedSeries;
|
||||
isAnonymous?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ProgressBar } from "./progress-bar";
|
||||
import { BookX } from "lucide-react";
|
||||
import type { SeriesCoverProps } from "./cover-utils";
|
||||
|
||||
export function SeriesCover({
|
||||
@@ -6,12 +7,14 @@ export function SeriesCover({
|
||||
alt = "Image de couverture",
|
||||
className,
|
||||
showProgressUi = true,
|
||||
isAnonymous = false,
|
||||
}: SeriesCoverProps) {
|
||||
const isCompleted = series.bookCount === series.booksReadCount;
|
||||
const isCompleted = isAnonymous ? false : series.bookCount === series.booksReadCount;
|
||||
|
||||
const readBooks = series.booksReadCount;
|
||||
const readBooks = isAnonymous ? 0 : series.booksReadCount;
|
||||
const totalBooks = series.bookCount;
|
||||
const showProgress = Boolean(showProgressUi && totalBooks > 0 && readBooks > 0 && !isCompleted);
|
||||
const showProgress = Boolean(!isAnonymous && showProgressUi && totalBooks > 0 && readBooks > 0 && !isCompleted);
|
||||
const missingCount = series.missingCount;
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
@@ -27,6 +30,12 @@ export function SeriesCover({
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
/>
|
||||
{showProgressUi && missingCount != null && missingCount > 0 && (
|
||||
<div className="absolute top-1.5 right-1.5 flex items-center gap-0.5 rounded-full bg-orange-500/90 px-1.5 py-0.5 text-white shadow-md backdrop-blur-sm">
|
||||
<BookX className="h-3 w-3" />
|
||||
<span className="text-[10px] font-bold leading-none">{missingCount}</span>
|
||||
</div>
|
||||
)}
|
||||
{showProgress ? <ProgressBar progress={readBooks} total={totalBooks} type="series" /> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
34
src/contexts/AnonymousContext.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useMemo, useCallback } from "react";
|
||||
import { usePreferences } from "@/contexts/PreferencesContext";
|
||||
|
||||
interface AnonymousContextType {
|
||||
isAnonymous: boolean;
|
||||
toggleAnonymous: () => void;
|
||||
}
|
||||
|
||||
const AnonymousContext = createContext<AnonymousContextType | undefined>(undefined);
|
||||
|
||||
export function AnonymousProvider({ children }: { children: React.ReactNode }) {
|
||||
const { preferences, updatePreferences } = usePreferences();
|
||||
|
||||
const toggleAnonymous = useCallback(() => {
|
||||
updatePreferences({ anonymousMode: !preferences.anonymousMode });
|
||||
}, [preferences.anonymousMode, updatePreferences]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ isAnonymous: preferences.anonymousMode, toggleAnonymous }),
|
||||
[preferences.anonymousMode, toggleAnonymous]
|
||||
);
|
||||
|
||||
return <AnonymousContext.Provider value={contextValue}>{children}</AnonymousContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAnonymous() {
|
||||
const context = useContext(AnonymousContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAnonymous must be used within an AnonymousProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -35,6 +35,7 @@
|
||||
},
|
||||
"title": "Home",
|
||||
"sections": {
|
||||
"favorites": "Favorite series",
|
||||
"continue_series": "Continue series",
|
||||
"continue_reading": "Continue reading",
|
||||
"up_next": "Up next",
|
||||
@@ -134,7 +135,9 @@
|
||||
},
|
||||
"error": {
|
||||
"title": "Error saving configuration",
|
||||
"message": "An error occurred while saving the configuration"
|
||||
"message": "An error occurred while saving the configuration",
|
||||
"connectionTitle": "Connection error",
|
||||
"connectionMessage": "Unable to connect to the Komga server. Check the URL and credentials."
|
||||
}
|
||||
},
|
||||
"cache": {
|
||||
@@ -258,6 +261,9 @@
|
||||
"add": "Added to favorites",
|
||||
"remove": "Removed from favorites"
|
||||
},
|
||||
"showMore": "Show more",
|
||||
"showLess": "Show less",
|
||||
"missing": "{{count}} missing",
|
||||
"toggleSidebar": "Toggle sidebar",
|
||||
"toggleTheme": "Toggle theme"
|
||||
}
|
||||
@@ -361,7 +367,6 @@
|
||||
"errors": {
|
||||
"MONGODB_MISSING_URI": "MongoDB URI missing",
|
||||
"MONGODB_CONNECTION_FAILED": "MongoDB connection failed",
|
||||
|
||||
"AUTH_UNAUTHENTICATED": "Unauthenticated",
|
||||
"AUTH_INVALID_CREDENTIALS": "Invalid credentials",
|
||||
"AUTH_PASSWORD_NOT_STRONG": "Password is not strong enough",
|
||||
@@ -371,7 +376,6 @@
|
||||
"AUTH_LOGOUT_ERROR": "Error during logout",
|
||||
"AUTH_LOGIN_ERROR": "Error during login",
|
||||
"AUTH_REGISTER_ERROR": "Error during registration",
|
||||
|
||||
"KOMGA_MISSING_CONFIG": "Komga configuration missing",
|
||||
"KOMGA_MISSING_CREDENTIALS": "Komga credentials missing",
|
||||
"KOMGA_CONNECTION_ERROR": "Error connecting to Komga server",
|
||||
@@ -380,25 +384,20 @@
|
||||
"STRIPSTREAM_MISSING_CONFIG": "Stripstream Librarian configuration missing",
|
||||
"STRIPSTREAM_CONNECTION_ERROR": "Error connecting to Stripstream Librarian",
|
||||
"STRIPSTREAM_HTTP_ERROR": "HTTP error while communicating with Stripstream Librarian",
|
||||
|
||||
"CONFIG_SAVE_ERROR": "Error saving configuration",
|
||||
"CONFIG_FETCH_ERROR": "Error fetching configuration",
|
||||
"CONFIG_TTL_SAVE_ERROR": "Error saving TTL configuration",
|
||||
"CONFIG_TTL_FETCH_ERROR": "Error fetching TTL configuration",
|
||||
|
||||
"LIBRARY_NOT_FOUND": "Library not found",
|
||||
"LIBRARY_FETCH_ERROR": "Error fetching library",
|
||||
"LIBRARY_SCAN_ERROR": "Error scanning library",
|
||||
|
||||
"SERIES_FETCH_ERROR": "Error fetching series",
|
||||
"SERIES_NO_BOOKS_FOUND": "No books found in series",
|
||||
|
||||
"BOOK_NOT_FOUND": "Book not found",
|
||||
"BOOK_PROGRESS_UPDATE_ERROR": "Error updating reading progress",
|
||||
"BOOK_PROGRESS_DELETE_ERROR": "Error deleting reading progress",
|
||||
"BOOK_PAGES_FETCH_ERROR": "Error fetching book pages",
|
||||
"BOOK_DOWNLOAD_CANCELLED": "Book download cancelled",
|
||||
|
||||
"FAVORITE_ADD_ERROR": "Error adding to favorites",
|
||||
"FAVORITE_DELETE_ERROR": "Error removing from favorites",
|
||||
"FAVORITE_FETCH_ERROR": "Error fetching favorites",
|
||||
@@ -406,26 +405,19 @@
|
||||
"FAVORITE_NETWORK_ERROR": "Network error while accessing favorites",
|
||||
"FAVORITE_SERVER_ERROR": "Server error while accessing favorites",
|
||||
"FAVORITE_STATUS_CHECK_ERROR": "Error checking favorites status",
|
||||
|
||||
"PREFERENCES_FETCH_ERROR": "Error fetching preferences",
|
||||
"PREFERENCES_UPDATE_ERROR": "Error updating preferences",
|
||||
"PREFERENCES_CONTEXT_ERROR": "Preferences context error",
|
||||
|
||||
"UI_TABS_TRIGGER_ERROR": "Error triggering tabs",
|
||||
"UI_TABS_CONTENT_ERROR": "Error loading tabs content",
|
||||
|
||||
"IMAGE_FETCH_ERROR": "Error fetching image",
|
||||
|
||||
"HOME_FETCH_ERROR": "Error fetching home page",
|
||||
|
||||
"MIDDLEWARE_UNAUTHORIZED": "Unauthorized",
|
||||
"MIDDLEWARE_INVALID_TOKEN": "Invalid authentication token",
|
||||
"MIDDLEWARE_INVALID_SESSION": "Invalid session",
|
||||
|
||||
"CLIENT_FETCH_ERROR": "Error fetching data",
|
||||
"CLIENT_NETWORK_ERROR": "Network error",
|
||||
"CLIENT_REQUEST_FAILED": "Request failed",
|
||||
|
||||
"GENERIC_ERROR": "An error occurred"
|
||||
},
|
||||
"reader": {
|
||||
@@ -460,6 +452,9 @@
|
||||
"header": {
|
||||
"toggleSidebar": "Toggle sidebar",
|
||||
"toggleTheme": "Toggle theme",
|
||||
"anonymousMode": "Anonymous mode",
|
||||
"anonymousModeOn": "Anonymous mode enabled",
|
||||
"anonymousModeOff": "Anonymous mode disabled",
|
||||
"search": {
|
||||
"placeholder": "Search series and books...",
|
||||
"empty": "No results",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
},
|
||||
"title": "Accueil",
|
||||
"sections": {
|
||||
"favorites": "Séries favorites",
|
||||
"continue_series": "Continuer la série",
|
||||
"continue_reading": "Continuer la lecture",
|
||||
"up_next": "À suivre",
|
||||
@@ -134,7 +135,9 @@
|
||||
},
|
||||
"error": {
|
||||
"title": "Erreur lors de la sauvegarde de la configuration",
|
||||
"message": "Une erreur est survenue lors de la sauvegarde de la configuration"
|
||||
"message": "Une erreur est survenue lors de la sauvegarde de la configuration",
|
||||
"connectionTitle": "Erreur de connexion",
|
||||
"connectionMessage": "Impossible de se connecter au serveur Komga. Vérifiez l'URL et les identifiants."
|
||||
}
|
||||
},
|
||||
"cache": {
|
||||
@@ -257,7 +260,10 @@
|
||||
"favorite": {
|
||||
"add": "Ajouté aux favoris",
|
||||
"remove": "Retiré des favoris"
|
||||
}
|
||||
},
|
||||
"showMore": "Voir plus",
|
||||
"showLess": "Voir moins",
|
||||
"missing": "{{count}} manquant(s)"
|
||||
}
|
||||
},
|
||||
"books": {
|
||||
@@ -359,7 +365,6 @@
|
||||
"errors": {
|
||||
"MONGODB_MISSING_URI": "URI MongoDB manquante",
|
||||
"MONGODB_CONNECTION_FAILED": "Erreur lors de la connexion à MongoDB",
|
||||
|
||||
"AUTH_UNAUTHENTICATED": "Non authentifié",
|
||||
"AUTH_INVALID_CREDENTIALS": "Identifiants invalides",
|
||||
"AUTH_PASSWORD_NOT_STRONG": "Le mot de passe n'est pas assez fort",
|
||||
@@ -369,7 +374,6 @@
|
||||
"AUTH_LOGOUT_ERROR": "Erreur lors de la déconnexion",
|
||||
"AUTH_LOGIN_ERROR": "Erreur lors de la connexion",
|
||||
"AUTH_REGISTER_ERROR": "Erreur lors de l'inscription",
|
||||
|
||||
"KOMGA_MISSING_CONFIG": "Configuration Komga manquante",
|
||||
"KOMGA_MISSING_CREDENTIALS": "Identifiants Komga manquants",
|
||||
"KOMGA_CONNECTION_ERROR": "Erreur de connexion au serveur Komga",
|
||||
@@ -378,25 +382,20 @@
|
||||
"STRIPSTREAM_MISSING_CONFIG": "Configuration Stripstream Librarian manquante",
|
||||
"STRIPSTREAM_CONNECTION_ERROR": "Erreur de connexion à Stripstream Librarian",
|
||||
"STRIPSTREAM_HTTP_ERROR": "Erreur HTTP lors de la communication avec Stripstream Librarian",
|
||||
|
||||
"CONFIG_SAVE_ERROR": "Erreur lors de la sauvegarde de la configuration",
|
||||
"CONFIG_FETCH_ERROR": "Erreur lors de la récupération de la configuration",
|
||||
"CONFIG_TTL_SAVE_ERROR": "Erreur lors de la sauvegarde des TTL",
|
||||
"CONFIG_TTL_FETCH_ERROR": "Erreur lors de la récupération des TTL",
|
||||
|
||||
"LIBRARY_NOT_FOUND": "Bibliothèque introuvable",
|
||||
"LIBRARY_FETCH_ERROR": "Erreur lors de la récupération de la bibliothèque",
|
||||
"LIBRARY_SCAN_ERROR": "Erreur lors de l'analyse de la bibliothèque",
|
||||
|
||||
"SERIES_FETCH_ERROR": "Erreur lors de la récupération des séries",
|
||||
"SERIES_NO_BOOKS_FOUND": "Aucun livre trouvé dans la série",
|
||||
|
||||
"BOOK_NOT_FOUND": "Livre introuvable",
|
||||
"BOOK_PROGRESS_UPDATE_ERROR": "Erreur lors de la mise à jour de la progression",
|
||||
"BOOK_PROGRESS_DELETE_ERROR": "Erreur lors de la suppression de la progression",
|
||||
"BOOK_PAGES_FETCH_ERROR": "Erreur lors de la récupération des pages du livre",
|
||||
"BOOK_DOWNLOAD_CANCELLED": "Téléchargement du livre annulé",
|
||||
|
||||
"FAVORITE_ADD_ERROR": "Erreur lors de l'ajout aux favoris",
|
||||
"FAVORITE_DELETE_ERROR": "Erreur lors de la suppression des favoris",
|
||||
"FAVORITE_FETCH_ERROR": "Erreur lors de la récupération des favoris",
|
||||
@@ -404,26 +403,19 @@
|
||||
"FAVORITE_NETWORK_ERROR": "Erreur réseau lors de l'accès aux favoris",
|
||||
"FAVORITE_SERVER_ERROR": "Erreur serveur lors de l'accès aux favoris",
|
||||
"FAVORITE_STATUS_CHECK_ERROR": "Erreur lors de la vérification du statut des favoris",
|
||||
|
||||
"PREFERENCES_FETCH_ERROR": "Erreur lors de la récupération des préférences",
|
||||
"PREFERENCES_UPDATE_ERROR": "Erreur lors de la mise à jour des préférences",
|
||||
"PREFERENCES_CONTEXT_ERROR": "Erreur de contexte des préférences",
|
||||
|
||||
"UI_TABS_TRIGGER_ERROR": "Erreur lors du déclenchement des onglets",
|
||||
"UI_TABS_CONTENT_ERROR": "Erreur lors du chargement du contenu des onglets",
|
||||
|
||||
"IMAGE_FETCH_ERROR": "Erreur lors de la récupération de l'image",
|
||||
|
||||
"HOME_FETCH_ERROR": "Erreur lors de la récupération de l'accueil",
|
||||
|
||||
"MIDDLEWARE_UNAUTHORIZED": "Non autorisé",
|
||||
"MIDDLEWARE_INVALID_TOKEN": "Jeton d'authentification invalide",
|
||||
"MIDDLEWARE_INVALID_SESSION": "Session invalide",
|
||||
|
||||
"CLIENT_FETCH_ERROR": "Erreur lors de la récupération des données",
|
||||
"CLIENT_NETWORK_ERROR": "Erreur réseau",
|
||||
"CLIENT_REQUEST_FAILED": "La requête a échoué",
|
||||
|
||||
"GENERIC_ERROR": "Une erreur est survenue"
|
||||
},
|
||||
"reader": {
|
||||
@@ -458,6 +450,9 @@
|
||||
"header": {
|
||||
"toggleSidebar": "Afficher/masquer le menu latéral",
|
||||
"toggleTheme": "Changer le thème",
|
||||
"anonymousMode": "Mode anonyme",
|
||||
"anonymousModeOn": "Mode anonyme activé",
|
||||
"anonymousModeOff": "Mode anonyme désactivé",
|
||||
"search": {
|
||||
"placeholder": "Rechercher séries et tomes...",
|
||||
"empty": "Aucun résultat",
|
||||
|
||||
@@ -37,6 +37,7 @@ export class KomgaAdapter {
|
||||
bookCount: series.booksCount,
|
||||
booksReadCount: series.booksReadCount,
|
||||
thumbnailUrl: `/api/komga/images/series/${series.id}/thumbnail`,
|
||||
libraryId: series.libraryId,
|
||||
summary: series.metadata?.summary ?? null,
|
||||
authors: series.booksMetadata?.authors ?? [],
|
||||
genres: series.metadata?.genres ?? [],
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { LibraryResponse } from "@/types/library";
|
||||
import type { AuthConfig } from "@/types/auth";
|
||||
import logger from "@/lib/logger";
|
||||
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG, SERIES_BOOKS_CACHE_TAG } from "@/constants/cacheConstants";
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
type KomgaCondition = Record<string, unknown>;
|
||||
|
||||
@@ -64,7 +65,6 @@ export class KomgaProvider implements IMediaProvider {
|
||||
|
||||
const isDebug = process.env.KOMGA_DEBUG === "true";
|
||||
const isCacheDebug = process.env.CACHE_DEBUG === "true";
|
||||
const startTime = isDebug ? Date.now() : 0;
|
||||
|
||||
if (isDebug) {
|
||||
logger.info(
|
||||
@@ -72,8 +72,14 @@ export class KomgaProvider implements IMediaProvider {
|
||||
"🔵 Komga Request"
|
||||
);
|
||||
}
|
||||
if (isCacheDebug && options.revalidate) {
|
||||
logger.info({ url, cache: "enabled", ttl: options.revalidate }, "💾 Cache enabled");
|
||||
if (isCacheDebug) {
|
||||
if (options.tags) {
|
||||
logger.info({ url, cache: "tags", tags: options.tags }, "💾 Cache tags");
|
||||
} else if (options.revalidate !== undefined) {
|
||||
logger.info({ url, cache: "revalidate", ttl: options.revalidate }, "💾 Cache revalidate");
|
||||
} else {
|
||||
logger.info({ url, cache: "none" }, "💾 Cache none");
|
||||
}
|
||||
}
|
||||
|
||||
const nextOptions = options.tags
|
||||
@@ -88,6 +94,19 @@ export class KomgaProvider implements IMediaProvider {
|
||||
next: nextOptions,
|
||||
};
|
||||
|
||||
// Next.js does not cache POST fetch requests — use unstable_cache to cache results instead
|
||||
if (options.method === "POST" && nextOptions) {
|
||||
const cacheKey = ["komga", this.config.authHeader, url, String(options.body ?? "")];
|
||||
return unstable_cache(() => this.executeRequest<T>(url, fetchOptions), cacheKey, nextOptions)();
|
||||
}
|
||||
|
||||
return this.executeRequest<T>(url, fetchOptions);
|
||||
}
|
||||
|
||||
private async executeRequest<T>(url: string, fetchOptions: RequestInit): Promise<T> {
|
||||
const isDebug = process.env.KOMGA_DEBUG === "true";
|
||||
const startTime = isDebug ? Date.now() : 0;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
|
||||
@@ -124,10 +143,6 @@ export class KomgaProvider implements IMediaProvider {
|
||||
"🟢 Komga Response"
|
||||
);
|
||||
}
|
||||
if (isCacheDebug && options.revalidate) {
|
||||
const cacheStatus = response.headers.get("x-nextjs-cache") ?? "UNKNOWN";
|
||||
logger.info({ url, cacheStatus }, `💾 Cache ${cacheStatus}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (isDebug) {
|
||||
@@ -163,25 +178,7 @@ export class KomgaProvider implements IMediaProvider {
|
||||
const raw = await this.fetch<KomgaLibrary[]>("libraries", undefined, {
|
||||
revalidate: CACHE_TTL_LONG,
|
||||
});
|
||||
// Enrich with book counts
|
||||
const enriched = await Promise.all(
|
||||
raw.map(async (lib) => {
|
||||
try {
|
||||
const resp = await this.fetch<{ totalElements: number }>(
|
||||
"books",
|
||||
{
|
||||
library_id: lib.id,
|
||||
size: "0",
|
||||
},
|
||||
{ revalidate: CACHE_TTL_LONG }
|
||||
);
|
||||
return { ...lib, booksCount: resp.totalElements, booksReadCount: 0 } as KomgaLibrary;
|
||||
} catch {
|
||||
return { ...lib, booksCount: 0, booksReadCount: 0 } as KomgaLibrary;
|
||||
}
|
||||
})
|
||||
);
|
||||
return enriched.map(KomgaAdapter.toNormalizedLibrary);
|
||||
return raw.map(KomgaAdapter.toNormalizedLibrary);
|
||||
}
|
||||
|
||||
async getSeries(libraryId: string, cursor?: string, limit = 20, unreadOnly = false, search?: string): Promise<NormalizedSeriesPage> {
|
||||
@@ -356,30 +353,8 @@ export class KomgaProvider implements IMediaProvider {
|
||||
}
|
||||
|
||||
async getLibraryById(libraryId: string): Promise<NormalizedLibrary | null> {
|
||||
try {
|
||||
const lib = await this.fetch<KomgaLibrary>(`libraries/${libraryId}`, undefined, {
|
||||
revalidate: CACHE_TTL_LONG,
|
||||
});
|
||||
try {
|
||||
const resp = await this.fetch<{ totalElements: number }>(
|
||||
"books",
|
||||
{
|
||||
library_id: lib.id,
|
||||
size: "0",
|
||||
},
|
||||
{ revalidate: CACHE_TTL_LONG }
|
||||
);
|
||||
return KomgaAdapter.toNormalizedLibrary({
|
||||
...lib,
|
||||
booksCount: resp.totalElements,
|
||||
booksReadCount: 0,
|
||||
});
|
||||
} catch {
|
||||
return KomgaAdapter.toNormalizedLibrary({ ...lib, booksCount: 0, booksReadCount: 0 });
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const libraries = await this.getLibraries();
|
||||
return libraries.find((lib) => lib.id === libraryId) ?? null;
|
||||
}
|
||||
|
||||
async getNextBook(bookId: string): Promise<NormalizedBook | null> {
|
||||
@@ -398,7 +373,14 @@ export class KomgaProvider implements IMediaProvider {
|
||||
}
|
||||
|
||||
async getHomeData(): Promise<HomeData> {
|
||||
const homeOpts = { revalidate: CACHE_TTL_MED, tags: [HOME_CACHE_TAG] };
|
||||
return unstable_cache(
|
||||
() => this.fetchHomeData(),
|
||||
["komga-home", this.config.authHeader],
|
||||
{ revalidate: CACHE_TTL_MED, tags: [HOME_CACHE_TAG] }
|
||||
)();
|
||||
}
|
||||
|
||||
private async fetchHomeData(): Promise<HomeData> {
|
||||
const [ongoing, ongoingBooks, recentlyRead, onDeck, latestSeries] = await Promise.all([
|
||||
this.fetch<LibraryResponse<KomgaSeries>>(
|
||||
"series/list",
|
||||
@@ -408,7 +390,6 @@ export class KomgaProvider implements IMediaProvider {
|
||||
body: JSON.stringify({
|
||||
condition: { readStatus: { operator: "is", value: "IN_PROGRESS" } },
|
||||
}),
|
||||
...homeOpts,
|
||||
}
|
||||
).catch(() => ({ content: [] as KomgaSeries[] })),
|
||||
this.fetch<LibraryResponse<KomgaBook>>(
|
||||
@@ -419,23 +400,19 @@ export class KomgaProvider implements IMediaProvider {
|
||||
body: JSON.stringify({
|
||||
condition: { readStatus: { operator: "is", value: "IN_PROGRESS" } },
|
||||
}),
|
||||
...homeOpts,
|
||||
}
|
||||
).catch(() => ({ content: [] as KomgaBook[] })),
|
||||
this.fetch<LibraryResponse<KomgaBook>>(
|
||||
"books/latest",
|
||||
{ page: "0", size: "10", media_status: "READY" },
|
||||
{ ...homeOpts }
|
||||
{ page: "0", size: "10", media_status: "READY" }
|
||||
).catch(() => ({ content: [] as KomgaBook[] })),
|
||||
this.fetch<LibraryResponse<KomgaBook>>(
|
||||
"books/ondeck",
|
||||
{ page: "0", size: "10", media_status: "READY" },
|
||||
{ ...homeOpts }
|
||||
{ page: "0", size: "10", media_status: "READY" }
|
||||
).catch(() => ({ content: [] as KomgaBook[] })),
|
||||
this.fetch<LibraryResponse<KomgaSeries>>(
|
||||
"series/latest",
|
||||
{ page: "0", size: "10", media_status: "READY" },
|
||||
{ ...homeOpts }
|
||||
{ page: "0", size: "10", media_status: "READY" }
|
||||
).catch(() => ({ content: [] as KomgaSeries[] })),
|
||||
]);
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentUser } from "@/lib/auth-utils";
|
||||
import { getResolvedStripstreamConfig } from "./stripstream/stripstream-config-resolver";
|
||||
import type { IMediaProvider } from "./provider.interface";
|
||||
|
||||
export async function getProvider(): Promise<IMediaProvider | null> {
|
||||
@@ -13,7 +14,7 @@ export async function getProvider(): Promise<IMediaProvider | null> {
|
||||
select: {
|
||||
activeProvider: true,
|
||||
config: { select: { url: true, authHeader: true } },
|
||||
stripstreamConfig: { select: { url: true, token: true } },
|
||||
stripstreamConfig: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -21,12 +22,12 @@ export async function getProvider(): Promise<IMediaProvider | null> {
|
||||
|
||||
const activeProvider = dbUser.activeProvider ?? "komga";
|
||||
|
||||
if (activeProvider === "stripstream" && dbUser.stripstreamConfig) {
|
||||
const { StripstreamProvider } = await import("./stripstream/stripstream.provider");
|
||||
return new StripstreamProvider(
|
||||
dbUser.stripstreamConfig.url,
|
||||
dbUser.stripstreamConfig.token
|
||||
);
|
||||
if (activeProvider === "stripstream") {
|
||||
const resolved = await getResolvedStripstreamConfig(userId);
|
||||
if (resolved) {
|
||||
const { StripstreamProvider } = await import("./stripstream/stripstream.provider");
|
||||
return new StripstreamProvider(resolved.url, resolved.token);
|
||||
}
|
||||
}
|
||||
|
||||
if (activeProvider === "komga" || !dbUser.activeProvider) {
|
||||
|
||||
26
src/lib/providers/stripstream/stripstream-config-resolver.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
export interface ResolvedStripstreamConfig {
|
||||
url: string;
|
||||
token: string;
|
||||
source: "db" | "env";
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout la config Stripstream : d'abord en base (par utilisateur), sinon depuis les env STRIPSTREAM_URL et STRIPSTREAM_TOKEN.
|
||||
*/
|
||||
export async function getResolvedStripstreamConfig(
|
||||
userId: number
|
||||
): Promise<ResolvedStripstreamConfig | null> {
|
||||
const fromDb = await prisma.stripstreamConfig.findUnique({
|
||||
where: { userId },
|
||||
select: { url: true, token: true },
|
||||
});
|
||||
if (fromDb) return { ...fromDb, source: "db" };
|
||||
|
||||
const url = process.env.STRIPSTREAM_URL?.trim();
|
||||
const token = process.env.STRIPSTREAM_TOKEN?.trim();
|
||||
if (url && token) return { url, token, source: "env" };
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -34,11 +34,14 @@ export class StripstreamAdapter {
|
||||
volume: book.volume ?? null,
|
||||
pageCount: book.page_count ?? 0,
|
||||
thumbnailUrl: `/api/stripstream/images/books/${book.id}/thumbnail`,
|
||||
readProgress: {
|
||||
page: book.reading_current_page ?? null,
|
||||
completed: book.reading_status === "read",
|
||||
lastReadAt: book.reading_last_read_at ?? null,
|
||||
},
|
||||
readProgress:
|
||||
book.reading_status === "unread" && !book.reading_current_page
|
||||
? null
|
||||
: {
|
||||
page: book.reading_current_page ?? null,
|
||||
completed: book.reading_status === "read",
|
||||
lastReadAt: book.reading_last_read_at ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,11 +55,14 @@ export class StripstreamAdapter {
|
||||
volume: book.volume ?? null,
|
||||
pageCount: book.page_count ?? 0,
|
||||
thumbnailUrl: `/api/stripstream/images/books/${book.id}/thumbnail`,
|
||||
readProgress: {
|
||||
page: book.reading_current_page ?? null,
|
||||
completed: book.reading_status === "read",
|
||||
lastReadAt: book.reading_last_read_at ?? null,
|
||||
},
|
||||
readProgress:
|
||||
book.reading_status === "unread" && !book.reading_current_page
|
||||
? null
|
||||
: {
|
||||
page: book.reading_current_page ?? null,
|
||||
completed: book.reading_status === "read",
|
||||
lastReadAt: book.reading_last_read_at ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,11 +73,13 @@ export class StripstreamAdapter {
|
||||
bookCount: series.book_count,
|
||||
booksReadCount: series.books_read_count,
|
||||
thumbnailUrl: `/api/stripstream/images/books/${series.first_book_id}/thumbnail`,
|
||||
libraryId: series.library_id,
|
||||
summary: null,
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
createdAt: null,
|
||||
missingCount: series.missing_count ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
const TIMEOUT_MS = 15000;
|
||||
const IMAGE_TIMEOUT_MS = 60000;
|
||||
|
||||
interface FetchErrorLike { code?: string; cause?: { code?: string } }
|
||||
|
||||
@@ -59,8 +60,14 @@ export class StripstreamClient {
|
||||
"🔵 Stripstream Request"
|
||||
);
|
||||
}
|
||||
if (isCacheDebug && options.revalidate) {
|
||||
logger.info({ url, cache: "enabled", ttl: options.revalidate }, "💾 Cache enabled");
|
||||
if (isCacheDebug) {
|
||||
if (options.tags) {
|
||||
logger.info({ url, cache: "tags", tags: options.tags }, "💾 Cache tags");
|
||||
} else if (options.revalidate !== undefined) {
|
||||
logger.info({ url, cache: "revalidate", ttl: options.revalidate }, "💾 Cache revalidate");
|
||||
} else {
|
||||
logger.info({ url, cache: "none" }, "💾 Cache none");
|
||||
}
|
||||
}
|
||||
|
||||
const nextOptions = options.tags
|
||||
@@ -106,10 +113,6 @@ export class StripstreamClient {
|
||||
"🟢 Stripstream Response"
|
||||
);
|
||||
}
|
||||
if (isCacheDebug && options.revalidate) {
|
||||
const cacheStatus = response.headers.get("x-nextjs-cache") ?? "UNKNOWN";
|
||||
logger.info({ url, cacheStatus }, `💾 Cache ${cacheStatus}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (isDebug) {
|
||||
@@ -147,7 +150,7 @@ export class StripstreamClient {
|
||||
Accept: "image/webp, image/jpeg, image/png, */*",
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
const timeoutId = setTimeout(() => controller.abort(), IMAGE_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(url, { headers, signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -12,15 +12,16 @@ import type {
|
||||
import type { HomeData } from "@/types/home";
|
||||
import { StripstreamClient } from "./stripstream.client";
|
||||
import { StripstreamAdapter } from "./stripstream.adapter";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import type {
|
||||
StripstreamLibraryResponse,
|
||||
StripstreamBooksPage,
|
||||
StripstreamSeriesPage,
|
||||
StripstreamBookItem,
|
||||
StripstreamSeriesItem,
|
||||
StripstreamBookDetails,
|
||||
StripstreamReadingProgressResponse,
|
||||
StripstreamSearchResponse,
|
||||
StripstreamSeriesMetadata,
|
||||
} from "@/types/stripstream";
|
||||
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG, SERIES_BOOKS_CACHE_TAG } from "@/constants/cacheConstants";
|
||||
|
||||
@@ -84,18 +85,25 @@ export class StripstreamProvider implements IMediaProvider {
|
||||
revalidate: CACHE_TTL_MED,
|
||||
});
|
||||
if (!book.series) return null;
|
||||
return {
|
||||
|
||||
// Try to find series in library to get real book counts
|
||||
const seriesInfo = await this.findSeriesByName(book.series, book.library_id);
|
||||
if (seriesInfo) return seriesInfo;
|
||||
|
||||
const fallback: NormalizedSeries = {
|
||||
id: seriesId,
|
||||
name: book.series,
|
||||
bookCount: 0,
|
||||
booksReadCount: 0,
|
||||
thumbnailUrl: `/api/stripstream/images/books/${seriesId}/thumbnail`,
|
||||
libraryId: book.library_id,
|
||||
summary: null,
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
createdAt: null,
|
||||
};
|
||||
return this.enrichSeriesWithMetadata(fallback, book.library_id, book.series);
|
||||
} catch {
|
||||
// Fall back: treat seriesId as a series name, find its first book
|
||||
try {
|
||||
@@ -106,24 +114,81 @@ export class StripstreamProvider implements IMediaProvider {
|
||||
);
|
||||
if (!page.items.length) return null;
|
||||
const firstBook = page.items[0];
|
||||
return {
|
||||
|
||||
const seriesInfo = await this.findSeriesByName(seriesId, firstBook.library_id);
|
||||
if (seriesInfo) return seriesInfo;
|
||||
|
||||
const fallback: NormalizedSeries = {
|
||||
id: firstBook.id,
|
||||
name: seriesId,
|
||||
bookCount: 0,
|
||||
booksReadCount: 0,
|
||||
thumbnailUrl: `/api/stripstream/images/books/${firstBook.id}/thumbnail`,
|
||||
libraryId: firstBook.library_id,
|
||||
summary: null,
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
createdAt: null,
|
||||
};
|
||||
return this.enrichSeriesWithMetadata(fallback, firstBook.library_id, seriesId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async findSeriesByName(seriesName: string, libraryId: string): Promise<NormalizedSeries | null> {
|
||||
try {
|
||||
const seriesPage = await this.client.fetch<StripstreamSeriesPage>(
|
||||
`libraries/${libraryId}/series`,
|
||||
{ q: seriesName, limit: "10" },
|
||||
{ revalidate: CACHE_TTL_MED }
|
||||
);
|
||||
const match = seriesPage.items.find((s) => s.name === seriesName);
|
||||
if (match) {
|
||||
const normalized = StripstreamAdapter.toNormalizedSeries(match);
|
||||
return this.enrichSeriesWithMetadata(normalized, libraryId, seriesName);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async enrichSeriesWithMetadata(
|
||||
series: NormalizedSeries,
|
||||
libraryId: string,
|
||||
seriesName: string
|
||||
): Promise<NormalizedSeries> {
|
||||
try {
|
||||
const metadata = await this.client.fetch<StripstreamSeriesMetadata>(
|
||||
`libraries/${libraryId}/series/${encodeURIComponent(seriesName)}/metadata`,
|
||||
undefined,
|
||||
{ revalidate: CACHE_TTL_MED }
|
||||
);
|
||||
return {
|
||||
...series,
|
||||
summary: metadata.description ?? null,
|
||||
authors: metadata.authors.map((name) => ({ name, role: "writer" })),
|
||||
};
|
||||
} catch (error) {
|
||||
return series;
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveSeriesInfo(seriesId: string): Promise<{ libraryId: string; seriesName: string } | null> {
|
||||
try {
|
||||
const book = await this.client.fetch<StripstreamBookDetails>(`books/${seriesId}`, undefined, {
|
||||
revalidate: CACHE_TTL_MED,
|
||||
});
|
||||
if (!book.series) return null;
|
||||
return { libraryId: book.library_id, seriesName: book.series };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getBooks(filter: BookListFilter): Promise<NormalizedBooksPage> {
|
||||
const limit = filter.limit ?? 24;
|
||||
const params: Record<string, string | undefined> = { limit: String(limit) };
|
||||
@@ -195,36 +260,36 @@ export class StripstreamProvider implements IMediaProvider {
|
||||
}
|
||||
|
||||
async getHomeData(): Promise<HomeData> {
|
||||
// Stripstream has no "in-progress" filter — show recent books and first library's series
|
||||
const homeOpts = { revalidate: CACHE_TTL_MED, tags: [HOME_CACHE_TAG] };
|
||||
const [booksPage, libraries] = await Promise.allSettled([
|
||||
this.client.fetch<StripstreamBooksPage>("books", { limit: "10" }, homeOpts),
|
||||
this.client.fetch<StripstreamLibraryResponse[]>("libraries", undefined, { revalidate: CACHE_TTL_LONG, tags: [HOME_CACHE_TAG] }),
|
||||
const [ongoingBooksResult, ongoingSeriesResult, booksPage, latestSeriesResult] = await Promise.allSettled([
|
||||
this.client.fetch<StripstreamBookItem[]>("books/ongoing", { limit: "20" }, homeOpts),
|
||||
this.client.fetch<StripstreamSeriesItem[]>("series/ongoing", { limit: "10" }, homeOpts),
|
||||
this.client.fetch<StripstreamBooksPage>("books", { sort: "latest", limit: "10" }, homeOpts),
|
||||
this.client.fetch<StripstreamSeriesPage>("series", { sort: "latest", limit: "10" }, homeOpts),
|
||||
]);
|
||||
|
||||
const books = booksPage.status === "fulfilled"
|
||||
// /books/ongoing returns both currently reading and next unread per series
|
||||
const ongoingBooks = ongoingBooksResult.status === "fulfilled"
|
||||
? ongoingBooksResult.value.map(StripstreamAdapter.toNormalizedBook)
|
||||
: [];
|
||||
|
||||
const ongoingSeries = ongoingSeriesResult.status === "fulfilled"
|
||||
? ongoingSeriesResult.value.map(StripstreamAdapter.toNormalizedSeries)
|
||||
: [];
|
||||
|
||||
const recentlyRead = booksPage.status === "fulfilled"
|
||||
? booksPage.value.items.map(StripstreamAdapter.toNormalizedBook)
|
||||
: [];
|
||||
|
||||
let latestSeries: NormalizedSeries[] = [];
|
||||
if (libraries.status === "fulfilled" && libraries.value.length > 0) {
|
||||
try {
|
||||
const seriesPage = await this.client.fetch<StripstreamSeriesPage>(
|
||||
`libraries/${libraries.value[0].id}/series`,
|
||||
{ limit: "10" },
|
||||
homeOpts
|
||||
);
|
||||
latestSeries = seriesPage.items.map(StripstreamAdapter.toNormalizedSeries);
|
||||
} catch {
|
||||
latestSeries = [];
|
||||
}
|
||||
}
|
||||
const latestSeries = latestSeriesResult.status === "fulfilled"
|
||||
? latestSeriesResult.value.items.map(StripstreamAdapter.toNormalizedSeries)
|
||||
: [];
|
||||
|
||||
return {
|
||||
ongoing: latestSeries,
|
||||
ongoingBooks: books,
|
||||
recentlyRead: books,
|
||||
onDeck: [],
|
||||
ongoing: ongoingSeries,
|
||||
ongoingBooks: [],
|
||||
recentlyRead,
|
||||
onDeck: ongoingBooks,
|
||||
latestSeries,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,12 +18,14 @@ export interface NormalizedSeries {
|
||||
bookCount: number;
|
||||
booksReadCount: number;
|
||||
thumbnailUrl: string;
|
||||
libraryId?: string;
|
||||
// Optional metadata (Komga-rich, Stripstream-sparse)
|
||||
summary?: string | null;
|
||||
authors?: Array<{ name: string; role: string }>;
|
||||
genres?: string[];
|
||||
tags?: string[];
|
||||
createdAt?: string | null;
|
||||
missingCount?: number | null;
|
||||
}
|
||||
|
||||
export interface NormalizedBook {
|
||||
|
||||
@@ -39,7 +39,7 @@ export class ConfigDBService {
|
||||
|
||||
return config as KomgaConfig;
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
if (error instanceof AppError) {
|
||||
throw error;
|
||||
}
|
||||
throw new AppError(ERROR_CODES.CONFIG.SAVE_ERROR, {}, error);
|
||||
|
||||
@@ -34,6 +34,7 @@ export class PreferencesService {
|
||||
return {
|
||||
showThumbnails: preferences.showThumbnails,
|
||||
showOnlyUnread: preferences.showOnlyUnread,
|
||||
anonymousMode: preferences.anonymousMode,
|
||||
displayMode: {
|
||||
...defaultPreferences.displayMode,
|
||||
...displayMode,
|
||||
@@ -72,6 +73,8 @@ export class PreferencesService {
|
||||
}
|
||||
if (preferences.readerPrefetchCount !== undefined)
|
||||
updateData.readerPrefetchCount = preferences.readerPrefetchCount;
|
||||
if (preferences.anonymousMode !== undefined)
|
||||
updateData.anonymousMode = preferences.anonymousMode;
|
||||
|
||||
const updatedPreferences = await prisma.preferences.upsert({
|
||||
where: { userId },
|
||||
@@ -80,6 +83,7 @@ export class PreferencesService {
|
||||
userId,
|
||||
showThumbnails: preferences.showThumbnails ?? defaultPreferences.showThumbnails,
|
||||
showOnlyUnread: preferences.showOnlyUnread ?? defaultPreferences.showOnlyUnread,
|
||||
anonymousMode: preferences.anonymousMode ?? defaultPreferences.anonymousMode,
|
||||
displayMode: preferences.displayMode ?? defaultPreferences.displayMode,
|
||||
background: (preferences.background ??
|
||||
defaultPreferences.background) as unknown as Prisma.InputJsonValue,
|
||||
@@ -90,6 +94,7 @@ export class PreferencesService {
|
||||
return {
|
||||
showThumbnails: updatedPreferences.showThumbnails,
|
||||
showOnlyUnread: updatedPreferences.showOnlyUnread,
|
||||
anonymousMode: updatedPreferences.anonymousMode,
|
||||
displayMode: updatedPreferences.displayMode as UserPreferences["displayMode"],
|
||||
background: {
|
||||
...defaultPreferences.background,
|
||||
|
||||
@@ -118,11 +118,14 @@ body.no-pinch-zoom * {
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
/* Empêche le zoom automatique iOS sur les inputs */
|
||||
}
|
||||
|
||||
/* Empêche le zoom automatique iOS sur les inputs (hors @layer pour surcharger text-sm) */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-size: 16px;
|
||||
font-size: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
4
src/types/env.d.ts
vendored
@@ -3,5 +3,9 @@ declare namespace NodeJS {
|
||||
NEXT_PUBLIC_APP_URL: string;
|
||||
NEXT_PUBLIC_DEFAULT_KOMGA_URL?: string;
|
||||
NEXT_PUBLIC_APP_VERSION: string;
|
||||
/** URL Stripstream Librarian (fallback si pas de config en base) */
|
||||
STRIPSTREAM_URL?: string;
|
||||
/** Token API Stripstream (fallback si pas de config en base) */
|
||||
STRIPSTREAM_TOKEN?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NormalizedBook, NormalizedSeries } from "@/lib/providers/types";
|
||||
|
||||
export interface HomeData {
|
||||
favorites?: NormalizedSeries[];
|
||||
ongoing: NormalizedSeries[];
|
||||
ongoingBooks: NormalizedBook[];
|
||||
recentlyRead: NormalizedBook[];
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface BackgroundPreferences {
|
||||
export interface UserPreferences {
|
||||
showThumbnails: boolean;
|
||||
showOnlyUnread: boolean;
|
||||
anonymousMode: boolean;
|
||||
displayMode: {
|
||||
compact: boolean;
|
||||
itemsPerPage: number;
|
||||
@@ -24,6 +25,7 @@ export interface UserPreferences {
|
||||
export const defaultPreferences: UserPreferences = {
|
||||
showThumbnails: true,
|
||||
showOnlyUnread: false,
|
||||
anonymousMode: false,
|
||||
displayMode: {
|
||||
compact: false,
|
||||
itemsPerPage: 20,
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface StripstreamSeriesItem {
|
||||
book_count: number;
|
||||
books_read_count: number;
|
||||
first_book_id: string;
|
||||
library_id: string;
|
||||
missing_count?: number | null;
|
||||
}
|
||||
|
||||
export interface StripstreamSeriesPage {
|
||||
@@ -80,6 +82,15 @@ export interface StripstreamUpdateReadingProgressRequest {
|
||||
current_page?: number | null;
|
||||
}
|
||||
|
||||
export interface StripstreamSeriesMetadata {
|
||||
authors: string[];
|
||||
publishers: string[];
|
||||
description?: string | null;
|
||||
start_year?: number | null;
|
||||
book_author?: string | null;
|
||||
book_language?: string | null;
|
||||
}
|
||||
|
||||
export interface StripstreamSearchResponse {
|
||||
hits: StripstreamSearchHit[];
|
||||
series_hits: StripstreamSeriesHit[];
|
||||
|
||||