Compare commits

..

48 Commits

Author SHA1 Message Date
d9ffacc124 fix: prevent second page flicker in double page mode when image is already loaded
All checks were successful
Build, Push & Deploy / deploy (push) Successful in 5m15s
Skip resetting loading state to true when the blob URL already exists,
avoiding an unnecessary opacity-0 → opacity-100 CSS transition.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 23:10:31 +01:00
8d1f91d636 feat: optimize Docker startup with Next.js standalone output and proper migrations
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
- Add `output: standalone` to next.config.js for faster cold start
- Rebuild runner stage around standalone bundle (node server.js instead of pnpm start)
- Replace prisma db push with prisma migrate deploy (proper migration workflow)
- Remove npx/pnpm at runtime, use direct binary paths
- Add HOSTNAME=0.0.0.0 for standalone server to listen on all interfaces
- Fix next.config.js not copied in builder stage
- Update README: pnpm instead of yarn, correct ports, full env vars documentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 21:52:49 +01:00
7e4c48469a feat: enhance Stripstream configuration handling
- Introduced a new resolver function to streamline fetching Stripstream configuration from the database or environment variables.
- Updated various components and API routes to utilize the new configuration resolver, improving code maintainability and reducing direct database calls.
- Added optional environment variables for Stripstream URL and token in the .env.example file.
- Refactored image loading logic in the reader components to improve performance and error handling.
2026-03-11 21:25:58 +01:00
e74b02e3a2 feat: add docker push script and DockerHub deployment docs
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 4m24s
2026-03-11 13:33:48 +01:00
7d0f1c4457 feat: add multi-provider support (Komga + Stripstream Librarian)
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
- Introduce provider abstraction layer (IMediaProvider, KomgaProvider, StripstreamProvider)
- Add Stripstream Librarian as second media provider with full feature parity
- Migrate all pages and components from direct Komga services to provider factory
- Remove dead service code (BaseApiService, HomeService, LibraryService, SearchService, TestService)
- Fix library/series page-based pagination for both providers (Komga 0-indexed, Stripstream 1-indexed)
- Fix unread filter and search on library page for both providers
- Fix read progress display for Stripstream (reading_status mapping)
- Fix series read status (books_read_count) for Stripstream
- Add global search with series results for Stripstream (series_hits from Meilisearch)
- Fix thumbnail proxy to return 404 gracefully instead of JSON on upstream error
- Replace duration-based cache debug detection with x-nextjs-cache header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:48:17 +01:00
a1a95775db fix: align book sorting with Komga numberSort
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m19s
2026-03-05 08:45:02 +01:00
3d7ac0c13e feat: add global Komga search autocomplete in header
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m50s
2026-03-04 13:46:02 +01:00
818fe67c99 fix: add ios startup splash coverage for modern devices
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m50s
2026-03-04 08:23:43 +01:00
06848d2c3a feat: apply new branding logo across app and pwa assets 2026-03-04 08:21:25 +01:00
4e8c8ebac0 fix: make next-book lookup non-blocking when opening reader 2026-03-04 07:05:04 +01:00
23fa884af7 fix: restore reader direction and double-page navigation UI
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m6s
2026-03-04 06:49:40 +01:00
6a06e5a7d3 fix: disable service worker by default in production
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m6s
2026-03-02 21:20:47 +01:00
3e5687441d fix: improve reader image error handling and double page alignment
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m54s
- Show a clean placeholder (icon + label) instead of the browser's broken image icon when a page fails to load
- Track error state per page (page 1 and page 2) and reset on page navigation
- Center each page within its half in double page mode instead of pushing toward the spine

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 16:03:25 +01:00
99d9f41299 feat: refresh buttons invalidate cache and show spinner during refresh
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m56s
- Add revalidateForRefresh(scope, id) server action for home/library/series
- Library/Series wrappers: revalidate cache then router.refresh(), 400ms delay for animation
- Home: revalidate home-data + path before refresh
- RefreshButton uses refreshLibrary from RefreshContext when not passed as prop
- Library/Series pages pass id to wrapper for context and pull-to-refresh
- read-progress: pass 'max' to revalidateTag for Next 16 types

Made-with: Cursor
2026-03-02 13:38:45 +01:00
30e3529be3 fix: invalidate library series cache when read progress changes
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 48s
- Add LIBRARY_SERIES_CACHE_TAG to getLibrarySeries fetch
- Revalidate library-series tag in updateReadProgress and deleteReadProgress
- Add eslint ignores for temp/, .next/, node_modules/

