Compare commits
48 Commits
b8961b85c5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| acb12b946e | |||
| d9ffacc124 | |||
| 8cdbebaafb | |||
| c5da33d6b2 | |||
| a82ce024ee | |||
| f48d894eca | |||
| a1a986f462 | |||
| 894ea7114c | |||
| 32757a8723 | |||
| 11da2335cd | |||
| feceb61e30 | |||
| 701a02b55c | |||
| b2664cce08 | |||
| ff44a781c8 | |||
| d535f9f28e | |||
| 2174579cc1 | |||
| e6eab32473 | |||
| 86b7382a04 | |||
| 53af9db046 | |||
| 1d03cfc177 | |||
| b0d56948a3 | |||
| fc9c220be6 | |||
| 100d8b37e7 | |||
| f9651676a5 | |||
| 539bb34716 | |||
| 8d1f91d636 | |||
| 7e4c48469a | |||
| e74b02e3a2 | |||
| 7d0f1c4457 | |||
| a1a95775db | |||
| 3d7ac0c13e | |||
| 818fe67c99 | |||
| 06848d2c3a | |||
| 4e8c8ebac0 | |||
| 23fa884af7 | |||
| 6a06e5a7d3 | |||
| 3e5687441d | |||
| 99d9f41299 | |||
| 30e3529be3 | |||
| 4288e4c541 | |||
| fdc9da7f8f | |||
| 4441c59584 | |||
| fead5ff6a0 | |||
| e6fe5ac27f | |||
| c704e24a53 | |||
| 5a3b0ace61 | |||
| 844cd3f58e | |||
| 6a1f208e66 |
@@ -6,3 +6,7 @@ MONGODB_URI=mongodb://admin:password@host.docker.internal:27017/stripstream?auth
|
|||||||
NEXTAUTH_SECRET=SECRET
|
NEXTAUTH_SECRET=SECRET
|
||||||
#openssl rand -base64 32
|
#openssl rand -base64 32
|
||||||
NEXTAUTH_URL=http://localhost:3000
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Stripstream Librarian (optionnel : fallback si l'utilisateur n'a pas sauvegardé d'URL/token en base)
|
||||||
|
# STRIPSTREAM_URL=https://librarian.example.com
|
||||||
|
# STRIPSTREAM_TOKEN=stl_xxxx_xxxxxxxx
|
||||||
@@ -1,26 +1,33 @@
|
|||||||
name: Deploy with Docker Compose
|
name: Build, Push & Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main # adapte la branche que tu veux déployer
|
- main
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: mac-orbstack-runner # le nom que tu as donné au runner
|
runs-on: mac-orbstack-runner
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Deploy stack
|
- name: Login to DockerHub
|
||||||
|
run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
env:
|
env:
|
||||||
DOCKER_BUILDKIT: 1
|
DOCKER_BUILDKIT: 1
|
||||||
COMPOSE_DOCKER_CLI_BUILD: 1
|
run: docker build -t julienfroidefond32/stripstream:latest .
|
||||||
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
|
|
||||||
NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL }}
|
- name: Push to DockerHub
|
||||||
ADMIN_DEFAULT_PASSWORD: ${{ secrets.ADMIN_DEFAULT_PASSWORD }}
|
run: docker push julienfroidefond32/stripstream:latest
|
||||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
|
||||||
PRISMA_DATA_PATH: ${{ vars.PRISMA_DATA_PATH }}
|
- name: Pull new image and restart container
|
||||||
NODE_ENV: production
|
|
||||||
run: |
|
run: |
|
||||||
docker compose up -d --build
|
docker pull julienfroidefond32/stripstream:latest
|
||||||
|
cd /Users/julienfroidefond/Sites/docker-stack
|
||||||
|
./scripts/stack.sh up stripstream
|
||||||
|
|
||||||
|
- name: Cleanup old images
|
||||||
|
run: docker image prune -f
|
||||||
|
|||||||
52
AGENTS.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
- `src/app/`: Next.js App Router pages, layouts, API routes, and server actions.
|
||||||
|
- `src/components/`: UI and feature components (`home/`, `reader/`, `layout/`, `ui/`).
|
||||||
|
- `src/lib/`: shared services (Komga/API access), auth, logger, utilities.
|
||||||
|
- `src/hooks/`, `src/contexts/`, `src/types/`, `src/constants/`: reusable runtime logic and typing.
|
||||||
|
- `src/i18n/messages/{en,fr}/`: translation dictionaries.
|
||||||
|
- `prisma/`: database schema and Prisma artifacts.
|
||||||
|
- `public/`: static files and PWA assets.
|
||||||
|
- `scripts/`: maintenance scripts (DB init, admin password reset, icon generation).
|
||||||
|
- `docs/` and `devbook.md`: implementation notes and architecture decisions.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
Use `pnpm` (lockfile and `packageManager` are configured for it).
|
||||||
|
- `pnpm dev`: start local dev server.
|
||||||
|
- `pnpm build`: create production build.
|
||||||
|
- `pnpm start`: run production server.
|
||||||
|
- `pnpm lint`: run ESLint across the repo.
|
||||||
|
- `pnpm typecheck` or `pnpm -s tsc --noEmit`: strict TypeScript checks.
|
||||||
|
- `pnpm init-db`: initialize database data.
|
||||||
|
- `pnpm reset-admin-password`: reset admin credentials.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
- Language: TypeScript (`.ts/.tsx`) with React function components.
|
||||||
|
- Architecture priority: **server-first**. Default to React Server Components (RSC) for pages and feature composition.
|
||||||
|
- Data mutations: prefer **Server Actions** (`src/app/actions/`) over client-side fetch patterns when possible.
|
||||||
|
- Client components (`"use client"`): use only for browser-only concerns (event handlers, local UI state, effects, DOM APIs).
|
||||||
|
- Data fetching: do it on the server first (`page.tsx`, server components, services in `src/lib/services`), then pass serialized props down.
|
||||||
|
- Indentation: 2 spaces; keep imports grouped and sorted logically.
|
||||||
|
- Components/hooks/services: `PascalCase` for components, `camelCase` for hooks/functions, `*.service.ts` for service modules.
|
||||||
|
- Styling: Tailwind utility classes; prefer existing `src/components/ui` primitives before creating new ones.
|
||||||
|
- Quality gates: ESLint (`eslint.config.mjs`) + TypeScript must pass before merge.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
- No dedicated unit test framework is currently configured.
|
||||||
|
- Minimum validation for each change: `pnpm lint` and `pnpm typecheck`.
|
||||||
|
- For UI changes, perform a quick manual smoke test on affected routes (home, libraries, series, reader) and both themes.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
- Follow Conventional Commit style seen in history: `fix: ...`, `refactor: ...`, `feat: ...`.
|
||||||
|
- Keep subjects imperative and specific (e.g., `fix: reduce header/home spacing overlap`).
|
||||||
|
- PRs should include:
|
||||||
|
- short problem/solution summary,
|
||||||
|
- linked issue (if any),
|
||||||
|
- screenshots or short video for UI updates,
|
||||||
|
- verification steps/commands run.
|
||||||
|
|
||||||
|
## Security & Configuration Tips
|
||||||
|
- Never commit secrets; use `.env` based on `.env.example`.
|
||||||
|
- Validate Komga and auth-related config through settings flows before merging.
|
||||||
|
- Prefer server-side data fetching/services for sensitive operations.
|
||||||
23
Dockerfile
@@ -17,7 +17,7 @@ COPY package.json pnpm-lock.yaml ./
|
|||||||
COPY prisma ./prisma
|
COPY prisma ./prisma
|
||||||
|
|
||||||
# Copy configuration files
|
# Copy configuration files
|
||||||
COPY tsconfig.json .eslintrc.json ./
|
COPY tsconfig.json .eslintrc.json next.config.js ./
|
||||||
COPY tailwind.config.ts postcss.config.js ./
|
COPY tailwind.config.ts postcss.config.js ./
|
||||||
|
|
||||||
# Install dependencies with pnpm using cache mount for store
|
# Install dependencies with pnpm using cache mount for store
|
||||||
@@ -43,22 +43,20 @@ WORKDIR /app
|
|||||||
# Install OpenSSL (required by Prisma)
|
# Install OpenSSL (required by Prisma)
|
||||||
RUN apk add --no-cache openssl libc6-compat
|
RUN apk add --no-cache openssl libc6-compat
|
||||||
|
|
||||||
# Copy package files and prisma schema
|
# Copy standalone output (server.js + minimal node_modules)
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
COPY prisma ./prisma
|
|
||||||
|
|
||||||
# Enable pnpm
|
# Copy static assets and public directory
|
||||||
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
# Copy the entire node_modules from builder (includes Prisma Client)
|
# Copy full node_modules for Prisma CLI (pnpm symlinks prevent cherry-picking)
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
# Copy built application from builder stage
|
# Copy prisma schema and init scripts
|
||||||
COPY --from=builder /app/.next ./.next
|
COPY prisma ./prisma
|
||||||
COPY --from=builder /app/public ./public
|
|
||||||
COPY --from=builder /app/next-env.d.ts ./
|
|
||||||
COPY --from=builder /app/tailwind.config.ts ./
|
|
||||||
COPY --from=builder /app/scripts ./scripts
|
COPY --from=builder /app/scripts ./scripts
|
||||||
|
COPY package.json ./
|
||||||
|
|
||||||
# Copy entrypoint script
|
# Copy entrypoint script
|
||||||
COPY docker-entrypoint.sh ./
|
COPY docker-entrypoint.sh ./
|
||||||
@@ -76,6 +74,7 @@ USER nextjs
|
|||||||
# Set environment variables
|
# Set environment variables
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
# Expose the port the app runs on
|
# Expose the port the app runs on
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
68
README.md
@@ -74,7 +74,7 @@ A modern web application for reading digital comics, built with Next.js 14 and t
|
|||||||
## 🛠 Prerequisites
|
## 🛠 Prerequisites
|
||||||
|
|
||||||
- Node.js 20.x or higher
|
- Node.js 20.x or higher
|
||||||
- Yarn 1.22.x or higher
|
- pnpm 9.x or higher
|
||||||
- Docker and Docker Compose (optional)
|
- Docker and Docker Compose (optional)
|
||||||
|
|
||||||
## 📦 Installation
|
## 📦 Installation
|
||||||
@@ -91,7 +91,7 @@ cd stripstream
|
|||||||
2. Install dependencies
|
2. Install dependencies
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Copy the example environment file and adjust it to your needs
|
3. Copy the example environment file and adjust it to your needs
|
||||||
@@ -103,10 +103,10 @@ cp .env.example .env.local
|
|||||||
4. Start the development server
|
4. Start the development server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### With Docker
|
### With Docker (Build Local)
|
||||||
|
|
||||||
1. Clone the repository and navigate to the folder
|
1. Clone the repository and navigate to the folder
|
||||||
|
|
||||||
@@ -121,15 +121,65 @@ cd stripstream
|
|||||||
docker-compose up --build
|
docker-compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The application will be accessible at `http://localhost:3020`
|
||||||
|
|
||||||
|
### With Docker (DockerHub Image)
|
||||||
|
|
||||||
|
You can also use the pre-built image from DockerHub without cloning the repository:
|
||||||
|
|
||||||
|
1. Create a `docker-compose.yml` file:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: julienfroidefond32/stripstream:latest
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
# Required
|
||||||
|
- NEXTAUTH_SECRET=your_secret_here # openssl rand -base64 32
|
||||||
|
- NEXTAUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Optional — defaults shown
|
||||||
|
# - NODE_ENV=production
|
||||||
|
# - DATABASE_URL=file:/app/prisma/data/stripstream.db
|
||||||
|
# - ADMIN_DEFAULT_PASSWORD=Admin@2025
|
||||||
|
# - AUTH_TRUST_HOST=true
|
||||||
|
# - KOMGA_MAX_CONCURRENT_REQUESTS=5
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/prisma/data
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run the container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
The application will be accessible at `http://localhost:3000`
|
The application will be accessible at `http://localhost:3000`
|
||||||
|
|
||||||
## 🔧 Available Scripts
|
## 🔧 Available Scripts
|
||||||
|
|
||||||
- `yarn dev` - Starts the development server
|
- `pnpm dev` - Starts the development server
|
||||||
- `yarn build` - Creates a production build
|
- `pnpm build` - Creates a production build
|
||||||
- `yarn start` - Runs the production version
|
- `pnpm start` - Runs the production version
|
||||||
- `yarn lint` - Checks code with ESLint
|
- `pnpm lint` - Checks code with ESLint
|
||||||
- `yarn format` - Formats code with Prettier
|
- `./docker-push.sh [tag]` - Build and push Docker image to DockerHub (default tag: `latest`)
|
||||||
|
|
||||||
|
### Docker Push Script
|
||||||
|
|
||||||
|
The `docker-push.sh` script automates building and pushing the Docker image to DockerHub:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Push with 'latest' tag
|
||||||
|
./docker-push.sh
|
||||||
|
|
||||||
|
# Push with a specific version tag
|
||||||
|
./docker-push.sh v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prerequisite:** You must be logged in to DockerHub (`docker login`) before running the script.
|
||||||
|
|
||||||
## 🌐 Komga API
|
## 🌐 Komga API
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ version: "3.8"
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
stripstream-app:
|
stripstream-app:
|
||||||
|
image: julienfroidefond32/stripstream:latest
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "📁 Ensuring data directory exists..."
|
echo "🔄 Applying database migrations..."
|
||||||
mkdir -p /app/data
|
./node_modules/.bin/prisma migrate deploy
|
||||||
|
|
||||||
echo "🔄 Pushing Prisma schema to database..."
|
|
||||||
npx prisma db push --skip-generate --accept-data-loss
|
|
||||||
|
|
||||||
echo "🔧 Initializing database..."
|
echo "🔧 Initializing database..."
|
||||||
node scripts/init-db.mjs
|
node scripts/init-db.mjs
|
||||||
|
|
||||||
echo "🚀 Starting application..."
|
echo "🚀 Starting application..."
|
||||||
exec pnpm start
|
exec node server.js
|
||||||
|
|
||||||
|
|||||||
24
docker-push.sh
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script pour builder et push l'image Docker vers DockerHub
|
||||||
|
# Usage: ./docker-push.sh [tag]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DOCKER_USERNAME="julienfroidefond32"
|
||||||
|
IMAGE_NAME="stripstream"
|
||||||
|
|
||||||
|
# Utiliser le tag fourni ou 'latest' par défaut
|
||||||
|
TAG=${1:-latest}
|
||||||
|
|
||||||
|
FULL_IMAGE_NAME="$DOCKER_USERNAME/$IMAGE_NAME:$TAG"
|
||||||
|
|
||||||
|
echo "=== Building Docker image: $FULL_IMAGE_NAME ==="
|
||||||
|
docker build -t $FULL_IMAGE_NAME .
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Pushing to DockerHub: $FULL_IMAGE_NAME ==="
|
||||||
|
docker push $FULL_IMAGE_NAME
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Successfully pushed: $FULL_IMAGE_NAME ==="
|
||||||
@@ -4,6 +4,7 @@ import nextTypescript from "eslint-config-next/typescript";
|
|||||||
import unusedImports from "eslint-plugin-unused-imports";
|
import unusedImports from "eslint-plugin-unused-imports";
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
|
{ ignores: ["temp/**", ".next/**", "node_modules/**"] },
|
||||||
...nextCoreWebVitals,
|
...nextCoreWebVitals,
|
||||||
...nextTypescript,
|
...nextTypescript,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
output: "standalone",
|
||||||
webpack: (config) => {
|
webpack: (config) => {
|
||||||
config.resolve.fallback = {
|
config.resolve.fallback = {
|
||||||
...config.resolve.fallback,
|
...config.resolve.fallback,
|
||||||
|
|||||||
77
prisma/migrations/20260311203728_init/migration.sql
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"password" TEXT NOT NULL,
|
||||||
|
"roles" JSONB NOT NULL DEFAULT ["ROLE_USER"],
|
||||||
|
"authenticated" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"activeProvider" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "komgaconfigs" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"username" TEXT NOT NULL,
|
||||||
|
"authHeader" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "komgaconfigs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "stripstreamconfigs" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "stripstreamconfigs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "preferences" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"showThumbnails" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"showOnlyUnread" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"displayMode" JSONB NOT NULL,
|
||||||
|
"background" JSONB NOT NULL,
|
||||||
|
"readerPrefetchCount" INTEGER NOT NULL DEFAULT 5,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "preferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "favorites" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"userId" INTEGER NOT NULL,
|
||||||
|
"seriesId" TEXT NOT NULL,
|
||||||
|
"provider" TEXT NOT NULL DEFAULT 'komga',
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "favorites_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "komgaconfigs_userId_key" ON "komgaconfigs"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "stripstreamconfigs_userId_key" ON "stripstreamconfigs"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "preferences_userId_key" ON "preferences"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "favorites_userId_idx" ON "favorites"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "favorites_userId_provider_seriesId_key" ON "favorites"("userId", "provider", "seriesId");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "preferences" ADD COLUMN "anonymousMode" BOOLEAN NOT NULL DEFAULT false;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "sqlite"
|
||||||
@@ -16,11 +16,13 @@ model User {
|
|||||||
password String
|
password String
|
||||||
roles Json @default("[\"ROLE_USER\"]")
|
roles Json @default("[\"ROLE_USER\"]")
|
||||||
authenticated Boolean @default(true)
|
authenticated Boolean @default(true)
|
||||||
|
activeProvider String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
config KomgaConfig?
|
config KomgaConfig?
|
||||||
|
stripstreamConfig StripstreamConfig?
|
||||||
preferences Preferences?
|
preferences Preferences?
|
||||||
favorites Favorite[]
|
favorites Favorite[]
|
||||||
|
|
||||||
@@ -41,6 +43,19 @@ model KomgaConfig {
|
|||||||
@@map("komgaconfigs")
|
@@map("komgaconfigs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model StripstreamConfig {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
userId Int @unique
|
||||||
|
url String
|
||||||
|
token String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("stripstreamconfigs")
|
||||||
|
}
|
||||||
|
|
||||||
model Preferences {
|
model Preferences {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int @unique
|
userId Int @unique
|
||||||
@@ -49,6 +64,7 @@ model Preferences {
|
|||||||
displayMode Json
|
displayMode Json
|
||||||
background Json
|
background Json
|
||||||
readerPrefetchCount Int @default(5)
|
readerPrefetchCount Int @default(5)
|
||||||
|
anonymousMode Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -61,12 +77,13 @@ model Favorite {
|
|||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int
|
userId Int
|
||||||
seriesId String
|
seriesId String
|
||||||
|
provider String @default("komga")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([userId, seriesId])
|
@@unique([userId, provider, seriesId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@map("favorites")
|
@@map("favorites")
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/favicon.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
public/images/Gemini_Generated_Image_wyfsoiwyfsoiwyfs.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 21 KiB |
BIN
public/images/icons/home.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 7.3 KiB |
BIN
public/images/icons/library.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
public/images/logostripstream-white.png
Normal file
|
After Width: | Height: | Size: 889 KiB |
BIN
public/images/logostripstream.png
Normal file
|
After Width: | Height: | Size: 895 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 4.1 MiB |
BIN
public/images/splash/splash-1170x2532.png
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
public/images/splash/splash-1179x2556.png
Normal file
|
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: 26 KiB After Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 4.7 MiB |
BIN
public/images/splash/splash-1284x2778.png
Normal file
|
After Width: | Height: | Size: 4.9 MiB |
BIN
public/images/splash/splash-1290x2796.png
Normal file
|
After Width: | Height: | Size: 5.0 MiB |
BIN
public/images/splash/splash-1320x2868.png
Normal file
|
After Width: | Height: | Size: 5.1 MiB |
BIN
public/images/splash/splash-1334x750.png
Normal file
|
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: 30 KiB After Width: | Height: | Size: 4.5 MiB |
BIN
public/images/splash/splash-1620x2160.png
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
BIN
public/images/splash/splash-1640x2360.png
Normal file
|
After Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 5.3 MiB |
BIN
public/images/splash/splash-1668x2420.png
Normal file
|
After Width: | Height: | Size: 5.3 MiB |
BIN
public/images/splash/splash-1792x828.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
public/images/splash/splash-2048x1536.png
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 6.8 MiB |
BIN
public/images/splash/splash-2064x2752.png
Normal file
|
After Width: | Height: | Size: 6.9 MiB |
BIN
public/images/splash/splash-2160x1620.png
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
BIN
public/images/splash/splash-2208x1242.png
Normal file
|
After Width: | Height: | Size: 4.0 MiB |
BIN
public/images/splash/splash-2266x1488.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
BIN
public/images/splash/splash-2360x1640.png
Normal file
|
After Width: | Height: | Size: 5.2 MiB |
BIN
public/images/splash/splash-2388x1668.png
Normal file
|
After Width: | Height: | Size: 5.3 MiB |
BIN
public/images/splash/splash-2420x1668.png
Normal file
|
After Width: | Height: | Size: 5.3 MiB |
BIN
public/images/splash/splash-2436x1125.png
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
BIN
public/images/splash/splash-2532x1170.png
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
public/images/splash/splash-2556x1179.png
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
public/images/splash/splash-2622x1206.png
Normal file
|
After Width: | Height: | Size: 4.4 MiB |
BIN
public/images/splash/splash-2688x1242.png
Normal file
|
After Width: | Height: | Size: 4.6 MiB |
BIN
public/images/splash/splash-2732x2048.png
Normal file
|
After Width: | Height: | Size: 6.7 MiB |
BIN
public/images/splash/splash-2752x2064.png
Normal file
|
After Width: | Height: | Size: 6.8 MiB |
BIN
public/images/splash/splash-2778x1284.png
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
BIN
public/images/splash/splash-2796x1290.png
Normal file
|
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: 13 KiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 2.5 MiB |
@@ -5,75 +5,417 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Hors ligne - StripStream</title>
|
<title>Hors ligne - StripStream</title>
|
||||||
<style>
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #020817;
|
||||||
|
--panel: rgba(2, 8, 23, 0.66);
|
||||||
|
--panel-strong: rgba(2, 8, 23, 0.82);
|
||||||
|
--line: rgba(99, 102, 241, 0.3);
|
||||||
|
--text: #f1f5f9;
|
||||||
|
--muted: #cbd5e1;
|
||||||
|
--primary: #4f46e5;
|
||||||
|
--primary-2: #06b6d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-family:
|
font-family:
|
||||||
system-ui,
|
"Segoe UI",
|
||||||
|
"SF Pro Text",
|
||||||
-apple-system,
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
background-color: #0f172a;
|
background: var(--bg);
|
||||||
color: #e2e8f0;
|
color: var(--text);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(2, 8, 23, 0.99) 0%, rgba(2, 8, 23, 0.94) 42%, #020817 100%),
|
||||||
|
radial-gradient(70% 45% at 12% 0%, rgba(79, 70, 229, 0.16), transparent 62%),
|
||||||
|
radial-gradient(58% 38% at 88% 8%, rgba(6, 182, 212, 0.14), transparent 65%),
|
||||||
|
radial-gradient(50% 34% at 50% 100%, rgba(236, 72, 153, 0.1), transparent 70%),
|
||||||
|
repeating-linear-gradient(0deg, rgba(226, 232, 240, 0.02) 0 1px, transparent 1px 24px),
|
||||||
|
repeating-linear-gradient(90deg, rgba(226, 232, 240, 0.015) 0 1px, transparent 1px 30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 30;
|
||||||
|
height: 64px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: rgba(2, 8, 23, 0.7);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
112deg,
|
||||||
|
rgba(79, 70, 229, 0.24) 0%,
|
||||||
|
rgba(6, 182, 212, 0.2) 30%,
|
||||||
|
transparent 56%
|
||||||
|
),
|
||||||
|
linear-gradient(248deg, rgba(244, 114, 182, 0.16) 0%, transparent 46%),
|
||||||
|
repeating-linear-gradient(135deg, rgba(226, 232, 240, 0.03) 0 1px, transparent 1px 11px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
width: 2.4rem;
|
||||||
|
height: 2.4rem;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
object-fit: cover;
|
||||||
|
box-shadow: 0 0 20px rgba(34, 211, 238, 0.35);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn {
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
background: rgba(15, 23, 42, 0.6);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: linear-gradient(90deg, var(--primary), var(--primary-2), #d946ef);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-subtitle {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.25em;
|
||||||
|
color: rgba(226, 232, 240, 0.75);
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
background: rgba(2, 8, 23, 0.55);
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
position: relative;
|
||||||
|
min-height: calc(100vh - 64px);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 64px;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 280px;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
padding: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-open .sidebar {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 64px 0 0 0;
|
||||||
|
background: rgba(2, 6, 23, 0.48);
|
||||||
|
backdrop-filter: blur(1px);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
z-index: 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-open .sidebar-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
160deg,
|
||||||
|
rgba(79, 70, 229, 0.12) 0%,
|
||||||
|
rgba(6, 182, 212, 0.08) 32%,
|
||||||
|
transparent 58%
|
||||||
|
),
|
||||||
|
linear-gradient(332deg, rgba(244, 114, 182, 0.06) 0%, transparent 42%),
|
||||||
|
repeating-linear-gradient(135deg, rgba(226, 232, 240, 0.02) 0 1px, transparent 1px 11px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
background: rgba(2, 8, 23, 0.45);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
padding: 0.7rem;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h2 {
|
||||||
|
margin: 0.25rem 0.45rem 0.6rem;
|
||||||
|
font-size: 0.67rem;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(148, 163, 184, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.62rem 0.78rem;
|
||||||
|
margin: 0.14rem 0;
|
||||||
|
font-size: 0.93rem;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
background: rgba(148, 163, 184, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
border-color: rgba(79, 70, 229, 0.45);
|
||||||
|
background: rgba(79, 70, 229, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
text-align: center;
|
padding: 2rem;
|
||||||
|
min-height: calc(100vh - 64px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: min(720px, 100%);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--panel-strong);
|
||||||
|
box-shadow: 0 25px 60px -35px rgba(2, 6, 23, 0.92);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
color: #fecaca;
|
||||||
|
background: rgba(127, 29, 29, 0.3);
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.35);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 0.7rem;
|
||||||
|
font-size: clamp(1.35rem, 2.4vw, 1.95rem);
|
||||||
|
line-height: 1.24;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.7rem;
|
||||||
|
margin-top: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: rgba(2, 8, 23, 0.45);
|
||||||
|
border-color: rgba(148, 163, 184, 0.35);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: rgba(30, 41, 59, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #4338ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: rgba(148, 163, 184, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 1.2rem;
|
||||||
|
padding-top: 0.85rem;
|
||||||
|
border-top: 1px dashed rgba(148, 163, 184, 0.28);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: rgba(148, 163, 184, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.main {
|
||||||
|
min-height: calc(100vh - 64px);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
.container {
|
|
||||||
max-width: 600px;
|
.actions {
|
||||||
margin: 0 auto;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
h1 {
|
|
||||||
font-size: 2rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: #4f46e5;
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: #94a3b8;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
.buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
background-color: #4f46e5;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
background-color: #4338ca;
|
|
||||||
}
|
|
||||||
button.secondary {
|
|
||||||
background-color: #475569;
|
|
||||||
}
|
|
||||||
button.secondary:hover {
|
|
||||||
background-color: #334155;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<header class="header">
|
||||||
<h1>Vous êtes hors ligne</h1>
|
<div class="header-inner">
|
||||||
|
<div class="brand">
|
||||||
|
<button class="menu-btn" id="sidebar-toggle" type="button" aria-label="Menu">☰</button>
|
||||||
|
<img class="brand-logo" src="/images/logostripstream.png" alt="StripStream logo" />
|
||||||
|
<div>
|
||||||
|
<div class="brand-title">StripStream</div>
|
||||||
|
<div class="brand-subtitle">comic reader</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="pill">Mode hors ligne</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<button class="sidebar-overlay" id="sidebar-overlay" aria-label="Fermer le menu"></button>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<div class="section">
|
||||||
|
<h2>Navigation</h2>
|
||||||
|
<button class="nav-link active" type="button">Accueil</button>
|
||||||
|
<button class="nav-link" type="button">Telechargements</button>
|
||||||
|
</div>
|
||||||
|
<div class="section">
|
||||||
|
<h2>Compte</h2>
|
||||||
|
<button class="nav-link" type="button">Mon compte</button>
|
||||||
|
<button class="nav-link" type="button">Preferences</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div class="card">
|
||||||
|
<div class="status">● Hors ligne</div>
|
||||||
|
<h1>Cette page n'est pas encore disponible hors ligne.</h1>
|
||||||
<p>
|
<p>
|
||||||
Il semble que vous n'ayez pas de connexion internet. Certaines fonctionnalités de
|
Tu peux continuer a naviguer sur les pages deja consultees. Cette route sera disponible
|
||||||
StripStream peuvent ne pas être disponibles en mode hors ligne.
|
hors ligne apres une visite en ligne.
|
||||||
</p>
|
</p>
|
||||||
<div class="buttons">
|
<div class="actions">
|
||||||
<button class="secondary" onclick="window.history.back()">Retour</button>
|
<button class="btn btn-secondary" onclick="window.history.back()">Retour</button>
|
||||||
<button onclick="window.location.reload()">Réessayer</button>
|
<button class="btn btn-primary" onclick="window.location.reload()">Reessayer</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hint">
|
||||||
|
Astuce: visite d'abord Accueil, Bibliotheques, Series et pages de lecture quand tu es en
|
||||||
|
ligne.
|
||||||
</div>
|
</div>
|
||||||
|
<div class="footer">StripStream - interface hors ligne</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const toggle = document.getElementById("sidebar-toggle");
|
||||||
|
const overlay = document.getElementById("sidebar-overlay");
|
||||||
|
|
||||||
|
if (toggle && overlay) {
|
||||||
|
toggle.addEventListener("click", () => {
|
||||||
|
document.body.classList.toggle("sidebar-open");
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.addEventListener("click", () => {
|
||||||
|
document.body.classList.remove("sidebar-open");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("online", () => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
222
public/sw.js
@@ -3,75 +3,100 @@ const fs = require("fs").promises;
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const sizes = [72, 96, 128, 144, 152, 192, 384, 512];
|
const sizes = [72, 96, 128, 144, 152, 192, 384, 512];
|
||||||
const inputSvg = path.join(__dirname, "../public/favicon.svg");
|
const sourceLogo = path.join(__dirname, "../public/images/logostripstream.png");
|
||||||
const inputAppleSvg = path.join(__dirname, "../public/apple-icon.svg");
|
|
||||||
const outputDir = path.join(__dirname, "../public/images/icons");
|
const outputDir = path.join(__dirname, "../public/images/icons");
|
||||||
const screenshotsDir = path.join(__dirname, "../public/images/screenshots");
|
const screenshotsDir = path.join(__dirname, "../public/images/screenshots");
|
||||||
const splashDir = path.join(__dirname, "../public/images/splash");
|
const splashDir = path.join(__dirname, "../public/images/splash");
|
||||||
|
const faviconPath = path.join(__dirname, "../public/favicon.png");
|
||||||
|
|
||||||
|
// Source pour les splash screens
|
||||||
|
const splashSource = path.join(__dirname, "../public/images/Gemini_Generated_Image_wyfsoiwyfsoiwyfs.png");
|
||||||
|
|
||||||
// Configuration des splashscreens pour différents appareils
|
// Configuration des splashscreens pour différents appareils
|
||||||
const splashScreens = [
|
const splashScreens = [
|
||||||
{ width: 2048, height: 2732, name: "iPad Pro 12.9" }, // iPad Pro 12.9
|
// iPad (portrait + landscape)
|
||||||
{ width: 1668, height: 2388, name: "iPad Pro 11" }, // iPad Pro 11
|
{ width: 2048, height: 2732, name: "iPad Pro 12.9 portrait" },
|
||||||
{ width: 1536, height: 2048, name: "iPad Mini/Air" }, // iPad Mini, Air
|
{ width: 2732, height: 2048, name: "iPad Pro 12.9 landscape" },
|
||||||
{ width: 1125, height: 2436, name: "iPhone X/XS" }, // iPhone X/XS
|
{ width: 1668, height: 2388, name: "iPad Pro 11 portrait" },
|
||||||
{ width: 1242, height: 2688, name: "iPhone XS Max" }, // iPhone XS Max
|
{ width: 2388, height: 1668, name: "iPad Pro 11 landscape" },
|
||||||
{ width: 828, height: 1792, name: "iPhone XR" }, // iPhone XR
|
{ width: 1668, height: 2420, name: "iPad Pro 11 M4 portrait" },
|
||||||
{ width: 750, height: 1334, name: "iPhone 8/SE" }, // iPhone 8, SE
|
{ width: 2420, height: 1668, name: "iPad Pro 11 M4 landscape" },
|
||||||
{ width: 1242, height: 2208, name: "iPhone 8 Plus" }, // iPhone 8 Plus
|
{ 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" },
|
||||||
|
{ width: 2360, height: 1640, name: "iPad Air 10.9 landscape" },
|
||||||
|
|
||||||
|
// iPhone legacy
|
||||||
|
{ width: 1125, height: 2436, name: "iPhone X/XS/11 Pro portrait" },
|
||||||
|
{ width: 2436, height: 1125, name: "iPhone X/XS/11 Pro landscape" },
|
||||||
|
{ width: 1242, height: 2688, name: "iPhone XS Max/11 Pro Max portrait" },
|
||||||
|
{ width: 2688, height: 1242, name: "iPhone XS Max/11 Pro Max landscape" },
|
||||||
|
{ width: 828, height: 1792, name: "iPhone XR/11 portrait" },
|
||||||
|
{ width: 1792, height: 828, name: "iPhone XR/11 landscape" },
|
||||||
|
{ width: 750, height: 1334, name: "iPhone 8/SE portrait" },
|
||||||
|
{ width: 1334, height: 750, name: "iPhone 8/SE landscape" },
|
||||||
|
{ width: 1242, height: 2208, name: "iPhone 8 Plus portrait" },
|
||||||
|
{ width: 2208, height: 1242, name: "iPhone 8 Plus landscape" },
|
||||||
|
|
||||||
|
// iPhone modern (12+)
|
||||||
|
{ width: 1170, height: 2532, name: "iPhone 12/13/14 portrait" },
|
||||||
|
{ 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/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: 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() {
|
async function generateSplashScreens() {
|
||||||
await fs.mkdir(splashDir, { recursive: true });
|
await fs.mkdir(splashDir, { recursive: true });
|
||||||
|
console.log(`\n📱 Génération des splash screens...`);
|
||||||
// Créer le SVG de base pour la splashscreen avec le même style que le favicon
|
|
||||||
const splashSvg = `
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect width="100" height="100" fill="#4F46E5"/>
|
|
||||||
<g transform="translate(25, 20) scale(1.5)">
|
|
||||||
<!-- Lettre S stylisée -->
|
|
||||||
<path
|
|
||||||
d="M21 12C21 10.3431 19.6569 9 18 9H14C12.3431 9 11 10.3431 11 12V12.5C11 14.1569 12.3431 15.5 14 15.5H18C19.6569 15.5 21 16.8431 21 18.5V19C21 20.6569 19.6569 22 18 22H14C12.3431 22 11 20.6569 11 19"
|
|
||||||
stroke="white"
|
|
||||||
stroke-width="3"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
<!-- Points décoratifs -->
|
|
||||||
<circle cx="11" cy="24" r="1.5" fill="white"/>
|
|
||||||
<circle cx="21" cy="8" r="1.5" fill="white"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
`;
|
|
||||||
|
|
||||||
for (const screen of splashScreens) {
|
for (const screen of splashScreens) {
|
||||||
const outputPath = path.join(splashDir, `splash-${screen.width}x${screen.height}.png`);
|
const outputPath = path.join(splashDir, `splash-${screen.width}x${screen.height}.png`);
|
||||||
|
|
||||||
await sharp(Buffer.from(splashSvg))
|
await sharp(splashSource)
|
||||||
.resize(screen.width, screen.height, {
|
.resize(screen.width, screen.height, {
|
||||||
fit: "contain",
|
fit: "cover",
|
||||||
background: "#4F46E5",
|
position: "center",
|
||||||
|
})
|
||||||
|
.png({
|
||||||
|
compressionLevel: 9,
|
||||||
})
|
})
|
||||||
.png()
|
|
||||||
.toFile(outputPath);
|
.toFile(outputPath);
|
||||||
|
|
||||||
console.log(`✓ Splashscreen ${screen.name} (${screen.width}x${screen.height}) générée`);
|
console.log(` ✓ ${screen.name} (${screen.width}x${screen.height})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateIcons() {
|
async function generateIcons() {
|
||||||
try {
|
try {
|
||||||
|
await fs.access(sourceLogo);
|
||||||
|
|
||||||
// Créer les dossiers de sortie s'ils n'existent pas
|
// Créer les dossiers de sortie s'ils n'existent pas
|
||||||
await fs.mkdir(outputDir, { recursive: true });
|
await fs.mkdir(outputDir, { recursive: true });
|
||||||
await fs.mkdir(screenshotsDir, { recursive: true });
|
await fs.mkdir(screenshotsDir, { recursive: true });
|
||||||
|
|
||||||
// Générer les icônes Android (avec bords arrondis)
|
// Générer les icônes Android
|
||||||
for (const size of sizes) {
|
for (const size of sizes) {
|
||||||
const outputPath = path.join(outputDir, `icon-${size}x${size}.png`);
|
const outputPath = path.join(outputDir, `icon-${size}x${size}.png`);
|
||||||
|
|
||||||
await sharp(inputSvg)
|
await sharp(sourceLogo)
|
||||||
.resize(size, size, {
|
.resize(size, size, {
|
||||||
fit: "contain",
|
fit: "cover",
|
||||||
background: { r: 0, g: 0, b: 0, alpha: 0 }, // Fond transparent
|
background: { r: 0, g: 0, b: 0, alpha: 0 }, // Fond transparent
|
||||||
})
|
})
|
||||||
.png({
|
.png({
|
||||||
@@ -88,9 +113,9 @@ async function generateIcons() {
|
|||||||
for (const size of appleSizes) {
|
for (const size of appleSizes) {
|
||||||
const outputPath = path.join(outputDir, `apple-icon-${size}x${size}.png`);
|
const outputPath = path.join(outputDir, `apple-icon-${size}x${size}.png`);
|
||||||
|
|
||||||
await sharp(inputAppleSvg)
|
await sharp(sourceLogo)
|
||||||
.resize(size, size, {
|
.resize(size, size, {
|
||||||
fit: "contain",
|
fit: "cover",
|
||||||
background: { r: 0, g: 0, b: 0, alpha: 0 }, // Fond transparent
|
background: { r: 0, g: 0, b: 0, alpha: 0 }, // Fond transparent
|
||||||
})
|
})
|
||||||
.png({
|
.png({
|
||||||
@@ -102,26 +127,25 @@ async function generateIcons() {
|
|||||||
console.log(`✓ Icône Apple ${size}x${size} générée`);
|
console.log(`✓ Icône Apple ${size}x${size} générée`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Générer le favicon principal utilisé par Next metadata
|
||||||
|
await sharp(sourceLogo)
|
||||||
|
.resize(64, 64, {
|
||||||
|
fit: "cover",
|
||||||
|
})
|
||||||
|
.png({
|
||||||
|
compressionLevel: 9,
|
||||||
|
palette: true,
|
||||||
|
})
|
||||||
|
.toFile(faviconPath);
|
||||||
|
|
||||||
|
console.log("✓ Favicon principal généré");
|
||||||
|
|
||||||
// Générer les icônes de raccourcis
|
// Générer les icônes de raccourcis
|
||||||
const shortcutIcons = [
|
const shortcutIcons = ["home", "library"];
|
||||||
{ name: "home", icon: "Home" },
|
|
||||||
{ name: "library", icon: "Library" },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const shortcut of shortcutIcons) {
|
for (const shortcut of shortcutIcons) {
|
||||||
const outputPath = path.join(outputDir, `${shortcut.name}.png`);
|
const outputPath = path.join(outputDir, `${shortcut}.png`);
|
||||||
|
await sharp(sourceLogo)
|
||||||
// Créer une image carrée avec fond indigo et icône blanche
|
|
||||||
const svg = `
|
|
||||||
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect width="96" height="96" rx="20" fill="#4F46E5"/>
|
|
||||||
<path d="${getIconPath(
|
|
||||||
shortcut.icon
|
|
||||||
)}" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
`;
|
|
||||||
|
|
||||||
await sharp(Buffer.from(svg))
|
|
||||||
.resize(96, 96)
|
.resize(96, 96)
|
||||||
.png({
|
.png({
|
||||||
compressionLevel: 9,
|
compressionLevel: 9,
|
||||||
@@ -129,7 +153,7 @@ async function generateIcons() {
|
|||||||
})
|
})
|
||||||
.toFile(outputPath);
|
.toFile(outputPath);
|
||||||
|
|
||||||
console.log(`✓ Icône de raccourci ${shortcut.name} générée`);
|
console.log(`✓ Icône de raccourci ${shortcut} générée`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Générer les screenshots de démonstration
|
// Générer les screenshots de démonstration
|
||||||
@@ -166,14 +190,4 @@ async function generateIcons() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fonction helper pour obtenir les chemins SVG des icônes
|
|
||||||
function getIconPath(iconName) {
|
|
||||||
const paths = {
|
|
||||||
Home: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6",
|
|
||||||
Library:
|
|
||||||
"M4 19.5A2.5 2.5 0 0 1 6.5 17H20M4 19.5A2.5 2.5 0 0 0 6.5 22H20M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20",
|
|
||||||
};
|
|
||||||
return paths[iconName] || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
generateIcons();
|
generateIcons();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default async function AccountPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<div className="max-w-4xl mx-auto space-y-8">
|
<div className="mx-auto max-w-4xl space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Mon compte</h1>
|
<h1 className="text-3xl font-bold">Mon compte</h1>
|
||||||
<p className="text-muted-foreground mt-2">
|
<p className="text-muted-foreground mt-2">
|
||||||
|
|||||||
@@ -1,30 +1,40 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { BookService } from "@/lib/services/book.service";
|
import { getProvider } from "@/lib/providers/provider.factory";
|
||||||
import { AppError } from "@/utils/errors";
|
import { AppError } from "@/utils/errors";
|
||||||
import type { KomgaBook } from "@/types/komga";
|
import type { NormalizedBook } from "@/lib/providers/types";
|
||||||
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
interface BookDataResult {
|
interface BookDataResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: {
|
data?: {
|
||||||
book: KomgaBook;
|
book: NormalizedBook;
|
||||||
pages: number[];
|
pages: number[];
|
||||||
nextBook: KomgaBook | null;
|
nextBook: NormalizedBook | null;
|
||||||
};
|
};
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBookData(bookId: string): Promise<BookDataResult> {
|
export async function getBookData(bookId: string): Promise<BookDataResult> {
|
||||||
try {
|
try {
|
||||||
const data = await BookService.getBook(bookId);
|
const provider = await getProvider();
|
||||||
const nextBook = await BookService.getNextBook(bookId, data.book.seriesId);
|
if (!provider) {
|
||||||
|
return { success: false, message: "KOMGA_MISSING_CONFIG" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const book = await provider.getBook(bookId);
|
||||||
|
const pages = Array.from({ length: book.pageCount }, (_, i) => i + 1);
|
||||||
|
|
||||||
|
let nextBook: NormalizedBook | null = null;
|
||||||
|
try {
|
||||||
|
nextBook = await provider.getNextBook(bookId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, bookId }, "Failed to fetch next book in server action");
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: { book, pages, nextBook },
|
||||||
...data,
|
|
||||||
nextBook,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { ConfigDBService } from "@/lib/services/config-db.service";
|
import { ConfigDBService } from "@/lib/services/config-db.service";
|
||||||
import { TestService } from "@/lib/services/test.service";
|
|
||||||
import { AppError } from "@/utils/errors";
|
import { AppError } from "@/utils/errors";
|
||||||
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
import type { KomgaConfig, KomgaConfigData, KomgaLibrary } from "@/types/komga";
|
import type { KomgaConfig, KomgaConfigData, KomgaLibrary } from "@/types/komga";
|
||||||
|
|
||||||
interface SaveConfigInput {
|
interface SaveConfigInput {
|
||||||
@@ -13,9 +13,6 @@ interface SaveConfigInput {
|
|||||||
authHeader?: string;
|
authHeader?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Teste la connexion à Komga
|
|
||||||
*/
|
|
||||||
export async function testKomgaConnection(
|
export async function testKomgaConnection(
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
username: string,
|
username: string,
|
||||||
@@ -23,27 +20,31 @@ export async function testKomgaConnection(
|
|||||||
): Promise<{ success: boolean; message: string }> {
|
): Promise<{ success: boolean; message: string }> {
|
||||||
try {
|
try {
|
||||||
const authHeader = Buffer.from(`${username}:${password}`).toString("base64");
|
const authHeader = Buffer.from(`${username}:${password}`).toString("base64");
|
||||||
|
const url = new URL(`${serverUrl}/api/v1/libraries`).toString();
|
||||||
const { libraries }: { libraries: KomgaLibrary[] } = await TestService.testConnection({
|
const headers = new Headers({
|
||||||
serverUrl,
|
Authorization: `Basic ${authHeader}`,
|
||||||
authHeader,
|
Accept: "application/json",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const response = await fetch(url, { headers });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new AppError(ERROR_CODES.KOMGA.CONNECTION_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraries: KomgaLibrary[] = await response.json();
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `Connexion réussie ! ${libraries.length} bibliothèque${libraries.length > 1 ? "s" : ""} trouvée${libraries.length > 1 ? "s" : ""}`,
|
message: `Connexion réussie ! ${libraries.length} bibliothèque${libraries.length > 1 ? "s" : ""} trouvée${libraries.length > 1 ? "s" : ""}`,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
return { success: false, message: error.message };
|
return { success: false, message: error.message };
|
||||||
}
|
}
|
||||||
return { success: false, message: "Erreur lors de la connexion" };
|
return { success: false, message: "Erreur lors de la connexion" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sauvegarde la configuration Komga
|
|
||||||
*/
|
|
||||||
export async function saveKomgaConfig(
|
export async function saveKomgaConfig(
|
||||||
config: SaveConfigInput
|
config: SaveConfigInput
|
||||||
): Promise<{ success: boolean; message: string; data?: KomgaConfig }> {
|
): Promise<{ success: boolean; message: string; data?: KomgaConfig }> {
|
||||||
@@ -55,17 +56,10 @@ export async function saveKomgaConfig(
|
|||||||
authHeader: config.authHeader || "",
|
authHeader: config.authHeader || "",
|
||||||
};
|
};
|
||||||
const mongoConfig = await ConfigDBService.saveConfig(configData);
|
const mongoConfig = await ConfigDBService.saveConfig(configData);
|
||||||
|
|
||||||
// Invalider le cache
|
|
||||||
revalidatePath("/settings");
|
revalidatePath("/settings");
|
||||||
|
return { success: true, message: "Configuration sauvegardée", data: mongoConfig };
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: "Configuration sauvegardée",
|
|
||||||
data: mongoConfig,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
return { success: false, message: error.message };
|
return { success: false, message: error.message };
|
||||||
}
|
}
|
||||||
return { success: false, message: "Erreur lors de la sauvegarde" };
|
return { success: false, message: "Erreur lors de la sauvegarde" };
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { LibraryService } from "@/lib/services/library.service";
|
import { getProvider } from "@/lib/providers/provider.factory";
|
||||||
import { BookService } from "@/lib/services/book.service";
|
|
||||||
import { AppError } from "@/utils/errors";
|
import { AppError } from "@/utils/errors";
|
||||||
|
|
||||||
/**
|
|
||||||
* Lance un scan de bibliothèque
|
|
||||||
*/
|
|
||||||
export async function scanLibrary(
|
export async function scanLibrary(
|
||||||
libraryId: string
|
libraryId: string
|
||||||
): Promise<{ success: boolean; message: string }> {
|
): Promise<{ success: boolean; message: string }> {
|
||||||
try {
|
try {
|
||||||
await LibraryService.scanLibrary(libraryId, false);
|
const provider = await getProvider();
|
||||||
|
if (!provider) return { success: false, message: "Provider non configuré" };
|
||||||
|
|
||||||
|
await provider.scanLibrary(libraryId);
|
||||||
|
|
||||||
// Invalider le cache de la bibliothèque
|
|
||||||
revalidatePath(`/libraries/${libraryId}`);
|
revalidatePath(`/libraries/${libraryId}`);
|
||||||
revalidatePath("/libraries");
|
revalidatePath("/libraries");
|
||||||
|
|
||||||
@@ -27,9 +25,6 @@ export async function scanLibrary(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retourne un livre aléatoire depuis les bibliothèques sélectionnées
|
|
||||||
*/
|
|
||||||
export async function getRandomBookFromLibraries(
|
export async function getRandomBookFromLibraries(
|
||||||
libraryIds: string[]
|
libraryIds: string[]
|
||||||
): Promise<{ success: boolean; bookId?: string; message?: string }> {
|
): Promise<{ success: boolean; bookId?: string; message?: string }> {
|
||||||
@@ -38,13 +33,15 @@ export async function getRandomBookFromLibraries(
|
|||||||
return { success: false, message: "Au moins une bibliothèque doit être sélectionnée" };
|
return { success: false, message: "Au moins une bibliothèque doit être sélectionnée" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookId = await BookService.getRandomBookFromLibraries(libraryIds);
|
const provider = await getProvider();
|
||||||
return { success: true, bookId };
|
if (!provider) return { success: false, message: "Provider non configuré" };
|
||||||
|
|
||||||
|
const bookId = await provider.getRandomBook(libraryIds);
|
||||||
|
return { success: true, bookId: bookId ?? undefined };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
return { success: false, message: error.message };
|
return { success: false, message: error.message };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: false, message: "Erreur lors de la récupération d'un livre aléatoire" };
|
return { success: false, message: "Erreur lors de la récupération d'un livre aléatoire" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,27 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidateTag } from "next/cache";
|
import { revalidateTag } from "next/cache";
|
||||||
import { BookService } from "@/lib/services/book.service";
|
import { getProvider } from "@/lib/providers/provider.factory";
|
||||||
|
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG, SERIES_BOOKS_CACHE_TAG } from "@/constants/cacheConstants";
|
||||||
import { AppError } from "@/utils/errors";
|
import { AppError } from "@/utils/errors";
|
||||||
|
|
||||||
const HOME_CACHE_TAG = "home-data";
|
function revalidateReadCaches() {
|
||||||
|
revalidateTag(HOME_CACHE_TAG, "max");
|
||||||
|
revalidateTag(LIBRARY_SERIES_CACHE_TAG, "max");
|
||||||
|
revalidateTag(SERIES_BOOKS_CACHE_TAG, "max");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Met à jour la progression de lecture d'un livre
|
|
||||||
* Note: ne pas utiliser "use server" avec redirect - on gère manuellement
|
|
||||||
*/
|
|
||||||
export async function updateReadProgress(
|
export async function updateReadProgress(
|
||||||
bookId: string,
|
bookId: string,
|
||||||
page: number,
|
page: number,
|
||||||
completed: boolean = false
|
completed: boolean = false
|
||||||
): Promise<{ success: boolean; message: string }> {
|
): Promise<{ success: boolean; message: string }> {
|
||||||
try {
|
try {
|
||||||
await BookService.updateReadProgress(bookId, page, completed);
|
const provider = await getProvider();
|
||||||
|
if (!provider) return { success: false, message: "Provider non configuré" };
|
||||||
|
|
||||||
// Invalider le cache de la home (sans refresh auto)
|
await provider.saveReadProgress(bookId, page, completed);
|
||||||
revalidateTag(HOME_CACHE_TAG, "max");
|
revalidateReadCaches();
|
||||||
|
|
||||||
return { success: true, message: "Progression mise à jour" };
|
return { success: true, message: "Progression mise à jour" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -30,17 +32,15 @@ export async function updateReadProgress(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Supprime la progression de lecture d'un livre
|
|
||||||
*/
|
|
||||||
export async function deleteReadProgress(
|
export async function deleteReadProgress(
|
||||||
bookId: string
|
bookId: string
|
||||||
): Promise<{ success: boolean; message: string }> {
|
): Promise<{ success: boolean; message: string }> {
|
||||||
try {
|
try {
|
||||||
await BookService.deleteReadProgress(bookId);
|
const provider = await getProvider();
|
||||||
|
if (!provider) return { success: false, message: "Provider non configuré" };
|
||||||
|
|
||||||
// Invalider le cache de la home (sans refresh auto)
|
await provider.resetReadProgress(bookId);
|
||||||
revalidateTag(HOME_CACHE_TAG, "max");
|
revalidateReadCaches();
|
||||||
|
|
||||||
return { success: true, message: "Progression supprimée" };
|
return { success: true, message: "Progression supprimée" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
30
src/app/actions/refresh.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath, revalidateTag } from "next/cache";
|
||||||
|
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG } from "@/constants/cacheConstants";
|
||||||
|
|
||||||
|
export type RefreshScope = "home" | "library" | "series";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalide le cache Next.js pour forcer un re-fetch au prochain router.refresh().
|
||||||
|
* À appeler côté client avant router.refresh() sur les boutons / pull-to-refresh.
|
||||||
|
*/
|
||||||
|
export async function revalidateForRefresh(scope: RefreshScope, id: string): Promise<void> {
|
||||||
|
switch (scope) {
|
||||||
|
case "home":
|
||||||
|
revalidateTag(HOME_CACHE_TAG, "max");
|
||||||
|
revalidatePath("/");
|
||||||
|
break;
|
||||||
|
case "library":
|
||||||
|
revalidateTag(LIBRARY_SERIES_CACHE_TAG, "max");
|
||||||
|
revalidatePath(`/libraries/${id}`);
|
||||||
|
revalidatePath("/libraries");
|
||||||
|
break;
|
||||||
|
case "series":
|
||||||
|
revalidatePath(`/series/${id}`);
|
||||||
|
revalidatePath("/series");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
181
src/app/actions/stripstream-config.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { getCurrentUser } from "@/lib/auth-utils";
|
||||||
|
import { StripstreamProvider } from "@/lib/providers/stripstream/stripstream.provider";
|
||||||
|
import { getResolvedStripstreamConfig } from "@/lib/providers/stripstream/stripstream-config-resolver";
|
||||||
|
import { AppError } from "@/utils/errors";
|
||||||
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
|
import type { ProviderType } from "@/lib/providers/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sauvegarde la configuration Stripstream
|
||||||
|
*/
|
||||||
|
export async function saveStripstreamConfig(
|
||||||
|
url: string,
|
||||||
|
token: string
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, message: "Non authentifié" };
|
||||||
|
}
|
||||||
|
const userId = parseInt(user.id, 10);
|
||||||
|
|
||||||
|
await prisma.stripstreamConfig.upsert({
|
||||||
|
where: { userId },
|
||||||
|
update: { url, token },
|
||||||
|
create: { userId, url, token },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/settings");
|
||||||
|
return { success: true, message: "Configuration Stripstream sauvegardée" };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
return { success: false, message: error.message };
|
||||||
|
}
|
||||||
|
return { success: false, message: "Erreur lors de la sauvegarde" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teste la connexion à Stripstream Librarian
|
||||||
|
*/
|
||||||
|
export async function testStripstreamConnection(
|
||||||
|
url: string,
|
||||||
|
token: string
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
try {
|
||||||
|
const provider = new StripstreamProvider(url, token);
|
||||||
|
const result = await provider.testConnection();
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return { success: false, message: result.error ?? "Connexion échouée" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: "Connexion Stripstream réussie !" };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
return { success: false, message: error.message };
|
||||||
|
}
|
||||||
|
return { success: false, message: "Erreur lors du test de connexion" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Définit le provider actif de l'utilisateur
|
||||||
|
*/
|
||||||
|
export async function setActiveProvider(
|
||||||
|
provider: ProviderType
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, message: "Non authentifié" };
|
||||||
|
}
|
||||||
|
const userId = parseInt(user.id, 10);
|
||||||
|
|
||||||
|
// Vérifier que le provider est configuré avant de l'activer
|
||||||
|
if (provider === "komga") {
|
||||||
|
const config = await prisma.komgaConfig.findUnique({ where: { userId } });
|
||||||
|
if (!config) {
|
||||||
|
return { success: false, message: "Komga n'est pas encore configuré" };
|
||||||
|
}
|
||||||
|
} else if (provider === "stripstream") {
|
||||||
|
const config = await getResolvedStripstreamConfig(userId);
|
||||||
|
if (!config) {
|
||||||
|
return { success: false, message: "Stripstream n'est pas encore configuré" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { activeProvider: provider },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/");
|
||||||
|
revalidatePath("/settings");
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Provider actif : ${provider === "komga" ? "Komga" : "Stripstream Librarian"}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
return { success: false, message: error.message };
|
||||||
|
}
|
||||||
|
return { success: false, message: "Erreur lors du changement de provider" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère la configuration Stripstream de l'utilisateur (affichage settings).
|
||||||
|
* Priorité : config en base, sinon env STRIPSTREAM_URL / STRIPSTREAM_TOKEN.
|
||||||
|
*/
|
||||||
|
export async function getStripstreamConfig(): Promise<{
|
||||||
|
url?: string;
|
||||||
|
hasToken: boolean;
|
||||||
|
} | null> {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return null;
|
||||||
|
const userId = parseInt(user.id, 10);
|
||||||
|
|
||||||
|
const resolved = await getResolvedStripstreamConfig(userId);
|
||||||
|
if (!resolved) return null;
|
||||||
|
return { url: resolved.url, hasToken: true };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère le provider actif de l'utilisateur
|
||||||
|
*/
|
||||||
|
export async function getActiveProvider(): Promise<ProviderType> {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return "komga";
|
||||||
|
const userId = parseInt(user.id, 10);
|
||||||
|
|
||||||
|
const dbUser = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { activeProvider: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (dbUser?.activeProvider as ProviderType) ?? "komga";
|
||||||
|
} catch {
|
||||||
|
return "komga";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie quels providers sont configurés
|
||||||
|
*/
|
||||||
|
export async function getProvidersStatus(): Promise<{
|
||||||
|
komgaConfigured: boolean;
|
||||||
|
stripstreamConfigured: boolean;
|
||||||
|
activeProvider: ProviderType;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return { komgaConfigured: false, stripstreamConfigured: false, activeProvider: "komga" };
|
||||||
|
}
|
||||||
|
const userId = parseInt(user.id, 10);
|
||||||
|
|
||||||
|
const [dbUser, komgaConfig, stripstreamResolved] = await Promise.all([
|
||||||
|
prisma.user.findUnique({ where: { id: userId }, select: { activeProvider: true } }),
|
||||||
|
prisma.komgaConfig.findUnique({ where: { userId }, select: { id: true } }),
|
||||||
|
getResolvedStripstreamConfig(userId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
komgaConfigured: !!komgaConfig,
|
||||||
|
stripstreamConfigured: !!stripstreamResolved,
|
||||||
|
activeProvider: (dbUser?.activeProvider as ProviderType) ?? "komga",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { komgaConfigured: false, stripstreamConfigured: false, activeProvider: "komga" };
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/api/provider/search/route.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getProvider } from "@/lib/providers/provider.factory";
|
||||||
|
import { AppError, getErrorMessage } from "@/utils/errors";
|
||||||
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
|
|
||||||
|
const MIN_QUERY_LENGTH = 2;
|
||||||
|
const DEFAULT_LIMIT = 6;
|
||||||
|
const MAX_LIMIT = 10;
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const query = request.nextUrl.searchParams.get("q")?.trim() ?? "";
|
||||||
|
const limitParam = request.nextUrl.searchParams.get("limit");
|
||||||
|
const parsedLimit = limitParam ? Number(limitParam) : Number.NaN;
|
||||||
|
const limit = Number.isFinite(parsedLimit)
|
||||||
|
? Math.max(1, Math.min(parsedLimit, MAX_LIMIT))
|
||||||
|
: DEFAULT_LIMIT;
|
||||||
|
|
||||||
|
if (query.length < MIN_QUERY_LENGTH) {
|
||||||
|
return NextResponse.json([], { headers: { "Cache-Control": "no-store" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = await getProvider();
|
||||||
|
if (!provider) {
|
||||||
|
return NextResponse.json([], { headers: { "Cache-Control": "no-store" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await provider.search(query, limit);
|
||||||
|
|
||||||
|
return NextResponse.json(results, { headers: { "Cache-Control": "no-store" } });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { code: error.code, message: getErrorMessage(error.code) } },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { code: ERROR_CODES.CLIENT.FETCH_ERROR, message: "Search error" } },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth-utils";
|
||||||
|
import { getResolvedStripstreamConfig } from "@/lib/providers/stripstream/stripstream-config-resolver";
|
||||||
|
import { StripstreamClient } from "@/lib/providers/stripstream/stripstream.client";
|
||||||
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
|
import { AppError } from "@/utils/errors";
|
||||||
|
import { getErrorMessage } from "@/utils/errors";
|
||||||
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ bookId: string; pageNumber: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { bookId, pageNumber } = await params;
|
||||||
|
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: { code: "AUTH_UNAUTHENTICATED" } }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = parseInt(user.id, 10);
|
||||||
|
const config = await getResolvedStripstreamConfig(userId);
|
||||||
|
if (!config) {
|
||||||
|
throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = request.nextUrl.search.slice(1); // strip leading '?'
|
||||||
|
const path = `books/${bookId}/pages/${pageNumber}${queryString ? `?${queryString}` : ""}`;
|
||||||
|
|
||||||
|
const client = new StripstreamClient(config.url, config.token);
|
||||||
|
const response = await client.fetchImage(path);
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type") ?? "image/jpeg";
|
||||||
|
const contentLength = response.headers.get("content-length");
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Cache-Control": "public, max-age=86400",
|
||||||
|
};
|
||||||
|
if (contentLength) headers["Content-Length"] = contentLength;
|
||||||
|
|
||||||
|
return new NextResponse(response.body, { headers });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, "Stripstream page fetch error");
|
||||||
|
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { code: error.code, message: getErrorMessage(error.code) } },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { code: ERROR_CODES.IMAGE.FETCH_ERROR, message: "Image fetch error" } },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth-utils";
|
||||||
|
import { getResolvedStripstreamConfig } from "@/lib/providers/stripstream/stripstream-config-resolver";
|
||||||
|
import { StripstreamClient } from "@/lib/providers/stripstream/stripstream.client";
|
||||||
|
import { AppError } from "@/utils/errors";
|
||||||
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ bookId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { bookId } = await params;
|
||||||
|
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: { code: "AUTH_UNAUTHENTICATED" } }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = parseInt(user.id, 10);
|
||||||
|
const config = await getResolvedStripstreamConfig(userId);
|
||||||
|
if (!config) {
|
||||||
|
throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new StripstreamClient(config.url, config.token);
|
||||||
|
const response = await client.fetchImage(`books/${bookId}/thumbnail`);
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type") ?? "image/jpeg";
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
|
||||||
|
return new NextResponse(buffer, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Cache-Control": "public, max-age=2592000, immutable",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ err: error }, "Stripstream thumbnail fetch error");
|
||||||
|
return new NextResponse(null, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/api/stripstream/search/route.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getProvider } from "@/lib/providers/provider.factory";
|
||||||
|
import { AppError, getErrorMessage } from "@/utils/errors";
|
||||||
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
|
|
||||||
|
const MIN_QUERY_LENGTH = 2;
|
||||||
|
const DEFAULT_LIMIT = 6;
|
||||||
|
const MAX_LIMIT = 10;
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const query = request.nextUrl.searchParams.get("q")?.trim() ?? "";
|
||||||
|
const limitParam = request.nextUrl.searchParams.get("limit");
|
||||||
|
const parsedLimit = limitParam ? Number(limitParam) : Number.NaN;
|
||||||
|
const limit = Number.isFinite(parsedLimit)
|
||||||
|
? Math.max(1, Math.min(parsedLimit, MAX_LIMIT))
|
||||||
|
: DEFAULT_LIMIT;
|
||||||
|
|
||||||
|
if (query.length < MIN_QUERY_LENGTH) {
|
||||||
|
return NextResponse.json([], { headers: { "Cache-Control": "no-store" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = await getProvider();
|
||||||
|
if (!provider) {
|
||||||
|
throw new AppError(ERROR_CODES.STRIPSTREAM.MISSING_CONFIG);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await provider.search(query, limit);
|
||||||
|
|
||||||
|
return NextResponse.json(results, { headers: { "Cache-Control": "no-store" } });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { code: error.code, message: getErrorMessage(error.code) } },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { code: ERROR_CODES.CLIENT.FETCH_ERROR, message: "Search error" } },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,40 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { ClientBookPage } from "@/components/reader/ClientBookPage";
|
import { ClientBookPage } from "@/components/reader/ClientBookPage";
|
||||||
import { BookSkeleton } from "@/components/skeletons/BookSkeleton";
|
import { BookSkeleton } from "@/components/skeletons/BookSkeleton";
|
||||||
import { BookService } from "@/lib/services/book.service";
|
import { getProvider } from "@/lib/providers/provider.factory";
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
import { AppError } from "@/utils/errors";
|
import { AppError } from "@/utils/errors";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
export default async function BookPage({ params }: { params: Promise<{ bookId: string }> }) {
|
export default async function BookPage({ params }: { params: Promise<{ bookId: string }> }) {
|
||||||
const { bookId } = await params;
|
const { bookId } = await params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// SSR: Fetch directly on server instead of client-side XHR
|
const provider = await getProvider();
|
||||||
const data = await BookService.getBook(bookId);
|
if (!provider) redirect("/settings");
|
||||||
const nextBook = await BookService.getNextBook(bookId, data.book.seriesId);
|
|
||||||
|
const book = await provider.getBook(bookId);
|
||||||
|
const pages = Array.from({ length: book.pageCount }, (_, i) => i + 1);
|
||||||
|
|
||||||
|
let nextBook = null;
|
||||||
|
try {
|
||||||
|
nextBook = await provider.getNextBook(bookId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error, bookId }, "Failed to fetch next book, continuing without it");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<BookSkeleton />}>
|
<Suspense fallback={<BookSkeleton />}>
|
||||||
<ClientBookPage bookId={bookId} initialData={{ ...data, nextBook }} />
|
<ClientBookPage bookId={bookId} initialData={{ book, pages, nextBook }} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If config is missing, redirect to settings
|
// If config is missing, redirect to settings
|
||||||
if (error instanceof AppError && error.code === ERROR_CODES.KOMGA.MISSING_CONFIG) {
|
if (error instanceof AppError && (
|
||||||
|
error.code === ERROR_CODES.KOMGA.MISSING_CONFIG ||
|
||||||
|
error.code === ERROR_CODES.STRIPSTREAM.MISSING_CONFIG
|
||||||
|
)) {
|
||||||
redirect("/settings");
|
redirect("/settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import { cn } from "@/lib/utils";
|
|||||||
import ClientLayout from "@/components/layout/ClientLayout";
|
import ClientLayout from "@/components/layout/ClientLayout";
|
||||||
import { PreferencesService } from "@/lib/services/preferences.service";
|
import { PreferencesService } from "@/lib/services/preferences.service";
|
||||||
import { PreferencesProvider } from "@/contexts/PreferencesContext";
|
import { PreferencesProvider } from "@/contexts/PreferencesContext";
|
||||||
|
import { AnonymousProvider } from "@/contexts/AnonymousContext";
|
||||||
import { I18nProvider } from "@/components/providers/I18nProvider";
|
import { I18nProvider } from "@/components/providers/I18nProvider";
|
||||||
import { AuthProvider } from "@/components/providers/AuthProvider";
|
import { AuthProvider } from "@/components/providers/AuthProvider";
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
import { defaultPreferences } from "@/types/preferences";
|
import { defaultPreferences } from "@/types/preferences";
|
||||||
import type { UserPreferences } from "@/types/preferences";
|
import type { UserPreferences } from "@/types/preferences";
|
||||||
import type { KomgaLibrary, KomgaSeries } from "@/types/komga";
|
import type { NormalizedLibrary, NormalizedSeries } from "@/lib/providers/types";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
@@ -36,8 +37,8 @@ export const metadata: Metadata = {
|
|||||||
icons: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
{
|
{
|
||||||
url: "/favicon.svg",
|
url: "/favicon.png",
|
||||||
type: "image/svg+xml",
|
type: "image/png",
|
||||||
},
|
},
|
||||||
{ url: "/images/icons/icon-72x72.png", sizes: "72x72", type: "image/png" },
|
{ url: "/images/icons/icon-72x72.png", sizes: "72x72", type: "image/png" },
|
||||||
{ url: "/images/icons/icon-96x96.png", sizes: "96x96", type: "image/png" },
|
{ url: "/images/icons/icon-96x96.png", sizes: "96x96", type: "image/png" },
|
||||||
@@ -77,8 +78,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
|
|
||||||
let preferences: UserPreferences = defaultPreferences;
|
let preferences: UserPreferences = defaultPreferences;
|
||||||
let userIsAdmin = false;
|
let userIsAdmin = false;
|
||||||
let libraries: KomgaLibrary[] = [];
|
let libraries: NormalizedLibrary[] = [];
|
||||||
let favorites: KomgaSeries[] = [];
|
let favorites: NormalizedSeries[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentUser = await import("@/lib/auth-utils").then((m) => m.getCurrentUser());
|
const currentUser = await import("@/lib/auth-utils").then((m) => m.getCurrentUser());
|
||||||
@@ -86,7 +87,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
const [preferencesData, librariesData, favoritesData] = await Promise.allSettled([
|
const [preferencesData, librariesData, favoritesData] = await Promise.allSettled([
|
||||||
PreferencesService.getPreferences(),
|
PreferencesService.getPreferences(),
|
||||||
import("@/lib/services/library.service").then((m) => m.LibraryService.getLibraries()),
|
import("@/lib/providers/provider.factory")
|
||||||
|
.then((m) => m.getProvider())
|
||||||
|
.then((provider) => provider?.getLibraries() ?? []),
|
||||||
import("@/lib/services/favorites.service").then((m) =>
|
import("@/lib/services/favorites.service").then((m) =>
|
||||||
m.FavoritesService.getFavorites({ requestPath, requestPathname })
|
m.FavoritesService.getFavorites({ requestPath, requestPathname })
|
||||||
),
|
),
|
||||||
@@ -131,41 +134,176 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
href="/images/splash/splash-2048x2732.png"
|
href="/images/splash/splash-2048x2732.png"
|
||||||
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
/>
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-2732x2048.png"
|
||||||
|
media="(device-width: 1366px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
<link
|
<link
|
||||||
rel="apple-touch-startup-image"
|
rel="apple-touch-startup-image"
|
||||||
href="/images/splash/splash-1668x2388.png"
|
href="/images/splash/splash-1668x2388.png"
|
||||||
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
/>
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-2388x1668.png"
|
||||||
|
media="(device-width: 1194px) and (device-height: 834px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
<link
|
<link
|
||||||
rel="apple-touch-startup-image"
|
rel="apple-touch-startup-image"
|
||||||
href="/images/splash/splash-1536x2048.png"
|
href="/images/splash/splash-1536x2048.png"
|
||||||
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
/>
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-2048x1536.png"
|
||||||
|
media="(device-width: 1024px) and (device-height: 768px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
<link
|
<link
|
||||||
rel="apple-touch-startup-image"
|
rel="apple-touch-startup-image"
|
||||||
href="/images/splash/splash-1125x2436.png"
|
href="/images/splash/splash-1125x2436.png"
|
||||||
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
/>
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-2436x1125.png"
|
||||||
|
media="(device-width: 812px) and (device-height: 375px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
<link
|
<link
|
||||||
rel="apple-touch-startup-image"
|
rel="apple-touch-startup-image"
|
||||||
href="/images/splash/splash-1242x2688.png"
|
href="/images/splash/splash-1242x2688.png"
|
||||||
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
/>
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-2688x1242.png"
|
||||||
|
media="(device-width: 896px) and (device-height: 414px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
<link
|
<link
|
||||||
rel="apple-touch-startup-image"
|
rel="apple-touch-startup-image"
|
||||||
href="/images/splash/splash-828x1792.png"
|
href="/images/splash/splash-828x1792.png"
|
||||||
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
/>
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-1792x828.png"
|
||||||
|
media="(device-width: 896px) and (device-height: 414px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
<link
|
<link
|
||||||
rel="apple-touch-startup-image"
|
rel="apple-touch-startup-image"
|
||||||
href="/images/splash/splash-750x1334.png"
|
href="/images/splash/splash-750x1334.png"
|
||||||
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
/>
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-1334x750.png"
|
||||||
|
media="(device-width: 667px) and (device-height: 375px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
<link
|
<link
|
||||||
rel="apple-touch-startup-image"
|
rel="apple-touch-startup-image"
|
||||||
href="/images/splash/splash-1242x2208.png"
|
href="/images/splash/splash-1242x2208.png"
|
||||||
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
/>
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-2208x1242.png"
|
||||||
|
media="(device-width: 736px) and (device-height: 414px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-1170x2532.png"
|
||||||
|
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-2532x1170.png"
|
||||||
|
media="(device-width: 844px) and (device-height: 390px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-1284x2778.png"
|
||||||
|
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-2778x1284.png"
|
||||||
|
media="(device-width: 926px) and (device-height: 428px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-1179x2556.png"
|
||||||
|
media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-2556x1179.png"
|
||||||
|
media="(device-width: 852px) and (device-height: 393px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-1290x2796.png"
|
||||||
|
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
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>
|
</head>
|
||||||
<body
|
<body
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -176,9 +314,15 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<I18nProvider locale={locale}>
|
<I18nProvider locale={locale}>
|
||||||
<PreferencesProvider initialPreferences={preferences}>
|
<PreferencesProvider initialPreferences={preferences}>
|
||||||
<ClientLayout initialLibraries={libraries} initialFavorites={favorites} userIsAdmin={userIsAdmin}>
|
<AnonymousProvider>
|
||||||
|
<ClientLayout
|
||||||
|
initialLibraries={libraries}
|
||||||
|
initialFavorites={favorites}
|
||||||
|
userIsAdmin={userIsAdmin}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</ClientLayout>
|
</ClientLayout>
|
||||||
|
</AnonymousProvider>
|
||||||
</PreferencesProvider>
|
</PreferencesProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
@@ -1,29 +1,44 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, type ReactNode } from "react";
|
import { useState, useCallback, type ReactNode } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
|
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
|
||||||
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
|
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
|
||||||
|
import { RefreshProvider } from "@/contexts/RefreshContext";
|
||||||
|
import { revalidateForRefresh } from "@/app/actions/refresh";
|
||||||
|
|
||||||
interface LibraryClientWrapperProps {
|
interface LibraryClientWrapperProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
libraryId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LibraryClientWrapper({ children }: LibraryClientWrapperProps) {
|
const REFRESH_ANIMATION_MS = 400;
|
||||||
|
|
||||||
|
export function LibraryClientWrapper({ children, libraryId }: LibraryClientWrapperProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = useCallback(
|
||||||
|
async (libraryIdArg?: string) => {
|
||||||
|
const id = libraryIdArg ?? libraryId;
|
||||||
|
if (!id) {
|
||||||
|
router.refresh();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
|
await revalidateForRefresh("library", id);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
await new Promise((r) => setTimeout(r, REFRESH_ANIMATION_MS));
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch {
|
} catch {
|
||||||
return { success: false, error: "Error refreshing library" };
|
return { success: false, error: "Error refreshing library" };
|
||||||
} finally {
|
} finally {
|
||||||
setIsRefreshing(false);
|
setIsRefreshing(false);
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[libraryId, router]
|
||||||
|
);
|
||||||
|
|
||||||
const pullToRefresh = usePullToRefresh({
|
const pullToRefresh = usePullToRefresh({
|
||||||
onRefresh: async () => {
|
onRefresh: async () => {
|
||||||
@@ -33,7 +48,9 @@ export function LibraryClientWrapper({ children }: LibraryClientWrapperProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<RefreshProvider
|
||||||
|
refreshLibrary={libraryId ? (id) => handleRefresh(id) : undefined}
|
||||||
|
>
|
||||||
<PullToRefreshIndicator
|
<PullToRefreshIndicator
|
||||||
isPulling={pullToRefresh.isPulling}
|
isPulling={pullToRefresh.isPulling}
|
||||||
isRefreshing={pullToRefresh.isRefreshing || isRefreshing}
|
isRefreshing={pullToRefresh.isRefreshing || isRefreshing}
|
||||||
@@ -42,6 +59,6 @@ export function LibraryClientWrapper({ children }: LibraryClientWrapperProps) {
|
|||||||
isHiding={pullToRefresh.isHiding}
|
isHiding={pullToRefresh.isHiding}
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
</>
|
</RefreshProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { LibraryHeader } from "@/components/library/LibraryHeader";
|
import { LibraryHeader } from "@/components/library/LibraryHeader";
|
||||||
import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid";
|
import { PaginatedSeriesGrid } from "@/components/library/PaginatedSeriesGrid";
|
||||||
import { Container } from "@/components/ui/container";
|
import { Container } from "@/components/ui/container";
|
||||||
import type { KomgaLibrary } from "@/types/komga";
|
import type { NormalizedLibrary, NormalizedSeriesPage } from "@/lib/providers/types";
|
||||||
import type { LibraryResponse } from "@/types/library";
|
|
||||||
import type { Series } from "@/types/series";
|
|
||||||
import type { UserPreferences } from "@/types/preferences";
|
import type { UserPreferences } from "@/types/preferences";
|
||||||
|
|
||||||
interface LibraryContentProps {
|
interface LibraryContentProps {
|
||||||
library: KomgaLibrary;
|
library: NormalizedLibrary;
|
||||||
series: LibraryResponse<Series>;
|
series: NormalizedSeriesPage;
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
preferences: UserPreferences;
|
preferences: UserPreferences;
|
||||||
unreadOnly: boolean;
|
unreadOnly: boolean;
|
||||||
@@ -28,15 +26,15 @@ export function LibraryContent({
|
|||||||
<>
|
<>
|
||||||
<LibraryHeader
|
<LibraryHeader
|
||||||
library={library}
|
library={library}
|
||||||
seriesCount={series.totalElements}
|
seriesCount={series.totalElements ?? series.items.length}
|
||||||
series={series.content || []}
|
series={series.items}
|
||||||
/>
|
/>
|
||||||
<Container>
|
<Container>
|
||||||
<PaginatedSeriesGrid
|
<PaginatedSeriesGrid
|
||||||
series={series.content || []}
|
series={series.items}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={series.totalPages}
|
totalPages={series.totalPages ?? 1}
|
||||||
totalElements={series.totalElements}
|
totalElements={series.totalElements ?? series.items.length}
|
||||||
defaultShowOnlyUnread={preferences.showOnlyUnread}
|
defaultShowOnlyUnread={preferences.showOnlyUnread}
|
||||||
showOnlyUnread={unreadOnly}
|
showOnlyUnread={unreadOnly}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { PreferencesService } from "@/lib/services/preferences.service";
|
import { PreferencesService } from "@/lib/services/preferences.service";
|
||||||
import { LibraryService } from "@/lib/services/library.service";
|
import { getProvider } from "@/lib/providers/provider.factory";
|
||||||
import { LibraryClientWrapper } from "./LibraryClientWrapper";
|
import { LibraryClientWrapper } from "./LibraryClientWrapper";
|
||||||
import { LibraryContent } from "./LibraryContent";
|
import { LibraryContent } from "./LibraryContent";
|
||||||
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||||
import { AppError } from "@/utils/errors";
|
import { AppError } from "@/utils/errors";
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
import type { UserPreferences } from "@/types/preferences";
|
import type { UserPreferences } from "@/types/preferences";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ libraryId: string }>;
|
params: Promise<{ libraryId: string }>;
|
||||||
@@ -17,9 +18,9 @@ const DEFAULT_PAGE_SIZE = 20;
|
|||||||
export default async function LibraryPage({ params, searchParams }: PageProps) {
|
export default async function LibraryPage({ params, searchParams }: PageProps) {
|
||||||
const libraryId = (await params).libraryId;
|
const libraryId = (await params).libraryId;
|
||||||
const unread = (await searchParams).unread;
|
const unread = (await searchParams).unread;
|
||||||
const search = (await searchParams).search;
|
|
||||||
const page = (await searchParams).page;
|
const page = (await searchParams).page;
|
||||||
const size = (await searchParams).size;
|
const size = (await searchParams).size;
|
||||||
|
const search = (await searchParams).search;
|
||||||
|
|
||||||
const currentPage = page ? parseInt(page) : 1;
|
const currentPage = page ? parseInt(page) : 1;
|
||||||
const preferences: UserPreferences = await PreferencesService.getPreferences();
|
const preferences: UserPreferences = await PreferencesService.getPreferences();
|
||||||
@@ -31,31 +32,36 @@ export default async function LibraryPage({ params, searchParams }: PageProps) {
|
|||||||
: preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
|
: preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [series, library] = await Promise.all([
|
const provider = await getProvider();
|
||||||
LibraryService.getLibrarySeries(
|
if (!provider) redirect("/settings");
|
||||||
libraryId,
|
|
||||||
currentPage - 1,
|
const [seriesPage, library] = await Promise.all([
|
||||||
effectivePageSize,
|
provider.getSeries(libraryId, String(currentPage), effectivePageSize, unreadOnly, search),
|
||||||
unreadOnly,
|
provider.getLibraryById(libraryId),
|
||||||
search
|
|
||||||
),
|
|
||||||
LibraryService.getLibrary(libraryId),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (!library) throw new AppError(ERROR_CODES.LIBRARY.NOT_FOUND);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LibraryClientWrapper>
|
<LibraryClientWrapper libraryId={libraryId}>
|
||||||
<LibraryContent
|
<LibraryContent
|
||||||
library={library}
|
library={library}
|
||||||
series={series}
|
series={seriesPage}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
preferences={preferences}
|
preferences={preferences}
|
||||||
unreadOnly={unreadOnly}
|
unreadOnly={unreadOnly}
|
||||||
search={search}
|
|
||||||
pageSize={effectivePageSize}
|
pageSize={effectivePageSize}
|
||||||
/>
|
/>
|
||||||
</LibraryClientWrapper>
|
</LibraryClientWrapper>
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof AppError && (
|
||||||
|
error.code === ERROR_CODES.KOMGA.MISSING_CONFIG ||
|
||||||
|
error.code === ERROR_CODES.STRIPSTREAM.MISSING_CONFIG
|
||||||
|
)) {
|
||||||
|
redirect("/settings");
|
||||||
|
}
|
||||||
|
|
||||||
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.SERIES.FETCH_ERROR;
|
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.SERIES.FETCH_ERROR;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
59
src/app/loading.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
const SHOW_DELAY_MS = 140;
|
||||||
|
|
||||||
|
export default function AppLoading() {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setIsVisible(true);
|
||||||
|
}, SHOW_DELAY_MS);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className={`flex min-h-screen items-center justify-center px-6 transition-opacity duration-300 motion-reduce:transition-none ${
|
||||||
|
isVisible ? "opacity-100" : "opacity-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex w-full max-w-sm flex-col items-center gap-6 text-center">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Image
|
||||||
|
src="/images/logostripstream.png"
|
||||||
|
alt="StripStream Logo"
|
||||||
|
width={80}
|
||||||
|
height={80}
|
||||||
|
className="mx-auto hidden h-20 w-20 rounded-xl object-cover dark:block"
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
src="/images/logostripstream-white.png"
|
||||||
|
alt="StripStream Logo"
|
||||||
|
width={80}
|
||||||
|
height={80}
|
||||||
|
className="mx-auto h-20 w-20 rounded-xl object-cover dark:hidden"
|
||||||
|
/>
|
||||||
|
<p className="bg-gradient-to-r from-primary via-cyan-500 to-fuchsia-500 bg-clip-text text-xl font-bold tracking-[0.08em] text-transparent">
|
||||||
|
StripStream
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Chargement de votre bibliotheque...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2" aria-hidden>
|
||||||
|
<span className="h-2 w-2 animate-bounce rounded-full bg-primary [animation-delay:-200ms]" />
|
||||||
|
<span className="h-2 w-2 animate-bounce rounded-full bg-cyan-500 [animation-delay:-100ms]" />
|
||||||
|
<span className="h-2 w-2 animate-bounce rounded-full bg-fuchsia-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative h-1.5 w-56 overflow-hidden rounded-full bg-muted/80" aria-hidden>
|
||||||
|
<div className="animate-loader-slide absolute inset-y-0 w-1/3 rounded-full bg-gradient-to-r from-primary via-cyan-500 to-fuchsia-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,21 +43,21 @@ export function LoginContent({ searchParams }: LoginContentProps) {
|
|||||||
transition={{ duration: 0.6, delay: 0.2 }}
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
className="relative z-20 flex items-center text-lg font-medium"
|
className="relative z-20 flex items-center text-lg font-medium"
|
||||||
>
|
>
|
||||||
<motion.svg
|
<motion.img
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
src="/images/logostripstream.png"
|
||||||
viewBox="0 0 24 24"
|
alt="StripStream Logo"
|
||||||
fill="none"
|
className="mr-3 hidden h-9 w-9 rounded-md object-cover dark:block"
|
||||||
stroke="currentColor"
|
whileHover={{ scale: 1.08, rotate: -3 }}
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="mr-2 h-6 w-6"
|
|
||||||
whileHover={{ rotate: 180 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
>
|
/>
|
||||||
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3" />
|
<motion.img
|
||||||
</motion.svg>
|
src="/images/logostripstream-white.png"
|
||||||
<span className="text-2xl font-bold bg-gradient-to-r from-white to-gray-300 bg-clip-text text-transparent">
|
alt="StripStream Logo"
|
||||||
|
className="mr-3 h-9 w-9 rounded-md object-cover dark:hidden"
|
||||||
|
whileHover={{ scale: 1.08, rotate: -3 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
/>
|
||||||
|
<span className="text-2xl font-bold bg-gradient-to-r from-primary via-cyan-500 to-fuchsia-500 bg-clip-text text-transparent">
|
||||||
StripStream
|
StripStream
|
||||||
</span>
|
</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -83,12 +83,20 @@ export function LoginContent({ searchParams }: LoginContentProps) {
|
|||||||
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
|
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
|
||||||
<div className="flex flex-col items-center space-y-4 text-center">
|
<div className="flex flex-col items-center space-y-4 text-center">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute -inset-1 bg-gradient-to-r from-[#4F46E5] to-[#6366F1] rounded-full opacity-75 blur-md animate-pulse"></div>
|
<div className="relative bg-gradient-to-br from-white to-gray-100 dark:from-slate-800 dark:to-slate-900 rounded-full shadow-xl overflow-hidden w-32 h-32 flex items-center justify-center">
|
||||||
<div className="relative bg-gradient-to-br from-white to-gray-100 dark:from-slate-800 dark:to-slate-900 rounded-full shadow-xl overflow-hidden w-24 h-24 flex items-center justify-center">
|
|
||||||
<motion.img
|
<motion.img
|
||||||
src="/images/icons/apple-icon-180x180.png"
|
src="/images/logostripstream.png"
|
||||||
alt="StripStream Logo"
|
alt="StripStream Logo"
|
||||||
className="w-[100%] h-[100%] object-cover"
|
className="hidden h-[100%] w-[100%] object-cover dark:block"
|
||||||
|
initial={{ scale: 1.8, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
/>
|
||||||
|
<motion.img
|
||||||
|
src="/images/logostripstream-white.png"
|
||||||
|
alt="StripStream Logo"
|
||||||
|
className="h-[100%] w-[100%] object-cover dark:hidden"
|
||||||
initial={{ scale: 1.8, opacity: 0 }}
|
initial={{ scale: 1.8, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
@@ -99,7 +107,7 @@ export function LoginContent({ searchParams }: LoginContentProps) {
|
|||||||
<motion.h1
|
<motion.h1
|
||||||
initial={{ y: -20 }}
|
initial={{ y: -20 }}
|
||||||
animate={{ y: 0 }}
|
animate={{ y: 0 }}
|
||||||
className="text-3xl font-bold tracking-tight bg-gradient-to-r from-slate-900 to-slate-700 dark:from-white dark:to-gray-300 bg-clip-text text-transparent"
|
className="text-3xl font-bold tracking-tight bg-gradient-to-r from-primary via-cyan-500 to-fuchsia-500 bg-clip-text text-transparent"
|
||||||
>
|
>
|
||||||
{t("login.title")}
|
{t("login.title")}
|
||||||
</motion.h1>
|
</motion.h1>
|
||||||
|
|||||||
@@ -1,28 +1,40 @@
|
|||||||
import { HomeService } from "@/lib/services/home.service";
|
import { getProvider } from "@/lib/providers/provider.factory";
|
||||||
import { HomeContent } from "@/components/home/HomeContent";
|
import { HomeContent } from "@/components/home/HomeContent";
|
||||||
import { HomeClientWrapper } from "@/components/home/HomeClientWrapper";
|
import { HomeClientWrapper } from "@/components/home/HomeClientWrapper";
|
||||||
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
import { AppError } from "@/utils/errors";
|
import { AppError } from "@/utils/errors";
|
||||||
|
import { FavoritesService } from "@/lib/services/favorites.service";
|
||||||
|
import { PreferencesService } from "@/lib/services/preferences.service";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
try {
|
try {
|
||||||
const data = await HomeService.getHomeData();
|
const provider = await getProvider();
|
||||||
|
if (!provider) redirect("/settings");
|
||||||
|
|
||||||
|
const [homeData, favorites, preferences] = await Promise.all([
|
||||||
|
provider.getHomeData(),
|
||||||
|
FavoritesService.getFavorites(),
|
||||||
|
PreferencesService.getPreferences().catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data = { ...homeData, favorites };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HomeClientWrapper>
|
<HomeClientWrapper>
|
||||||
<HomeContent data={data} />
|
<HomeContent data={data} isAnonymous={preferences?.anonymousMode ?? false} />
|
||||||
</HomeClientWrapper>
|
</HomeClientWrapper>
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Si la config Komga est manquante, rediriger vers les settings
|
if (error instanceof AppError && (
|
||||||
if (error instanceof AppError && error.code === ERROR_CODES.KOMGA.MISSING_CONFIG) {
|
error.code === ERROR_CODES.KOMGA.MISSING_CONFIG ||
|
||||||
|
error.code === ERROR_CODES.STRIPSTREAM.MISSING_CONFIG
|
||||||
|
)) {
|
||||||
redirect("/settings");
|
redirect("/settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Afficher une erreur pour les autres cas
|
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.HOME.FETCH_ERROR;
|
||||||
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.KOMGA.SERVER_UNREACHABLE;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
|||||||
@@ -1,32 +1,45 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, type ReactNode } from "react";
|
import { useState, useCallback, type ReactNode } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
|
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
|
||||||
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
|
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
|
||||||
import { RefreshProvider } from "@/contexts/RefreshContext";
|
import { RefreshProvider } from "@/contexts/RefreshContext";
|
||||||
|
import { revalidateForRefresh } from "@/app/actions/refresh";
|
||||||
|
|
||||||
interface SeriesClientWrapperProps {
|
interface SeriesClientWrapperProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
seriesId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REFRESH_ANIMATION_MS = 400;
|
||||||
|
|
||||||
export function SeriesClientWrapper({
|
export function SeriesClientWrapper({
|
||||||
children,
|
children,
|
||||||
|
seriesId,
|
||||||
}: SeriesClientWrapperProps) {
|
}: SeriesClientWrapperProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = useCallback(
|
||||||
|
async (seriesIdArg?: string) => {
|
||||||
|
const id = seriesIdArg ?? seriesId;
|
||||||
try {
|
try {
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
|
if (id) {
|
||||||
|
await revalidateForRefresh("series", id);
|
||||||
|
}
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
await new Promise((r) => setTimeout(r, REFRESH_ANIMATION_MS));
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch {
|
} catch {
|
||||||
return { success: false, error: "Error refreshing series" };
|
return { success: false, error: "Error refreshing series" };
|
||||||
} finally {
|
} finally {
|
||||||
setIsRefreshing(false);
|
setIsRefreshing(false);
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[seriesId, router]
|
||||||
|
);
|
||||||
|
|
||||||
const pullToRefresh = usePullToRefresh({
|
const pullToRefresh = usePullToRefresh({
|
||||||
onRefresh: async () => {
|
onRefresh: async () => {
|
||||||
@@ -44,7 +57,9 @@ export function SeriesClientWrapper({
|
|||||||
canRefresh={pullToRefresh.canRefresh}
|
canRefresh={pullToRefresh.canRefresh}
|
||||||
isHiding={pullToRefresh.isHiding}
|
isHiding={pullToRefresh.isHiding}
|
||||||
/>
|
/>
|
||||||
<RefreshProvider refreshSeries={handleRefresh}>{children}</RefreshProvider>
|
<RefreshProvider refreshSeries={seriesId ? (id) => handleRefresh(id) : undefined}>
|
||||||
|
{children}
|
||||||
|
</RefreshProvider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ import { PaginatedBookGrid } from "@/components/series/PaginatedBookGrid";
|
|||||||
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
import { SeriesHeader } from "@/components/series/SeriesHeader";
|
||||||
import { Container } from "@/components/ui/container";
|
import { Container } from "@/components/ui/container";
|
||||||
import { useRefresh } from "@/contexts/RefreshContext";
|
import { useRefresh } from "@/contexts/RefreshContext";
|
||||||
import type { LibraryResponse } from "@/types/library";
|
import type { NormalizedBooksPage, NormalizedSeries } from "@/lib/providers/types";
|
||||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
|
||||||
import type { UserPreferences } from "@/types/preferences";
|
import type { UserPreferences } from "@/types/preferences";
|
||||||
|
|
||||||
interface SeriesContentProps {
|
interface SeriesContentProps {
|
||||||
series: KomgaSeries;
|
series: NormalizedSeries;
|
||||||
books: LibraryResponse<KomgaBook>;
|
books: NormalizedBooksPage;
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
preferences: UserPreferences;
|
preferences: UserPreferences;
|
||||||
unreadOnly: boolean;
|
unreadOnly: boolean;
|
||||||
@@ -37,10 +36,10 @@ export function SeriesContent({
|
|||||||
/>
|
/>
|
||||||
<Container>
|
<Container>
|
||||||
<PaginatedBookGrid
|
<PaginatedBookGrid
|
||||||
books={books.content || []}
|
books={books.items}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={books.totalPages}
|
totalPages={books.totalPages ?? 1}
|
||||||
totalElements={books.totalElements}
|
totalElements={books.totalElements ?? books.items.length}
|
||||||
defaultShowOnlyUnread={preferences.showOnlyUnread}
|
defaultShowOnlyUnread={preferences.showOnlyUnread}
|
||||||
showOnlyUnread={unreadOnly}
|
showOnlyUnread={unreadOnly}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { PreferencesService } from "@/lib/services/preferences.service";
|
import { PreferencesService } from "@/lib/services/preferences.service";
|
||||||
import { SeriesService } from "@/lib/services/series.service";
|
import { getProvider } from "@/lib/providers/provider.factory";
|
||||||
|
|
||||||
import { FavoriteService } from "@/lib/services/favorite.service";
|
import { FavoriteService } from "@/lib/services/favorite.service";
|
||||||
import { SeriesClientWrapper } from "./SeriesClientWrapper";
|
import { SeriesClientWrapper } from "./SeriesClientWrapper";
|
||||||
import { SeriesContent } from "./SeriesContent";
|
import { SeriesContent } from "./SeriesContent";
|
||||||
@@ -7,6 +8,7 @@ import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
|||||||
import { AppError } from "@/utils/errors";
|
import { AppError } from "@/utils/errors";
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
import type { UserPreferences } from "@/types/preferences";
|
import type { UserPreferences } from "@/types/preferences";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ seriesId: string }>;
|
params: Promise<{ seriesId: string }>;
|
||||||
@@ -18,28 +20,38 @@ const DEFAULT_PAGE_SIZE = 20;
|
|||||||
export default async function SeriesPage({ params, searchParams }: PageProps) {
|
export default async function SeriesPage({ params, searchParams }: PageProps) {
|
||||||
const seriesId = (await params).seriesId;
|
const seriesId = (await params).seriesId;
|
||||||
const page = (await searchParams).page;
|
const page = (await searchParams).page;
|
||||||
const unread = (await searchParams).unread;
|
|
||||||
const size = (await searchParams).size;
|
const size = (await searchParams).size;
|
||||||
|
const unread = (await searchParams).unread;
|
||||||
const currentPage = page ? parseInt(page) : 1;
|
const currentPage = page ? parseInt(page) : 1;
|
||||||
const preferences: UserPreferences = await PreferencesService.getPreferences();
|
const preferences: UserPreferences = await PreferencesService.getPreferences();
|
||||||
|
|
||||||
// Utiliser le paramètre d'URL s'il existe, sinon utiliser la préférence utilisateur
|
|
||||||
const unreadOnly = unread !== undefined ? unread === "true" : preferences.showOnlyUnread;
|
const unreadOnly = unread !== undefined ? unread === "true" : preferences.showOnlyUnread;
|
||||||
const effectivePageSize = size ? parseInt(size) : preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
|
const effectivePageSize = size
|
||||||
|
? parseInt(size)
|
||||||
|
: preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [books, series, isFavorite] = await Promise.all([
|
const provider = await getProvider();
|
||||||
SeriesService.getSeriesBooks(seriesId, currentPage - 1, effectivePageSize, unreadOnly),
|
if (!provider) redirect("/settings");
|
||||||
SeriesService.getSeries(seriesId),
|
|
||||||
|
const [booksPage, series, isFavorite] = await Promise.all([
|
||||||
|
provider.getBooks({
|
||||||
|
seriesName: seriesId,
|
||||||
|
cursor: String(currentPage),
|
||||||
|
limit: effectivePageSize,
|
||||||
|
unreadOnly,
|
||||||
|
}),
|
||||||
|
provider.getSeriesById(seriesId),
|
||||||
FavoriteService.isFavorite(seriesId),
|
FavoriteService.isFavorite(seriesId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (!series) throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SeriesClientWrapper>
|
<SeriesClientWrapper seriesId={seriesId}>
|
||||||
<SeriesContent
|
<SeriesContent
|
||||||
series={series}
|
series={series}
|
||||||
books={books}
|
books={booksPage}
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
preferences={preferences}
|
preferences={preferences}
|
||||||
unreadOnly={unreadOnly}
|
unreadOnly={unreadOnly}
|
||||||
@@ -49,9 +61,15 @@ export default async function SeriesPage({ params, searchParams }: PageProps) {
|
|||||||
</SeriesClientWrapper>
|
</SeriesClientWrapper>
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorCode = error instanceof AppError
|
if (
|
||||||
? error.code
|
error instanceof AppError &&
|
||||||
: ERROR_CODES.BOOK.PAGES_FETCH_ERROR;
|
(error.code === ERROR_CODES.KOMGA.MISSING_CONFIG ||
|
||||||
|
error.code === ERROR_CODES.STRIPSTREAM.MISSING_CONFIG)
|
||||||
|
) {
|
||||||
|
redirect("/settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.BOOK.PAGES_FETCH_ERROR;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { ConfigDBService } from "@/lib/services/config-db.service";
|
import { ConfigDBService } from "@/lib/services/config-db.service";
|
||||||
import { LibraryService } from "@/lib/services/library.service";
|
|
||||||
import { ClientSettings } from "@/components/settings/ClientSettings";
|
import { ClientSettings } from "@/components/settings/ClientSettings";
|
||||||
|
import { getProvider } from "@/lib/providers/provider.factory";
|
||||||
|
import { getStripstreamConfig, getProvidersStatus } from "@/app/actions/stripstream-config";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import type { KomgaConfig, KomgaLibrary } from "@/types/komga";
|
import type { KomgaConfig } from "@/types/komga";
|
||||||
|
import type { NormalizedLibrary } from "@/lib/providers/types";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -14,10 +16,15 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
let config: KomgaConfig | null = null;
|
let config: KomgaConfig | null = null;
|
||||||
let libraries: KomgaLibrary[] = [];
|
let libraries: NormalizedLibrary[] = [];
|
||||||
|
let stripstreamConfig: { url?: string; hasToken: boolean } | null = null;
|
||||||
|
let providersStatus: {
|
||||||
|
komgaConfigured: boolean;
|
||||||
|
stripstreamConfigured: boolean;
|
||||||
|
activeProvider: "komga" | "stripstream";
|
||||||
|
} | undefined = undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Récupérer la configuration Komga
|
|
||||||
const mongoConfig: KomgaConfig | null = await ConfigDBService.getConfig();
|
const mongoConfig: KomgaConfig | null = await ConfigDBService.getConfig();
|
||||||
if (mongoConfig) {
|
if (mongoConfig) {
|
||||||
config = {
|
config = {
|
||||||
@@ -29,11 +36,31 @@ export default async function SettingsPage() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
libraries = await LibraryService.getLibraries();
|
const [provider, stConfig, status] = await Promise.allSettled([
|
||||||
|
getProvider().then((p) => p?.getLibraries() ?? []),
|
||||||
|
getStripstreamConfig(),
|
||||||
|
getProvidersStatus(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (provider.status === "fulfilled") {
|
||||||
|
libraries = provider.value;
|
||||||
|
}
|
||||||
|
if (stConfig.status === "fulfilled") {
|
||||||
|
stripstreamConfig = stConfig.value;
|
||||||
|
}
|
||||||
|
if (status.status === "fulfilled") {
|
||||||
|
providersStatus = status.value;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Erreur lors de la récupération de la configuration:");
|
logger.error({ err: error }, "Erreur lors de la récupération de la configuration:");
|
||||||
// On ne fait rien si la config n'existe pas, on laissera le composant client gérer l'état initial
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ClientSettings initialConfig={config} initialLibraries={libraries} />;
|
return (
|
||||||
|
<ClientSettings
|
||||||
|
initialConfig={config}
|
||||||
|
initialLibraries={libraries}
|
||||||
|
stripstreamConfig={stripstreamConfig}
|
||||||
|
providersStatus={providersStatus}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
@@ -16,7 +15,6 @@ interface LoginFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LoginForm({ from }: LoginFormProps) {
|
export function LoginForm({ from }: LoginFormProps) {
|
||||||
const router = useRouter();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<AppErrorType | null>(null);
|
const [error, setError] = useState<AppErrorType | null>(null);
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
@@ -57,8 +55,7 @@ export function LoginForm({ from }: LoginFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const redirectPath = getSafeRedirectPath(from);
|
const redirectPath = getSafeRedirectPath(from);
|
||||||
window.location.assign(redirectPath);
|
window.location.href = redirectPath;
|
||||||
router.refresh();
|
|
||||||
} catch {
|
} catch {
|
||||||
setError({
|
setError({
|
||||||
code: "AUTH_FETCH_ERROR",
|
code: "AUTH_FETCH_ERROR",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
@@ -16,7 +15,6 @@ interface RegisterFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RegisterForm({ from }: RegisterFormProps) {
|
export function RegisterForm({ from }: RegisterFormProps) {
|
||||||
const router = useRouter();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<AppErrorType | null>(null);
|
const [error, setError] = useState<AppErrorType | null>(null);
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
@@ -77,8 +75,7 @@ export function RegisterForm({ from }: RegisterFormProps) {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const redirectPath = getSafeRedirectPath(from);
|
const redirectPath = getSafeRedirectPath(from);
|
||||||
window.location.assign(redirectPath);
|
window.location.href = redirectPath;
|
||||||
router.refresh();
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError({
|
setError({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import type { KomgaBook } from "@/types/komga";
|
import type { NormalizedBook } from "@/lib/providers/types";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -26,7 +26,7 @@ interface BookDownloadStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DownloadedBook {
|
interface DownloadedBook {
|
||||||
book: KomgaBook;
|
book: NormalizedBook;
|
||||||
status: BookDownloadStatus;
|
status: BookDownloadStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,11 +112,11 @@ export function DownloadManager() {
|
|||||||
};
|
};
|
||||||
}, [loadDownloadedBooks, updateBookStatuses]);
|
}, [loadDownloadedBooks, updateBookStatuses]);
|
||||||
|
|
||||||
const handleDeleteBook = async (book: KomgaBook) => {
|
const handleDeleteBook = async (book: NormalizedBook) => {
|
||||||
try {
|
try {
|
||||||
const cache = await caches.open("stripstream-books");
|
const cache = await caches.open("stripstream-books");
|
||||||
await cache.delete(`/api/komga/images/books/${book.id}/pages`);
|
await cache.delete(`/api/komga/images/books/${book.id}/pages`);
|
||||||
for (let i = 1; i <= book.media.pagesCount; i++) {
|
for (let i = 1; i <= book.pageCount; i++) {
|
||||||
await cache.delete(`/api/komga/images/books/${book.id}/pages/${i}`);
|
await cache.delete(`/api/komga/images/books/${book.id}/pages/${i}`);
|
||||||
}
|
}
|
||||||
localStorage.removeItem(getStorageKey(book.id));
|
localStorage.removeItem(getStorageKey(book.id));
|
||||||
@@ -135,7 +135,7 @@ export function DownloadManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRetryDownload = async (book: KomgaBook) => {
|
const handleRetryDownload = async (book: NormalizedBook) => {
|
||||||
localStorage.removeItem(getStorageKey(book.id));
|
localStorage.removeItem(getStorageKey(book.id));
|
||||||
setDownloadedBooks((prev) => prev.filter((b) => b.book.id !== book.id));
|
setDownloadedBooks((prev) => prev.filter((b) => b.book.id !== book.id));
|
||||||
toast({
|
toast({
|
||||||
@@ -279,7 +279,7 @@ export function DownloadManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface BookDownloadCardProps {
|
interface BookDownloadCardProps {
|
||||||
book: KomgaBook;
|
book: NormalizedBook;
|
||||||
status: BookDownloadStatus;
|
status: BookDownloadStatus;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
@@ -315,8 +315,8 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="relative w-16 aspect-[2/3] bg-muted rounded overflow-hidden flex-shrink-0">
|
<div className="relative w-16 aspect-[2/3] bg-muted rounded overflow-hidden flex-shrink-0">
|
||||||
<Image
|
<Image
|
||||||
src={`/api/komga/images/books/${book.id}/thumbnail`}
|
src={book.thumbnailUrl}
|
||||||
alt={t("books.coverAlt", { title: book.metadata?.title })}
|
alt={t("books.coverAlt", { title: book.title })}
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
fill
|
fill
|
||||||
sizes="64px"
|
sizes="64px"
|
||||||
@@ -330,19 +330,17 @@ function BookDownloadCard({ book, status, onDelete, onRetry }: BookDownloadCardP
|
|||||||
className="hover:underline hover:text-primary transition-colors"
|
className="hover:underline hover:text-primary transition-colors"
|
||||||
>
|
>
|
||||||
<h3 className="font-medium truncate">
|
<h3 className="font-medium truncate">
|
||||||
{book.metadata?.title || t("books.title", { number: book.metadata?.number })}
|
{book.title || t("books.title", { number: book.number ?? "" })}
|
||||||
</h3>
|
</h3>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<span>{formatSize(book.sizeBytes)}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>
|
<span>
|
||||||
{status.status === "downloading"
|
{status.status === "downloading"
|
||||||
? t("downloads.info.pages", {
|
? t("downloads.info.pages", {
|
||||||
current: Math.floor((status.progress * book.media.pagesCount) / 100),
|
current: Math.floor((status.progress * book.pageCount) / 100),
|
||||||
total: book.media.pagesCount,
|
total: book.pageCount,
|
||||||
})
|
})
|
||||||
: t("downloads.info.totalPages", { count: book.media.pagesCount })}
|
: t("downloads.info.totalPages", { count: book.pageCount })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
|||||||