Made-with: Cursor
2026-03-02 13:27:59 +01:00
4288e4c541 feat: polish app loading screen and home section emphasis
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m52s
Refine the global loading experience to feel smoother and less flashy while keeping brand accents. Simplify the home continue-reading highlight by styling the section header instead of using a heavy card wrapper.
2026-03-01 22:01:56 +01:00
fdc9da7f8f fix: support service worker toggle in prod and dev
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m5s
2026-03-01 21:41:33 +01:00
4441c59584 fix: close reader immediately while cancelling prefetches
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m21s
2026-03-01 21:36:40 +01:00
fead5ff6a0 fix: stop lingering reader prefetches from blocking navigation
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m58s
2026-03-01 21:14:45 +01:00
e6fe5ac27f fix: harden offline fallback and track visitable pages
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m36s
2026-03-01 18:33:11 +01:00
c704e24a53 fix: always show service worker cache toggle in settings 2026-03-01 13:28:55 +01:00
5a3b0ace61 fix: improve service worker offline flow and dev toggle UX 2026-03-01 12:47:58 +01:00
844cd3f58e docs: enforce server-first RSC and actions guidance 2026-03-01 12:37:11 +01:00
6a1f208e66 Reduce top spacing before first home carousel 2026-03-01 12:35:16 +01:00
b8961b85c5 fix: reduce unauthenticated log noise and add request path context
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m46s
2026-02-28 22:18:55 +01:00
175 changed files with 5182 additions and 2107 deletions

View File

@@ -6,3 +6,7 @@ MONGODB_URI=mongodb://admin:password@host.docker.internal:27017/stripstream?auth
NEXTAUTH_SECRET=SECRET
#openssl rand -base64 32
NEXTAUTH_URL=http://localhost:3000
# Stripstream Librarian (optionnel : fallback si l'utilisateur n'a pas sauvegardé d'URL/token en base)
# STRIPSTREAM_URL=https://librarian.example.com
# STRIPSTREAM_TOKEN=stl_xxxx_xxxxxxxx

View File

@@ -1,26 +1,33 @@
name: Deploy with Docker Compose
name: Build, Push & Deploy
on:
push:
branches:
- main # adapte la branche que tu veux déployer
- main
jobs:
deploy:
runs-on: mac-orbstack-runner # le nom que tu as donné au runner
runs-on: mac-orbstack-runner
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy stack
- name: Login to DockerHub
run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Build Docker image
env:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL }}
ADMIN_DEFAULT_PASSWORD: ${{ secrets.ADMIN_DEFAULT_PASSWORD }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
PRISMA_DATA_PATH: ${{ vars.PRISMA_DATA_PATH }}
NODE_ENV: production
run: docker build -t julienfroidefond32/stripstream:latest .
- name: Push to DockerHub
run: docker push julienfroidefond32/stripstream:latest
- name: Pull new image and restart container
run: |
docker compose up -d --build
docker pull julienfroidefond32/stripstream:latest
cd /Users/julienfroidefond/Sites/docker-stack
./scripts/stack.sh up stripstream
- name: Cleanup old images
run: docker image prune -f

52
AGENTS.md Normal file
View 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.

View File

@@ -17,7 +17,7 @@ COPY package.json pnpm-lock.yaml ./
COPY prisma ./prisma
# Copy configuration files
COPY tsconfig.json .eslintrc.json ./
COPY tsconfig.json .eslintrc.json next.config.js ./
COPY tailwind.config.ts postcss.config.js ./
# Install dependencies with pnpm using cache mount for store
@@ -43,22 +43,20 @@ WORKDIR /app
# Install OpenSSL (required by Prisma)
RUN apk add --no-cache openssl libc6-compat
# Copy package files and prisma schema
COPY package.json pnpm-lock.yaml ./
COPY prisma ./prisma
# Copy standalone output (server.js + minimal node_modules)
COPY --from=builder /app/.next/standalone ./
# Enable pnpm
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
# Copy static assets and public directory
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
# Copy the entire node_modules from builder (includes Prisma Client)
# Copy full node_modules for Prisma CLI (pnpm symlinks prevent cherry-picking)
COPY --from=builder /app/node_modules ./node_modules
# Copy built application from builder stage
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/next-env.d.ts ./
COPY --from=builder /app/tailwind.config.ts ./
# Copy prisma schema and init scripts
COPY prisma ./prisma
COPY --from=builder /app/scripts ./scripts
COPY package.json ./
# Copy entrypoint script
COPY docker-entrypoint.sh ./
@@ -76,6 +74,7 @@ USER nextjs
# Set environment variables
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME="0.0.0.0"
# Expose the port the app runs on
EXPOSE 3000

View File

@@ -74,7 +74,7 @@ A modern web application for reading digital comics, built with Next.js 14 and t
## 🛠 Prerequisites
- Node.js 20.x or higher
- Yarn 1.22.x or higher
- pnpm 9.x or higher
- Docker and Docker Compose (optional)
## 📦 Installation
@@ -91,7 +91,7 @@ cd stripstream
2. Install dependencies
```bash
yarn install
pnpm install
```
3. Copy the example environment file and adjust it to your needs
@@ -103,10 +103,10 @@ cp .env.example .env.local
4. Start the development server
```bash
yarn dev
pnpm dev
```
### With Docker
### With Docker (Build Local)
1. Clone the repository and navigate to the folder
@@ -121,15 +121,65 @@ cd stripstream
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`
## 🔧 Available Scripts
- `yarn dev` - Starts the development server
- `yarn build` - Creates a production build
- `yarn start` - Runs the production version
- `yarn lint` - Checks code with ESLint
- `yarn format` - Formats code with Prettier
- `pnpm dev` - Starts the development server
- `pnpm build` - Creates a production build
- `pnpm start` - Runs the production version
- `pnpm lint` - Checks code with ESLint
- `./docker-push.sh [tag]` - Build and push Docker image to DockerHub (default tag: `latest`)
### Docker Push Script
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

View File

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

View File

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

24
docker-push.sh Executable file
View File

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

View File

@@ -4,6 +4,7 @@ import nextTypescript from "eslint-config-next/typescript";
import unusedImports from "eslint-plugin-unused-imports";
export default defineConfig([
{ ignores: ["temp/**", ".next/**", "node_modules/**"] },
...nextCoreWebVitals,
...nextTypescript,
{

View File

@@ -1,5 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
webpack: (config) => {
config.resolve.fallback = {
...config.resolve.fallback,

View File

@@ -0,0 +1,77 @@
-- CreateTable
CREATE TABLE "users" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"roles" JSONB NOT NULL DEFAULT ["ROLE_USER"],
"authenticated" BOOLEAN NOT NULL DEFAULT true,
"activeProvider" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "komgaconfigs" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"userId" INTEGER NOT NULL,
"url" TEXT NOT NULL,
"username" TEXT NOT NULL,
"authHeader" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "komgaconfigs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "stripstreamconfigs" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"userId" INTEGER NOT NULL,
"url" TEXT NOT NULL,
"token" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "stripstreamconfigs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "preferences" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"userId" INTEGER NOT NULL,
"showThumbnails" BOOLEAN NOT NULL DEFAULT true,
"showOnlyUnread" BOOLEAN NOT NULL DEFAULT false,
"displayMode" JSONB NOT NULL,
"background" JSONB NOT NULL,
"readerPrefetchCount" INTEGER NOT NULL DEFAULT 5,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "preferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "favorites" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"userId" INTEGER NOT NULL,
"seriesId" TEXT NOT NULL,
"provider" TEXT NOT NULL DEFAULT 'komga',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "favorites_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "komgaconfigs_userId_key" ON "komgaconfigs"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "stripstreamconfigs_userId_key" ON "stripstreamconfigs"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "preferences_userId_key" ON "preferences"("userId");
-- CreateIndex
CREATE INDEX "favorites_userId_idx" ON "favorites"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "favorites_userId_provider_seriesId_key" ON "favorites"("userId", "provider", "seriesId");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"

View File

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

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 895 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 6.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@@ -5,75 +5,417 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hors ligne - StripStream</title>
<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 {
margin: 0;
padding: 0;
font-family:
system-ui,
"Segoe UI",
"SF Pro Text",
-apple-system,
BlinkMacSystemFont,
sans-serif;
background-color: #0f172a;
color: #e2e8f0;
background: var(--bg);
color: var(--text);
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;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 1rem;
padding: 2rem;
min-height: calc(100vh - 64px);
}
.container {
max-width: 600px;
margin: 0 auto;
.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;
}
h1 {
font-size: 2rem;
.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;
color: #4f46e5;
}
h1 {
margin: 0 0 0.7rem;
font-size: clamp(1.35rem, 2.4vw, 1.95rem);
line-height: 1.24;
}
p {
font-size: 1.1rem;
line-height: 1.5;
color: #94a3b8;
margin-bottom: 2rem;
margin: 0;
color: var(--muted);
line-height: 1.6;
}
.buttons {
.actions {
display: flex;
gap: 1rem;
justify-content: center;
gap: 0.7rem;
margin-top: 1.35rem;
}
button {
background-color: #4f46e5;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-size: 1rem;
.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;
transition: background-color 0.2s ease;
}
button:hover {
background-color: #4338ca;
.btn-primary {
background: var(--primary);
color: #fff;
}
button.secondary {
background-color: #475569;
.btn-secondary {
background: rgba(2, 8, 23, 0.45);
border-color: rgba(148, 163, 184, 0.35);
color: var(--text);
}
button.secondary:hover {
background-color: #334155;
.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;
}
.actions {
flex-direction: column;
}
}
</style>
</head>
<body>
<div class="container">
<h1>Vous êtes hors ligne</h1>
<p>
Il semble que vous n'ayez pas de connexion internet. Certaines fonctionnalités de
StripStream peuvent ne pas être disponibles en mode hors ligne.
</p>
<div class="buttons">
<button class="secondary" onclick="window.history.back()">Retour</button>
<button onclick="window.location.reload()">Réessayer</button>
<header class="header">
<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>
Tu peux continuer a naviguer sur les pages deja consultees. Cette route sera disponible
hors ligne apres une visite en ligne.
</p>
<div class="actions">
<button class="btn btn-secondary" onclick="window.history.back()">Retour</button>
<button class="btn btn-primary" onclick="window.location.reload()">Reessayer</button>
</div>
<div class="hint">
Astuce: visite d'abord Accueil, Bibliotheques, Series et pages de lecture quand tu es en
ligne.
</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>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -3,75 +3,100 @@ const fs = require("fs").promises;
const path = require("path");
const sizes = [72, 96, 128, 144, 152, 192, 384, 512];
const inputSvg = path.join(__dirname, "../public/favicon.svg");
const inputAppleSvg = path.join(__dirname, "../public/apple-icon.svg");
const sourceLogo = path.join(__dirname, "../public/images/logostripstream.png");
const outputDir = path.join(__dirname, "../public/images/icons");
const screenshotsDir = path.join(__dirname, "../public/images/screenshots");
const splashDir = path.join(__dirname, "../public/images/splash");
const faviconPath = path.join(__dirname, "../public/favicon.png");
// Source pour les splash screens
const splashSource = path.join(__dirname, "../public/images/Gemini_Generated_Image_wyfsoiwyfsoiwyfs.png");
// Configuration des splashscreens pour différents appareils
const splashScreens = [
{ width: 2048, height: 2732, name: "iPad Pro 12.9" }, // iPad Pro 12.9
{ width: 1668, height: 2388, name: "iPad Pro 11" }, // iPad Pro 11
{ width: 1536, height: 2048, name: "iPad Mini/Air" }, // iPad Mini, Air
{ width: 1125, height: 2436, name: "iPhone X/XS" }, // iPhone X/XS
{ width: 1242, height: 2688, name: "iPhone XS Max" }, // iPhone XS Max
{ width: 828, height: 1792, name: "iPhone XR" }, // iPhone XR
{ width: 750, height: 1334, name: "iPhone 8/SE" }, // iPhone 8, SE
{ width: 1242, height: 2208, name: "iPhone 8 Plus" }, // iPhone 8 Plus
// iPad (portrait + landscape)
{ width: 2048, height: 2732, name: "iPad Pro 12.9 portrait" },
{ width: 2732, height: 2048, name: "iPad Pro 12.9 landscape" },
{ width: 1668, height: 2388, name: "iPad Pro 11 portrait" },
{ width: 2388, height: 1668, name: "iPad Pro 11 landscape" },
{ width: 1668, height: 2420, name: "iPad Pro 11 M4 portrait" },
{ width: 2420, height: 1668, name: "iPad Pro 11 M4 landscape" },
{ width: 2064, height: 2752, name: "iPad Pro 13 M4 portrait" },
{ width: 2752, height: 2064, name: "iPad Pro 13 M4 landscape" },
{ width: 1536, height: 2048, name: "iPad Mini/Air portrait" },
{ width: 2048, height: 1536, name: "iPad Mini/Air landscape" },
{ width: 1488, height: 2266, name: "iPad Mini 6 portrait" },
{ width: 2266, height: 1488, name: "iPad Mini 6 landscape" },
{ width: 1620, height: 2160, name: "iPad 10.2 portrait" },
{ width: 2160, height: 1620, name: "iPad 10.2 landscape" },
{ width: 1640, height: 2360, name: "iPad Air 10.9 portrait" },
{ 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() {
await fs.mkdir(splashDir, { recursive: true });
// 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>
`;
console.log(`\n📱 Génération des splash screens...`);
for (const screen of splashScreens) {
const outputPath = path.join(splashDir, `splash-${screen.width}x${screen.height}.png`);
await sharp(Buffer.from(splashSvg))
await sharp(splashSource)
.resize(screen.width, screen.height, {
fit: "contain",
background: "#4F46E5",
fit: "cover",
position: "center",
})
.png({
compressionLevel: 9,
})
.png()
.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() {
try {
await fs.access(sourceLogo);
// Créer les dossiers de sortie s'ils n'existent pas
await fs.mkdir(outputDir, { 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) {
const outputPath = path.join(outputDir, `icon-${size}x${size}.png`);
await sharp(inputSvg)
await sharp(sourceLogo)
.resize(size, size, {
fit: "contain",
fit: "cover",
background: { r: 0, g: 0, b: 0, alpha: 0 }, // Fond transparent
})
.png({
@@ -88,9 +113,9 @@ async function generateIcons() {
for (const size of appleSizes) {
const outputPath = path.join(outputDir, `apple-icon-${size}x${size}.png`);
await sharp(inputAppleSvg)
await sharp(sourceLogo)
.resize(size, size, {
fit: "contain",
fit: "cover",
background: { r: 0, g: 0, b: 0, alpha: 0 }, // Fond transparent
})
.png({
@@ -102,26 +127,25 @@ async function generateIcons() {
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
const shortcutIcons = [
{ name: "home", icon: "Home" },
{ name: "library", icon: "Library" },
];
const shortcutIcons = ["home", "library"];
for (const shortcut of shortcutIcons) {
const outputPath = path.join(outputDir, `${shortcut.name}.png`);
// 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))
const outputPath = path.join(outputDir, `${shortcut}.png`);
await sharp(sourceLogo)
.resize(96, 96)
.png({
compressionLevel: 9,
@@ -129,7 +153,7 @@ async function generateIcons() {
})
.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
@@ -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();

View File

@@ -15,7 +15,7 @@ export default async function AccountPage() {
return (
<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>
<h1 className="text-3xl font-bold">Mon compte</h1>
<p className="text-muted-foreground mt-2">

View File

@@ -1,30 +1,40 @@
"use server";
import { BookService } from "@/lib/services/book.service";
import { getProvider } from "@/lib/providers/provider.factory";
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 {
success: boolean;
data?: {
book: KomgaBook;
book: NormalizedBook;
pages: number[];
nextBook: KomgaBook | null;
nextBook: NormalizedBook | null;
};
message?: string;
}
export async function getBookData(bookId: string): Promise<BookDataResult> {
try {
const data = await BookService.getBook(bookId);
const nextBook = await BookService.getNextBook(bookId, data.book.seriesId);
const provider = await getProvider();
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 {
success: true,
data: {
...data,
nextBook,
},
data: { book, pages, nextBook },
};
} catch (error) {
if (error instanceof AppError) {

View File

@@ -2,8 +2,8 @@
import { revalidatePath } from "next/cache";
import { ConfigDBService } from "@/lib/services/config-db.service";
import { TestService } from "@/lib/services/test.service";
import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
import type { KomgaConfig, KomgaConfigData, KomgaLibrary } from "@/types/komga";
interface SaveConfigInput {
@@ -13,9 +13,6 @@ interface SaveConfigInput {
authHeader?: string;
}
/**
* Teste la connexion à Komga
*/
export async function testKomgaConnection(
serverUrl: string,
username: string,
@@ -23,27 +20,31 @@ export async function testKomgaConnection(
): Promise<{ success: boolean; message: string }> {
try {
const authHeader = Buffer.from(`${username}:${password}`).toString("base64");
const { libraries }: { libraries: KomgaLibrary[] } = await TestService.testConnection({
serverUrl,
authHeader,
const url = new URL(`${serverUrl}/api/v1/libraries`).toString();
const headers = new Headers({
Authorization: `Basic ${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 {
success: true,
message: `Connexion réussie ! ${libraries.length} bibliothèque${libraries.length > 1 ? "s" : ""} trouvée${libraries.length > 1 ? "s" : ""}`,
};
} catch (error) {
if (error instanceof AppError) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors de la connexion" };
}
}
/**
* Sauvegarde la configuration Komga
*/
export async function saveKomgaConfig(
config: SaveConfigInput
): Promise<{ success: boolean; message: string; data?: KomgaConfig }> {
@@ -55,17 +56,10 @@ export async function saveKomgaConfig(
authHeader: config.authHeader || "",
};
const mongoConfig = await ConfigDBService.saveConfig(configData);
// Invalider le cache
revalidatePath("/settings");
return {
success: true,
message: "Configuration sauvegardée",
data: mongoConfig,
};
return { success: true, message: "Configuration sauvegardée", data: mongoConfig };
} catch (error) {
if (error instanceof AppError) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors de la sauvegarde" };

View File

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

View File

@@ -1,25 +1,27 @@
"use server";
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";
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(
bookId: string,
page: number,
completed: boolean = false
): Promise<{ success: boolean; message: string }> {
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)
revalidateTag(HOME_CACHE_TAG, "max");
await provider.saveReadProgress(bookId, page, completed);
revalidateReadCaches();
return { success: true, message: "Progression mise à jour" };
} catch (error) {
@@ -30,17 +32,15 @@ export async function updateReadProgress(
}
}
/**
* Supprime la progression de lecture d'un livre
*/
export async function deleteReadProgress(
bookId: string
): Promise<{ success: boolean; message: string }> {
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)
revalidateTag(HOME_CACHE_TAG, "max");
await provider.resetReadProgress(bookId);
revalidateReadCaches();
return { success: true, message: "Progression supprimée" };
} catch (error) {

View 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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,27 +1,40 @@
import { Suspense } from "react";
import { ClientBookPage } from "@/components/reader/ClientBookPage";
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 { AppError } from "@/utils/errors";
import { redirect } from "next/navigation";
import logger from "@/lib/logger";
export default async function BookPage({ params }: { params: Promise<{ bookId: string }> }) {
const { bookId } = await params;
try {
// SSR: Fetch directly on server instead of client-side XHR
const data = await BookService.getBook(bookId);
const nextBook = await BookService.getNextBook(bookId, data.book.seriesId);
const provider = await getProvider();
if (!provider) redirect("/settings");
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 (
<Suspense fallback={<BookSkeleton />}>
<ClientBookPage bookId={bookId} initialData={{ ...data, nextBook }} />
<ClientBookPage bookId={bookId} initialData={{ book, pages, nextBook }} />
</Suspense>
);
} catch (error) {
// 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");
}

View File

@@ -5,12 +5,13 @@ import { cn } from "@/lib/utils";
import ClientLayout from "@/components/layout/ClientLayout";
import { PreferencesService } from "@/lib/services/preferences.service";
import { PreferencesProvider } from "@/contexts/PreferencesContext";
import { AnonymousProvider } from "@/contexts/AnonymousContext";
import { I18nProvider } from "@/components/providers/I18nProvider";
import { AuthProvider } from "@/components/providers/AuthProvider";
import { cookies } from "next/headers";
import { cookies, headers } from "next/headers";
import { defaultPreferences } 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";
const inter = Inter({
@@ -36,8 +37,8 @@ export const metadata: Metadata = {
icons: {
icon: [
{
url: "/favicon.svg",
type: "image/svg+xml",
url: "/favicon.png",
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" },
@@ -70,38 +71,49 @@ export const metadata: Metadata = {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies();
const requestHeaders = await headers();
const locale = cookieStore.get("NEXT_LOCALE")?.value || "fr";
const requestPath = requestHeaders.get("x-request-path") || "unknown";
const requestPathname = requestHeaders.get("x-request-pathname") || "unknown";
let preferences: UserPreferences = defaultPreferences;
let userIsAdmin = false;
let libraries: KomgaLibrary[] = [];
let favorites: KomgaSeries[] = [];
let libraries: NormalizedLibrary[] = [];
let favorites: NormalizedSeries[] = [];
try {
const [preferencesData, isAdminCheck, librariesData, favoritesData] = await Promise.allSettled([
PreferencesService.getPreferences(),
import("@/lib/auth-utils").then((m) => m.isAdmin()),
import("@/lib/services/library.service").then((m) => m.LibraryService.getLibraries()),
import("@/lib/services/favorites.service").then((m) => m.FavoritesService.getFavorites()),
]);
const currentUser = await import("@/lib/auth-utils").then((m) => m.getCurrentUser());
if (preferencesData.status === "fulfilled") {
preferences = preferencesData.value;
}
if (currentUser) {
const [preferencesData, librariesData, favoritesData] = await Promise.allSettled([
PreferencesService.getPreferences(),
import("@/lib/providers/provider.factory")
.then((m) => m.getProvider())
.then((provider) => provider?.getLibraries() ?? []),
import("@/lib/services/favorites.service").then((m) =>
m.FavoritesService.getFavorites({ requestPath, requestPathname })
),
]);
if (isAdminCheck.status === "fulfilled") {
userIsAdmin = isAdminCheck.value;
}
userIsAdmin = currentUser.roles.includes("ROLE_ADMIN");
if (librariesData.status === "fulfilled") {
libraries = librariesData.value || [];
}
if (preferencesData.status === "fulfilled") {
preferences = preferencesData.value;
}
if (favoritesData.status === "fulfilled") {
favorites = favoritesData.value;
if (librariesData.status === "fulfilled") {
libraries = librariesData.value || [];
}
if (favoritesData.status === "fulfilled") {
favorites = favoritesData.value;
}
}
} catch (error) {
logger.error({ err: error }, "Erreur lors du chargement des données initiales:");
logger.error(
{ err: error, requestPath, requestPathname },
"Erreur lors du chargement des données initiales:"
);
}
return (
@@ -122,41 +134,176 @@ export default async function RootLayout({ children }: { children: React.ReactNo
href="/images/splash/splash-2048x2732.png"
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
rel="apple-touch-startup-image"
href="/images/splash/splash-1668x2388.png"
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
rel="apple-touch-startup-image"
href="/images/splash/splash-1536x2048.png"
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
rel="apple-touch-startup-image"
href="/images/splash/splash-1125x2436.png"
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
rel="apple-touch-startup-image"
href="/images/splash/splash-1242x2688.png"
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
rel="apple-touch-startup-image"
href="/images/splash/splash-828x1792.png"
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
rel="apple-touch-startup-image"
href="/images/splash/splash-750x1334.png"
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
rel="apple-touch-startup-image"
href="/images/splash/splash-1242x2208.png"
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>
<body
className={cn(
@@ -167,9 +314,15 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<AuthProvider>
<I18nProvider locale={locale}>
<PreferencesProvider initialPreferences={preferences}>
<ClientLayout initialLibraries={libraries} initialFavorites={favorites} userIsAdmin={userIsAdmin}>
{children}
</ClientLayout>
<AnonymousProvider>
<ClientLayout
initialLibraries={libraries}
initialFavorites={favorites}
userIsAdmin={userIsAdmin}
>
{children}
</ClientLayout>
</AnonymousProvider>
</PreferencesProvider>
</I18nProvider>
</AuthProvider>

View File

@@ -1,29 +1,44 @@
"use client";
import { useState, type ReactNode } from "react";
import { useState, useCallback, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
import { RefreshProvider } from "@/contexts/RefreshContext";
import { revalidateForRefresh } from "@/app/actions/refresh";
interface LibraryClientWrapperProps {
children: ReactNode;
libraryId?: string;
}
export function LibraryClientWrapper({ children }: LibraryClientWrapperProps) {
const REFRESH_ANIMATION_MS = 400;
export function LibraryClientWrapper({ children, libraryId }: LibraryClientWrapperProps) {
const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => {
try {
setIsRefreshing(true);
router.refresh();
return { success: true };
} catch {
return { success: false, error: "Error refreshing library" };
} finally {
setIsRefreshing(false);
}
};
const handleRefresh = useCallback(
async (libraryIdArg?: string) => {
const id = libraryIdArg ?? libraryId;
if (!id) {
router.refresh();
return { success: true };
}
try {
setIsRefreshing(true);
await revalidateForRefresh("library", id);
router.refresh();
await new Promise((r) => setTimeout(r, REFRESH_ANIMATION_MS));
return { success: true };
} catch {
return { success: false, error: "Error refreshing library" };
} finally {
setIsRefreshing(false);
}
},
[libraryId, router]
);
const pullToRefresh = usePullToRefresh({
onRefresh: async () => {
@@ -33,7 +48,9 @@ export function LibraryClientWrapper({ children }: LibraryClientWrapperProps) {
});
return (
<>
<RefreshProvider
refreshLibrary={libraryId ? (id) => handleRefresh(id) : undefined}
>
<PullToRefreshIndicator
isPulling={pullToRefresh.isPulling}
isRefreshing={pullToRefresh.isRefreshing || isRefreshing}
@@ -42,6 +59,6 @@ export function LibraryClientWrapper({ children }: LibraryClientWrapperProps) {
isHiding={pullToRefresh.isHiding}
/>
{children}
</>
</RefreshProvider>
);
}

View File

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

View File

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

59
src/app/loading.tsx Normal file
View 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>
);
}

View File

@@ -43,21 +43,21 @@ export function LoginContent({ searchParams }: LoginContentProps) {
transition={{ duration: 0.6, delay: 0.2 }}
className="relative z-20 flex items-center text-lg font-medium"
>
<motion.svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2 h-6 w-6"
whileHover={{ rotate: 180 }}
<motion.img
src="/images/logostripstream.png"
alt="StripStream Logo"
className="mr-3 hidden h-9 w-9 rounded-md object-cover dark:block"
whileHover={{ scale: 1.08, rotate: -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.svg>
<span className="text-2xl font-bold bg-gradient-to-r from-white to-gray-300 bg-clip-text text-transparent">
/>
<motion.img
src="/images/logostripstream-white.png"
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
</span>
</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="flex flex-col items-center space-y-4 text-center">
<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-24 h-24 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-32 h-32 flex items-center justify-center">
<motion.img
src="/images/icons/apple-icon-180x180.png"
src="/images/logostripstream.png"
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 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.5 }}
@@ -99,7 +107,7 @@ export function LoginContent({ searchParams }: LoginContentProps) {
<motion.h1
initial={{ y: -20 }}
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")}
</motion.h1>

View File

@@ -1,28 +1,40 @@
import { HomeService } from "@/lib/services/home.service";
import { getProvider } from "@/lib/providers/provider.factory";
import { HomeContent } from "@/components/home/HomeContent";
import { HomeClientWrapper } from "@/components/home/HomeClientWrapper";
import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { ERROR_CODES } from "@/constants/errorCodes";
import { AppError } from "@/utils/errors";
import { FavoritesService } from "@/lib/services/favorites.service";
import { PreferencesService } from "@/lib/services/preferences.service";
import { redirect } from "next/navigation";
export default async function HomePage() {
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 (
<HomeClientWrapper>
<HomeContent data={data} />
<HomeContent data={data} isAnonymous={preferences?.anonymousMode ?? false} />
</HomeClientWrapper>
);
} catch (error) {
// Si la config Komga est manquante, rediriger vers les 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");
}
// Afficher une erreur pour les autres cas
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.KOMGA.SERVER_UNREACHABLE;
const errorCode = error instanceof AppError ? error.code : ERROR_CODES.HOME.FETCH_ERROR;
return (
<main className="container mx-auto px-4 py-8">

View File

@@ -1,32 +1,45 @@
"use client";
import { useState, type ReactNode } from "react";
import { useState, useCallback, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
import { RefreshProvider } from "@/contexts/RefreshContext";
import { revalidateForRefresh } from "@/app/actions/refresh";
interface SeriesClientWrapperProps {
children: ReactNode;
seriesId?: string;
}
const REFRESH_ANIMATION_MS = 400;
export function SeriesClientWrapper({
children,
seriesId,
}: SeriesClientWrapperProps) {
const router = useRouter();
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => {
try {
setIsRefreshing(true);
router.refresh();
return { success: true };
} catch {
return { success: false, error: "Error refreshing series" };
} finally {
setIsRefreshing(false);
}
};
const handleRefresh = useCallback(
async (seriesIdArg?: string) => {
const id = seriesIdArg ?? seriesId;
try {
setIsRefreshing(true);
if (id) {
await revalidateForRefresh("series", id);
}
router.refresh();
await new Promise((r) => setTimeout(r, REFRESH_ANIMATION_MS));
return { success: true };
} catch {
return { success: false, error: "Error refreshing series" };
} finally {
setIsRefreshing(false);
}
},
[seriesId, router]
);
const pullToRefresh = usePullToRefresh({
onRefresh: async () => {
@@ -44,7 +57,9 @@ export function SeriesClientWrapper({
canRefresh={pullToRefresh.canRefresh}
isHiding={pullToRefresh.isHiding}
/>
<RefreshProvider refreshSeries={handleRefresh}>{children}</RefreshProvider>
<RefreshProvider refreshSeries={seriesId ? (id) => handleRefresh(id) : undefined}>
{children}
</RefreshProvider>
</>
);
}

View File

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

View File

@@ -1,5 +1,6 @@
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 { SeriesClientWrapper } from "./SeriesClientWrapper";
import { SeriesContent } from "./SeriesContent";
@@ -7,6 +8,7 @@ import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
import type { UserPreferences } from "@/types/preferences";
import { redirect } from "next/navigation";
interface PageProps {
params: Promise<{ seriesId: string }>;
@@ -18,28 +20,38 @@ const DEFAULT_PAGE_SIZE = 20;
export default async function SeriesPage({ params, searchParams }: PageProps) {
const seriesId = (await params).seriesId;
const page = (await searchParams).page;
const unread = (await searchParams).unread;
const size = (await searchParams).size;
const unread = (await searchParams).unread;
const currentPage = page ? parseInt(page) : 1;
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 effectivePageSize = size ? parseInt(size) : preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
const effectivePageSize = size
? parseInt(size)
: preferences.displayMode?.itemsPerPage || DEFAULT_PAGE_SIZE;
try {
const [books, series, isFavorite] = await Promise.all([
SeriesService.getSeriesBooks(seriesId, currentPage - 1, effectivePageSize, unreadOnly),
SeriesService.getSeries(seriesId),
const provider = await getProvider();
if (!provider) redirect("/settings");
const [booksPage, series, isFavorite] = await Promise.all([
provider.getBooks({
seriesName: seriesId,
cursor: String(currentPage),
limit: effectivePageSize,
unreadOnly,
}),
provider.getSeriesById(seriesId),
FavoriteService.isFavorite(seriesId),
]);
if (!series) throw new AppError(ERROR_CODES.SERIES.FETCH_ERROR);
return (
<SeriesClientWrapper>
<SeriesClientWrapper seriesId={seriesId}>
<SeriesContent
series={series}
books={books}
books={booksPage}
currentPage={currentPage}
preferences={preferences}
unreadOnly={unreadOnly}
@@ -49,9 +61,15 @@ export default async function SeriesPage({ params, searchParams }: PageProps) {
</SeriesClientWrapper>
);
} catch (error) {
const errorCode = error instanceof AppError
? error.code
: ERROR_CODES.BOOK.PAGES_FETCH_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.BOOK.PAGES_FETCH_ERROR;
return (
<main className="container mx-auto px-4 py-8">

View File

@@ -1,8 +1,10 @@
import { ConfigDBService } from "@/lib/services/config-db.service";
import { LibraryService } from "@/lib/services/library.service";
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 { KomgaConfig, KomgaLibrary } from "@/types/komga";
import type { KomgaConfig } from "@/types/komga";
import type { NormalizedLibrary } from "@/lib/providers/types";
import logger from "@/lib/logger";
export const dynamic = "force-dynamic";
@@ -14,10 +16,15 @@ export const metadata: Metadata = {
export default async function SettingsPage() {
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 {
// Récupérer la configuration Komga
const mongoConfig: KomgaConfig | null = await ConfigDBService.getConfig();
if (mongoConfig) {
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) {
logger.error({ err: error }, "Erreur lors de la récupération de la configuration:");
// On ne fait rien si la config n'existe pas, on laissera le composant client gérer l'état initial
}
return <ClientSettings initialConfig={config} initialLibraries={libraries} />;
return (
<ClientSettings
initialConfig={config}
initialLibraries={libraries}
stripstreamConfig={stripstreamConfig}
providersStatus={providersStatus}
/>
);
}

View File

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

View File

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

View File

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

View File

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

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