Compare commits

..

88 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:48:17 +01:00
a1a95775db fix: align book sorting with Komga numberSort
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m19s
2026-03-05 08:45:02 +01:00
3d7ac0c13e feat: add global Komga search autocomplete in header
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m50s
2026-03-04 13:46:02 +01:00
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
8e7c46de23 refactor: unify and enrich default app background styling
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m56s
2026-02-28 22:07:29 +01:00
dc9f90f78f fix: preserve custom backgrounds and home fallback layering 2026-02-28 22:05:07 +01:00
0cb51ce99d fix: improve account password autofill semantics and settings layout
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m11s
2026-02-28 21:44:28 +01:00
41faa30453 feat: review series search panel 2026-02-28 21:37:39 +01:00
25ede2532e refactor: polish reader chrome and visual immersion 2026-02-28 21:15:03 +01:00
6ce8a6e38d refactor: refine home and library visual hierarchy 2026-02-28 21:11:07 +01:00
83212434f2 refactor: refresh shell UI styling and interaction polish 2026-02-28 18:45:54 +01:00
9b679a4db2 fix: harden auth form sign-in flow and redirect reliability 2026-02-28 18:25:10 +01:00
01951c806d refactor: make library rendering server-first and deterministic
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m7s
Move library header/covers to deterministic server-side rendering, split preference controls into controlled/uncontrolled modes, and remove client cover wrapper to eliminate hydration mismatches and provider coupling on library pages.
2026-02-28 14:06:27 +01:00
26021ea907 refactor: replace book details GET route with server action
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m16s
2026-02-28 12:21:07 +01:00
5eba969846 refactor: remove unused series details GET API route 2026-02-28 12:18:24 +01:00
9a11ab16bb refactor: remove unused user profile GET API route 2026-02-28 12:15:54 +01:00
70a77481e5 refactor: remove unused home GET API route 2026-02-28 12:14:28 +01:00
b1e0e18d9e refactor: replace random-book GET route with server action 2026-02-28 12:10:15 +01:00
e5497b4f58 refactor: migrate paginated library and series flows to server-first 2026-02-28 12:08:20 +01:00
612a70ffbe chore: resolve lint warnings with targeted type and rule fixes 2026-02-28 11:59:30 +01:00
1a88efc46b chore: migrate lint to ESLint CLI with flat config 2026-02-28 11:52:27 +01:00
29f5324bd7 refactor: remove client-only GET API routes for lot 1 2026-02-28 11:43:11 +01:00
7f361ce0a2 refactor: delete unused GET /api/komga/config route
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 2s
2026-02-28 11:13:45 +01:00
eec51b7ef8 refactor: convert Komga test connection to Server Action
- Add testKomgaConnection to config.ts
- Update KomgaSettings to use Server Action
- Remove api/komga/test route
2026-02-28 11:09:48 +01:00
b40f59bec6 refactor: convert admin user management to Server Actions
- Add src/app/actions/admin.ts with updateUserRoles, deleteUser, resetUserPassword
- Update EditUserDialog, DeleteUserDialog, ResetPasswordDialog to use Server Actions
- Remove admin users API routes (PATCH/DELETE/PUT)
2026-02-28 11:06:42 +01:00
7134c069d7 refactor: convert auth register to Server Action
- Add src/app/actions/auth.ts with registerUser
- Update RegisterForm to use Server Action
- Remove api/auth/register route
2026-02-28 11:01:13 +01:00
b815202529 refactor: convert password change to Server Action
- Add src/app/actions/password.ts with changePassword
- Update ChangePasswordForm to use Server Action
- Remove api/user/password route (entire file)
2026-02-28 10:59:00 +01:00
0548215096 refactor: convert Komga config to Server Action
- Add src/app/actions/config.ts with saveKomgaConfig
- Update KomgaSettings to use Server Action
- Remove POST from api/komga/config route (keep GET)
2026-02-28 10:56:52 +01:00
6180f9abb1 refactor: convert library scan to Server Action
- Add src/app/actions/library.ts with scanLibrary
- Update ScanButton to use Server Action
- Remove POST from api/komga/libraries/[libraryId]/scan route
2026-02-28 10:53:41 +01:00
d56b0fd7ae refactor: convert preferences to Server Action
- Add src/app/actions/preferences.ts with updatePreferences
- Update PreferencesContext to use Server Action
- Remove PUT from api/preferences route (keep GET)
2026-02-28 10:50:32 +01:00
7308c0aa63 refactor: convert favorites to Server Actions
- Add src/app/actions/favorites.ts with addToFavorites and removeFromFavorites
- Update SeriesHeader to use Server Actions instead of fetch
- Keep API route GET only (POST/DELETE removed)
2026-02-28 10:46:03 +01:00
7e3fb22d3a docs: add server actions conversion plan 2026-02-28 10:39:28 +01:00
546f3769c2 refactor: remove unused read-progress API route 2026-02-28 10:36:58 +01:00
03cb46f81b refactor: use Server Actions for read progress updates
- Create src/app/actions/read-progress.ts with updateReadProgress and deleteReadProgress
- Update mark-as-read-button and mark-as-unread-button to use Server Actions
- Update usePageNavigation hook to use Server Action
- Use revalidateTag with 'min' profile for cache invalidation
2026-02-28 10:34:26 +01:00
ecce0a9738 fix: invalidate home cache when updating read progress
- Add cache tags support to BaseApiService
- Tag home data with 'home-data' tag in HomeService
- Use revalidateTag('home-data', 'max') after read progress updates
- With 'max' profile: serve stale while fetching fresh in background
2026-02-28 10:16:12 +01:00
7523ec06e1 fix: optimistic favorites
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m53s
2026-02-28 09:38:22 +01:00
2908172777 feat: books fetch on SSR in book reader
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 17s
2026-02-28 08:50:57 +01:00
2669fb9865 docs: update plan optim
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 2s
2026-02-27 17:01:32 +01:00
fcbd9d0533 chore: next upgrade 2026-02-27 17:01:14 +01:00
0c3a54c62c feat: perf optimisation
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 2s
2026-02-27 16:23:05 +01:00
bcfd602353 refactor: simplify CoverClient component 2026-02-27 09:41:58 +01:00
38c7e59366 fix: use fullTextSearch in body for series search API 2026-02-27 09:14:53 +01:00
b9c8b05bc8 fix: resolve komga api errors 2026-02-27 09:02:11 +01:00
249 changed files with 22771 additions and 5138 deletions

View File

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

View File

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

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

View File

@@ -1,401 +0,0 @@
# Plan d'Optimisation des Performances - StripStream
## 🔴 Problèmes Identifiés
### Problème Principal : Pagination côté client au lieu de Komga
**Code actuel problématique :**
```typescript
// library.service.ts - ligne 59
size: "5000"; // Récupère TOUTES les séries d'un coup
// series.service.ts - ligne 69
size: "1000"; // Récupère TOUS les livres d'un coup
```
**Impact :**
- Charge massive en mémoire (stocker 5000 séries)
- Temps de réponse longs (transfert de gros JSON)
- Cache volumineux et inefficace
- Pagination manuelle côté serveur Node.js
### Autres Problèmes
1. **TRIPLE cache conflictuel**
- **Service Worker** : Cache les données API dans `DATA_CACHE` avec SWR
- **ServerCacheService** : Cache côté serveur avec SWR
- **Headers HTTP** : `Cache-Control` sur les routes API
- Comportements imprévisibles, données désynchronisées
2. **Clés de cache trop larges**
- `library-{id}-all-series` → stocke TOUT
- Pas de clé par page/filtres
3. **Préférences rechargées à chaque requête**
- `PreferencesService.getPreferences()` fait une query DB à chaque fois
- Pas de mise en cache des préférences
4. **ISR mal configuré**
- `export const revalidate = 60` sur routes dynamiques
- Conflit avec le cache serveur
---
## ✅ Plan de Développement
### Phase 1 : Pagination Native Komga (PRIORITÉ HAUTE)
- [x] **1.1 Refactorer `LibraryService.getLibrarySeries()`**
- Utiliser directement la pagination Komga
- Endpoint: `POST /api/v1/series/list?page={page}&size={size}`
- Supprimer `getAllLibrarySeries()` et le slice manuel
- Passer les filtres (unread, search) directement à Komga
- [x] **1.2 Refactorer `SeriesService.getSeriesBooks()`**
- Utiliser directement la pagination Komga
- Endpoint: `POST /api/v1/books/list?page={page}&size={size}`
- Supprimer `getAllSeriesBooks()` et le slice manuel (gardée pour book.service.ts)
- [x] **1.3 Adapter les clés de cache**
- Clé incluant page + size + filtres
- Format: `library-{id}-series-p{page}-s{size}-u{unread}-q{search}`
- Format: `series-{id}-books-p{page}-s{size}-u{unread}`
- [x] **1.4 Mettre à jour les routes API**
- `/api/komga/libraries/[libraryId]/series` ✅ (utilise déjà `LibraryService.getLibrarySeries()` refactoré)
- `/api/komga/series/[seriesId]/books` ✅ (utilise déjà `SeriesService.getSeriesBooks()` refactoré)
### Phase 2 : Simplification du Cache (Triple → Simple)
**Objectif : Passer de 3 couches de cache à 1 seule (ServerCacheService)**
- [x] **2.1 Désactiver le cache SW pour les données API**
- Modifier `sw.js` : retirer le cache des routes `/api/komga/*` (sauf images)
- Garder uniquement le cache SW pour : images, static, navigation
- Le cache serveur suffit pour les données
- [x] **2.2 Supprimer les headers HTTP Cache-Control**
- Retirer `Cache-Control` des NextResponse dans les routes API
- Évite les conflits avec le cache serveur
- Note: Conservé pour les images de pages de livres (max-age=31536000)
- [x] **2.3 Supprimer `revalidate` des routes dynamiques**
- Routes API = dynamiques, pas besoin d'ISR
- Le cache serveur suffit
- [x] **2.4 Optimiser les TTL ServerCacheService**
- Réduire TTL des listes paginées (2 min) ✅
- Garder TTL court pour les données avec progression (2 min) ✅
- Garder TTL long pour les images (7 jours) ✅
**Résultat final :**
| Type de donnée | Cache utilisé | Stratégie |
| ---------------- | ------------------ | ------------- |
| Images | SW (IMAGES_CACHE) | Cache-First |
| Static (\_next/) | SW (STATIC_CACHE) | Cache-First |
| Données API | ServerCacheService | SWR |
| Navigation | SW | Network-First |
### Phase 3 : Optimisation des Préférences
- [ ] **3.1 Cacher les préférences utilisateur**
- Créer `PreferencesService.getCachedPreferences()`
- TTL court (1 minute)
- Invalidation manuelle lors des modifications
- [ ] **3.2 Réduire les appels DB**
- Grouper les appels de config Komga + préférences
- Request-level caching (par requête HTTP)
### Phase 4 : Optimisation du Home
- [ ] **4.1 Paralléliser intelligemment les appels Komga**
- Les 5 appels sont déjà en parallèle ✅
- Vérifier que le circuit breaker ne bloque pas
- [ ] **4.2 Réduire la taille des données Home**
- Utiliser des projections (ne récupérer que les champs nécessaires)
- Limiter à 10 items par section (déjà fait ✅)
### Phase 5 : Nettoyage et Simplification
- [ ] **5.1 Supprimer le code mort**
- `getAllLibrarySeries()` (après phase 1)
- `getAllSeriesBooks()` (après phase 1)
- [ ] **5.2 Documenter la nouvelle architecture**
- Mettre à jour `docs/caching.md`
- Documenter les nouvelles clés de cache
- [ ] **5.3 Ajouter des métriques**
- Temps de réponse des requêtes Komga
- Hit/Miss ratio du cache
- Taille des payloads
---
## 📝 Implémentation Détaillée
### Phase 1.1 : Nouveau `LibraryService.getLibrarySeries()`
```typescript
static async getLibrarySeries(
libraryId: string,
page: number = 0,
size: number = 20,
unreadOnly: boolean = false,
search?: string
): Promise<LibraryResponse<Series>> {
const headers = { "Content-Type": "application/json" };
// Construction du body de recherche pour Komga
const condition: Record<string, any> = {
libraryId: { operator: "is", value: libraryId },
};
// Filtre unread natif Komga
if (unreadOnly) {
condition.readStatus = { operator: "is", value: "IN_PROGRESS" };
// OU utiliser: complete: { operator: "is", value: false }
}
const searchBody = { condition };
// Clé de cache incluant tous les paramètres
const cacheKey = `library-${libraryId}-series-p${page}-s${size}-u${unreadOnly}-q${search || ''}`;
const response = await this.fetchWithCache<LibraryResponse<Series>>(
cacheKey,
async () => {
const params: Record<string, string> = {
page: String(page),
size: String(size),
sort: "metadata.titleSort,asc",
};
// Filtre de recherche
if (search) {
params.search = search;
}
return this.fetchFromApi<LibraryResponse<Series>>(
{ path: "series/list", params },
headers,
{ method: "POST", body: JSON.stringify(searchBody) }
);
},
"SERIES"
);
// Filtrer les séries supprimées côté client (léger)
response.content = response.content.filter((series) => !series.deleted);
return response;
}
```
### Phase 1.2 : Nouveau `SeriesService.getSeriesBooks()`
```typescript
static async getSeriesBooks(
seriesId: string,
page: number = 0,
size: number = 24,
unreadOnly: boolean = false
): Promise<LibraryResponse<KomgaBook>> {
const headers = { "Content-Type": "application/json" };
const condition: Record<string, any> = {
seriesId: { operator: "is", value: seriesId },
};
if (unreadOnly) {
condition.readStatus = { operator: "isNot", value: "READ" };
}
const searchBody = { condition };
const cacheKey = `series-${seriesId}-books-p${page}-s${size}-u${unreadOnly}`;
const response = await this.fetchWithCache<LibraryResponse<KomgaBook>>(
cacheKey,
async () =>
this.fetchFromApi<LibraryResponse<KomgaBook>>(
{
path: "books/list",
params: {
page: String(page),
size: String(size),
sort: "number,asc",
},
},
headers,
{ method: "POST", body: JSON.stringify(searchBody) }
),
"BOOKS"
);
// Filtrer les livres supprimés côté client (léger)
response.content = response.content.filter((book) => !book.deleted);
return response;
}
```
### Phase 2.1 : Modification du Service Worker
```javascript
// sw.js - SUPPRIMER cette section
// Route 3: API data → Stale-While-Revalidate (if cacheable)
// if (isApiDataRequest(url.href) && shouldCacheApiData(url.href)) {
// event.respondWith(staleWhileRevalidateStrategy(request, DATA_CACHE));
// return;
// }
// Garder uniquement :
// - Route 1: Images → Cache-First
// - Route 2: RSC payloads → Stale-While-Revalidate (pour navigation)
// - Route 4: Static → Cache-First
// - Route 5: Navigation → Network-First
```
**Pourquoi supprimer le cache SW des données API ?**
- Le ServerCacheService fait déjà du SWR côté serveur
- Pas de bénéfice à cacher 2 fois
- Simplifie l'invalidation (un seul endroit)
- Les données restent accessibles en mode online via ServerCache
### Phase 2.2 : Routes API simplifiées
```typescript
// libraries/[libraryId]/series/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ libraryId: string }> }
) {
const libraryId = (await params).libraryId;
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get("page") || "0");
const size = parseInt(searchParams.get("size") || "20");
const unreadOnly = searchParams.get("unread") === "true";
const search = searchParams.get("search") || undefined;
const [series, library] = await Promise.all([
LibraryService.getLibrarySeries(libraryId, page, size, unreadOnly, search),
LibraryService.getLibrary(libraryId),
]);
// Plus de headers Cache-Control !
return NextResponse.json({ series, library });
}
// Supprimer: export const revalidate = 60;
```
---
## 📊 Gains Attendus
| Métrique | Avant | Après (estimé) |
| ------------------------- | ------------ | -------------- |
| Payload initial Library | ~500KB - 5MB | ~10-50KB |
| Temps 1ère page Library | 2-10s | 200-500ms |
| Mémoire cache par library | ~5MB | ~50KB/page |
| Requêtes Komga par page | 1 grosse | 1 petite |
---
## ⚠️ Impact sur le Mode Offline
**Avant (triple cache) :**
- Données API cachées par le SW → navigation offline possible
**Après (cache serveur uniquement) :**
- Données API non cachées côté client
- Mode offline limité aux images déjà vues
- Page offline.html affichée si pas de connexion
**Alternative si offline critique :**
- Option 1 : Garder le cache SW uniquement pour les pages "Home" et "Library" visitées
- Option 2 : Utiliser IndexedDB pour un vrai mode offline (plus complexe)
- Option 3 : Accepter la limitation (majoritaire pour un reader de comics)
---
## 🔧 Tests à Effectuer
- [ ] Test pagination avec grande bibliothèque (>1000 séries)
- [ ] Test filtres (unread, search) avec pagination
- [ ] Test changement de page rapide (pas de race conditions)
- [ ] Test invalidation cache (refresh)
- [ ] Test mode offline → vérifier que offline.html s'affiche
- [ ] Test images offline → doivent rester accessibles
---
## 📅 Ordre de Priorité
1. **Urgent** : Phase 1 (pagination native) - Impact maximal
2. **Important** : Phase 2 (simplification cache) - Évite les bugs
3. **Moyen** : Phase 3 (préférences) - Optimisation secondaire
4. **Faible** : Phase 4-5 (nettoyage) - Polish
---
## Notes Techniques
### API Komga - Pagination
L'API Komga supporte nativement :
- `page` : Index de page (0-based)
- `size` : Nombre d'éléments par page
- `sort` : Tri (ex: `metadata.titleSort,asc`)
Endpoint POST `/api/v1/series/list` accepte un body avec `condition` pour filtrer.
### Filtres Komga disponibles
```json
{
"condition": {
"libraryId": { "operator": "is", "value": "xxx" },
"readStatus": { "operator": "is", "value": "IN_PROGRESS" },
"complete": { "operator": "is", "value": false }
}
}
```
### Réponse paginée Komga
```json
{
"content": [...],
"pageable": { "pageNumber": 0, "pageSize": 20 },
"totalElements": 150,
"totalPages": 8,
"first": true,
"last": false
}
```

View File

@@ -74,7 +74,7 @@ A modern web application for reading digital comics, built with Next.js 14 and t
## 🛠 Prerequisites ## 🛠 Prerequisites
- Node.js 20.x or higher - Node.js 20.x or higher
- Yarn 1.22.x or higher - pnpm 9.x or higher
- Docker and Docker Compose (optional) - Docker and Docker Compose (optional)
## 📦 Installation ## 📦 Installation
@@ -91,7 +91,7 @@ cd stripstream
2. Install dependencies 2. Install dependencies
```bash ```bash
yarn install pnpm install
``` ```
3. Copy the example environment file and adjust it to your needs 3. Copy the example environment file and adjust it to your needs
@@ -103,10 +103,10 @@ cp .env.example .env.local
4. Start the development server 4. Start the development server
```bash ```bash
yarn dev pnpm dev
``` ```
### With Docker ### With Docker (Build Local)
1. Clone the repository and navigate to the folder 1. Clone the repository and navigate to the folder
@@ -121,15 +121,65 @@ cd stripstream
docker-compose up --build docker-compose up --build
``` ```
The application will be accessible at `http://localhost:3020`
### With Docker (DockerHub Image)
You can also use the pre-built image from DockerHub without cloning the repository:
1. Create a `docker-compose.yml` file:
```yaml
services:
app:
image: julienfroidefond32/stripstream:latest
ports:
- "3000:3000"
environment:
# Required
- NEXTAUTH_SECRET=your_secret_here # openssl rand -base64 32
- NEXTAUTH_URL=http://localhost:3000
# Optional — defaults shown
# - NODE_ENV=production
# - DATABASE_URL=file:/app/prisma/data/stripstream.db
# - ADMIN_DEFAULT_PASSWORD=Admin@2025
# - AUTH_TRUST_HOST=true
# - KOMGA_MAX_CONCURRENT_REQUESTS=5
volumes:
- ./data:/app/prisma/data
restart: unless-stopped
```
2. Run the container:
```bash
docker-compose up -d
```
The application will be accessible at `http://localhost:3000` The application will be accessible at `http://localhost:3000`
## 🔧 Available Scripts ## 🔧 Available Scripts
- `yarn dev` - Starts the development server - `pnpm dev` - Starts the development server
- `yarn build` - Creates a production build - `pnpm build` - Creates a production build
- `yarn start` - Runs the production version - `pnpm start` - Runs the production version
- `yarn lint` - Checks code with ESLint - `pnpm lint` - Checks code with ESLint
- `yarn format` - Formats code with Prettier - `./docker-push.sh [tag]` - Build and push Docker image to DockerHub (default tag: `latest`)
### Docker Push Script
The `docker-push.sh` script automates building and pushing the Docker image to DockerHub:
```bash
# Push with 'latest' tag
./docker-push.sh
# Push with a specific version tag
./docker-push.sh v1.0.0
```
**Prerequisite:** You must be logged in to DockerHub (`docker login`) before running the script.
## 🌐 Komga API ## 🌐 Komga API

View File

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

View File

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

24
docker-push.sh Executable file
View File

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

72
docs/api-get-cleanup.md Normal file
View File

@@ -0,0 +1,72 @@
---
status: reviewed
reviewed_at: 2026-02-28
review_file: thoughts/reviews/api-get-cleanup-review.md
---
# Plan - Cleanup des routes API GET (focus RSC)
## État réel (scan `src/app/api`)
Routes GET actuellement présentes :
### A. Migrees en Lot 1 (RSC, routes supprimees)
| Route | Utilisation client actuelle | Cible | Action |
|-------|-----------------------------|-------|--------|
| `GET /api/preferences` | `src/contexts/PreferencesContext.tsx` | Préférences fournies par layout/page server | ✅ Supprimée |
| `GET /api/komga/favorites` | `src/components/layout/Sidebar.tsx`, `src/components/series/SeriesHeader.tsx` | Favoris passés depuis parent Server Component | ✅ Supprimée |
| `GET /api/admin/users` | `src/components/admin/AdminContent.tsx` | Page admin en RSC + props | ✅ Supprimée |
| `GET /api/admin/stats` | `src/components/admin/AdminContent.tsx` | Page admin en RSC + props | ✅ Supprimée |
| `GET /api/komga/libraries` | `src/components/settings/BackgroundSettings.tsx` | Données passées depuis page/settings server | ✅ Supprimée |
### B. A garder temporairement (interaction client forte)
| Route | Utilisation actuelle | Pourquoi garder maintenant | Piste de simplification |
|-------|----------------------|----------------------------|-------------------------|
### B2. Migrees en Lot 2 (pagination server-first)
| Route | Utilisation client actuelle | Cible | Action |
|-------|-----------------------------|-------|--------|
| `GET /api/komga/libraries/[libraryId]/series` | `src/app/libraries/[libraryId]/LibraryClientWrapper.tsx` | Chargement via `searchParams` dans page server | ✅ Supprimée |
| `GET /api/komga/series/[seriesId]/books` | `src/app/series/[seriesId]/SeriesClientWrapper.tsx` | Chargement via `searchParams` dans page server | ✅ Supprimée |
| `GET /api/komga/random-book` | `src/components/layout/ClientLayout.tsx` | Action utilisateur via server action | ✅ Supprimée |
| `GET /api/komga/home` | `src/app/page.tsx` consomme déjà `HomeService` côté server | Données agrégées directement via service server | ✅ Supprimée |
| `GET /api/user/profile` | aucun consommateur client trouvé, page compte déjà server-first | Profil/statistiques via `UserService` en Server Component | ✅ Supprimée |
| `GET /api/komga/series/[seriesId]` | plus de consommateur `fetch('/api/...')` (chargement via `SeriesService`) | Détail série chargé en Server Component | ✅ Supprimée |
| `GET /api/komga/books/[bookId]` | fallback client (`ClientBookPage`) et DownloadManager migrés vers server action | Données livre/pages/nextBook via `BookService` et action server | ✅ Supprimée |
### C. A conserver (API de transport / framework)
| Route | Raison |
|-------|--------|
| `GET /api/komga/images/**` | streaming/binaire image, adapté à une route API |
| `GET /api/komga/books/[bookId]/pages/[pageNumber]` | endpoint image avec déduplication/cache |
| `GET /api/auth/[...nextauth]` | handler NextAuth, à conserver |
## Points importants
- `GET /api/komga/config` n'existe plus dans `src/app/api` (déjà retirée).
- Le gain principal vient des écrans qui refetchent des données déjà disponibles côté server (layout/page).
- Objectif: réduire les GET API utilisés comme couche interne entre composants React et services serveur.
## Plan d'exécution recommandé
1. **Lot 1 (quick wins)**
- Migrer `preferences`, `favorites`, `admin/users`, `admin/stats`, `komga/libraries` vers un chargement server-first.
- Garder les routes GET le temps de la transition, puis supprimer les appels client.
2. **Lot 2 (pages paginées)**
- Repenser `libraries/[libraryId]/series` et `series/[seriesId]/books` pour un flux `searchParams` server-first.
- Conserver seulement les interactions client réellement nécessaires.
3. **Lot 3 (stabilisation)**
- Vérifier `user/profile` et `komga/home` (route API vs appel direct service).
- Supprimer les routes GET devenues sans consommateurs.
## Check de validation
- Plus de `fetch("/api/...")` GET dans les composants server-capables.
- Pas de régression UX sur pagination/filtres et random book.
- Journal clair des routes supprimées et des routes conservées avec justification.

75
docs/komga-api-summary.md Normal file
View File

@@ -0,0 +1,75 @@
# Résumé Spec Komga OpenAPI v1.24.1
## Authentication
- **Basic Auth** ou **API Key** (`X-API-Key` header)
- Sessions: cookie `KOMGA-SESSION` ou header `X-Auth-Token`
- "Remember me" supporté
## Endpoints Principaux
### Libraries
| Méthode | Endpoint | Description |
|--------|----------|-------------|
| GET | `/libraries` | Liste des bibliothèques |
| GET | `/libraries/{id}` | Détail d'une bibliothèque |
### Series
| Méthode | Endpoint | Description |
|--------|----------|-------------|
| GET | `/series` | Liste des séries (GET avec params) |
| POST | `/series/list` | Liste paginée avec filtres (JSON body) |
| GET | `/series/{id}` | Détail d'une série |
| GET | `/series/{id}/thumbnail` | Vignette (image complète) |
| GET | `/series/{id}/books` | Livres d'une série |
### Books
| Méthode | Endpoint | Description |
|--------|----------|-------------|
| GET | `/books` | Liste des livres |
| POST | `/books/list` | Liste paginée avec filtres |
| GET | `/books/{id}` | Détail d'un livre |
| GET | `/books/{id}/pages` | Liste des pages |
| GET | `/books/{id}/pages/{n}` | Image d'une page (streaming) |
| GET | `/books/{id}/pages/{n}/thumbnail` | Miniature (300px max) |
### Collections & Readlists
| Méthode | Endpoint | Description |
|--------|----------|-------------|
| GET/POST | `/collections` | CRUD Collections |
| GET/POST | `/readlists` | CRUD Readlists |
## Pagination
**Paramètres query:**
- `page` - Index 0-based
- `size` - Taille de page
- `sort` - Tri (ex: `metadata.titleSort,asc`)
**Corps JSON (POST):**
```json
{
"condition": {
"libraryId": { "operator": "is", "value": "xxx" }
},
"fullTextSearch": "query"
}
```
## Opérateurs
- `is`, `isNot`
- `contains`, `containsNot`
- `before`, `after`, `beforeOrEqual`, `afterOrEqual`
## Opérateurs Logiques
- `allOf` - ET logique
- `anyOf` - OU logique
## Images
| Type | Endpoint | Taille |
|------|----------|--------|
| Vignette série | `/series/{id}/thumbnail` | Taille originale |
| Page livre | `/books/{id}/pages/{n}` | Taille originale (streaming) |
| Miniature page | `/books/{id}/pages/{n}/thumbnail` | 300px max |
**Note:** Komga ne fournit pas de redimensionnement pour les vignettes de séries.

141
docs/plan-optimisation.md Normal file
View File

@@ -0,0 +1,141 @@
# Plan d'Optimisation des Performances
> Dernière mise à jour: 2026-02-27
## État Actuel
### Ce qui fonctionne bien
- ✅ Pagination native Komga (`POST /series/list`, `POST /books/list`)
- ✅ Prefetching des pages de livre (avec déduplication)
- ✅ 5 appels parallèles pour la page Home
- ✅ Service Worker pour images et navigation
- ✅ Timeout et retry sur les appels API
- ✅ Prisma singleton pour éviter les connexions multiples
-**Cache serveur API avec Next.js revalidate** (ajouté)
---
## Analyse Complète
### ⚡ Problèmes de Performance
#### 🟡 Cache préférences (IMPACT: MOYEN)
**Symptôme:** Chaque lecture de préférences = 1 query DB
**Fichier:** `src/lib/services/preferences.service.ts`
#### 🟢 N+1 API Calls - résolu avec cache
Les appels pour récupérer le count des livres sont parallèles (Promise.all) + cache Next.js (0-2ms). Plus critique qu'avant.
---
### 🔒 Problemes de Securite
#### 🔴 Critique - Auth Header en clair
**Impact:** HIGH | **Fichiers:** `src/lib/services/config-db.service.ts:21-23`
```typescript
// Problème: authHeader stocké en clair dans la DB
const authHeader: string = Buffer.from(`${data.username}:${data.password}`).toString("base64");
```
**Solution:** Chiffrer avec AES-256 avant de stocker. Ajouter `ENCRYPTION_KEY` dans .env
#### 🔴 Pas de rate limiting
**Impact:** HIGH | **Fichiers:** Toutes les routes API
**Solution:** Ajouter `rate-limiter-flexible` pour limiter les requêtes par IP/user
---
### ⚠️ Autres Problemes
#### 🟡 Appels doublons (architecture Next.js)
**Impact:** Moyen | **Fichier:** `layout.tsx`
Le layout + les pages font des appels séparés → appels doublons. Résolu en partie par le cache.
#### Service Worker double-cache (IMPACT: FAIBLE)
**Symptôme:** Conflit entre cache SW et navigateur
#### getHomeData echoue completement si une requete echoue
**Impact:** Fort | **Fichier:** `src/app/api/komga/home/route.ts`
---
## Priorites d'implementation
### ✅ Phase 2: Performance (COMPLETEE)
- **Cache serveur API via fetchFromApi avec option `revalidate`**
- Fichiers modifiés:
- `src/lib/services/base-api.service.ts` - ajout option `revalidate` dans fetch + CACHE_DEBUG
- `src/lib/services/library.service.ts` - CACHE_TTL = 300s (5 min)
- `src/lib/services/home.service.ts` - CACHE_TTL = 120s (2 min)
- `src/lib/services/series.service.ts` - CACHE_TTL = 120s (2 min)
- `src/lib/services/book.service.ts` - CACHE_TTL = 60s (1 min)
### Phase 1: Securite (PRIORITE SUIVANTE)
1. **Chiffrer les identifiants Komga**
```typescript
// src/lib/utils/encryption.ts
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const key = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex');
export function encrypt(text: string): string {
const iv = randomBytes(16);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(text), cipher.final()]);
return `${iv.toString('hex')}:${encrypted.toString('hex')}:${cipher.getAuthTag().toString('hex')}`;
}
```
2. **Ajouter rate limiting**
### Phase 3: Fiabilite (Priorite MOYENNE)
1. **Graceful degradation** sur Home (afficher données partielles si un appel échoue)
2. **Cache préférences**
### Phase 4: Nettoyage (Priorite FAIBLE)
1. **Supprimer double-cache SW** pour les données API
---
## TTL Recommandes pour Cache
| Donnee | TTL | Status |
|--------|-----|--------|
| Home | 2 min | ✅ Implementé |
| Series list | 2 min | ✅ Implementé |
| Books list | 2 min | ✅ Implementé |
| Book details | 1 min | ✅ Implementé |
| Libraries | 5 min | ✅ Implementé |
| Preferences | - | ⏳ Non fait |
---
## Fichiers a Modifier
### ✅ Performance (COMPLET)
1. ~~`src/lib/services/server-cache.service.ts`~~ - Supprimé, on utilise Next.js natif
2. ~~`src/lib/services/base-api.service.ts`~~ - Ajout option `revalidate` dans fetch
3. ~~`src/lib/services/home.service.ts`~~ - CACHE_TTL = 120s
4. ~~`src/lib/services/library.service.ts`~~ - CACHE_TTL = 300s
5. ~~`src/lib/services/series.service.ts`~~ - CACHE_TTL = 120s
6. ~~`src/lib/services/book.service.ts`~~ - CACHE_TTL = 60s
### 🔒 Securite (A FAIRE)
1. `src/lib/utils/encryption.ts` (nouveau)
2. `src/lib/services/config-db.service.ts` - Utiliser chiffrement
3. `src/middleware.ts` - Ajouter rate limiting
### Fiabilite (A FAIRE)
1. `src/app/api/komga/home/route.ts` - Graceful degradation
2. `src/lib/services/base-api.service.ts` - Utiliser deduplication
### Nettoyage (A FAIRE)
1. `public/sw.js` - Supprimer cache API

149
docs/server-actions-plan.md Normal file
View File

@@ -0,0 +1,149 @@
# Plan de conversion API Routes → Server Actions
## État des lieux
### ✅ Converti
| Ancienne Route | Server Action | Status |
|----------------|---------------|--------|
| `PATCH /api/komga/books/[bookId]/read-progress` | `updateReadProgress()` | ✅ Done |
| `DELETE /api/komga/books/[bookId]/read-progress` | `deleteReadProgress()` | ✅ Done |
| `POST /api/komga/favorites` | `addToFavorites()` | ✅ Done |
| `DELETE /api/komga/favorites` | `removeFromFavorites()` | ✅ Done |
| `PUT /api/preferences` | `updatePreferences()` | ✅ Done |
| `POST /api/komga/libraries/[libraryId]/scan` | `scanLibrary()` | ✅ Done |
| `POST /api/komga/config` | `saveKomgaConfig()` | ✅ Done |
| `POST /api/komga/test` | `testKomgaConnection()` | ✅ Done |
| `PUT /api/user/password` | `changePassword()` | ✅ Done |
| `POST /api/auth/register` | `registerUser()` | ✅ Done |
| `PATCH /api/admin/users/[userId]` | `updateUserRoles()` | ✅ Done |
| `DELETE /api/admin/users/[userId]` | `deleteUser()` | ✅ Done |
| `PUT /api/admin/users/[userId]/password` | `resetUserPassword()` | ✅ Done |
---
## À convertir (priorité haute)
### 1. Scan de bibliothèque
**Route actuelle** : `api/komga/libraries/[libraryId]/scan/route.ts`
```typescript
// Action à créer : src/app/actions/library.ts
export async function scanLibrary(libraryId: string)
```
**Appelants à migrer** :
- `components/library/ScanButton.tsx` (POST fetch)
---
## À convertir (priorité moyenne)
### 2. Configuration Komga
**Route actuelle** : `api/komga/config/route.ts`
```typescript
// Action à créer : src/app/actions/config.ts
export async function saveKomgaConfig(config: KomgaConfigData)
export async function getKomgaConfig()
```
---
### 3. Mot de passe utilisateur
**Route actuelle** : `api/user/password/route.ts`
```typescript
// Action à créer : src/app/actions/password.ts
export async function changePassword(currentPassword: string, newPassword: string)
```
---
### 4. Inscription
**Route actuelle** : `api/auth/register/route.ts`
```typescript
// Action à créer : src/app/actions/auth.ts
export async function registerUser(email: string, password: string)
```
---
## À convertir (priorité basse - admin)
### 5. Gestion des utilisateurs (admin)
**Routes** :
- `api/admin/users/[userId]/route.ts` (PATCH, DELETE)
- `api/admin/users/[userId]/password/route.ts` (PUT)
```typescript
// Actions à créer : src/app/actions/admin.ts
export async function updateUserRoles(userId: string, roles: string[])
export async function deleteUser(userId: string)
export async function resetUserPassword(userId: string, newPassword: string)
```
---
## À garder en API Routes
Ces routes ne doivent PAS être converties :
| Route | Raison |
|-------|--------|
| `api/komga/home` | GET - called from Server Components |
| `api/komga/books/[bookId]` | GET - fetch données livre |
| `api/komga/series/*` | GET - fetch séries |
| `api/komga/libraries/*` | GET - fetch bibliothèques |
| `api/komga/random-book` | GET - fetch aléatoire |
| `api/komga/images/*` | GET - servir images (streaming) |
| `api/auth/[...nextauth]/*` | NextAuth handler externe |
| `api/admin/users` | GET - fetch liste users |
| `api/admin/stats` | GET - fetch stats |
| `api/user/profile` | GET - fetch profile |
---
## Pattern à suivre
```typescript
// src/app/actions/[feature].ts
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
import { Service } from "@/lib/services/service";
export async function actionName(params): Promise<{ success: boolean; message: string }> {
try {
await Service.doSomething(params);
// Invalider le cache si nécessaire
revalidateTag("cache-tag", "min");
revalidatePath("/");
return { success: true, message: "Succès" };
} catch (error) {
return { success: false, message: error.message };
}
}
```
```typescript
// src/components/feature/Component.tsx
"use client";
import { actionName } from "@/app/actions/feature";
const handleAction = async () => {
const result = await actionName(params);
if (!result.success) {
// handle error
}
};
```

97
eslint.config.mjs Normal file
View File

@@ -0,0 +1,97 @@
import { defineConfig } from "eslint/config";
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";
import unusedImports from "eslint-plugin-unused-imports";
export default defineConfig([
{ ignores: ["temp/**", ".next/**", "node_modules/**"] },
...nextCoreWebVitals,
...nextTypescript,
{
plugins: {
"unused-imports": unusedImports,
},
rules: {
"@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
"no-console": [
"warn",
{
allow: ["warn", "error"],
},
],
"@next/next/no-html-link-for-pages": "off",
"react/no-unescaped-entities": "off",
"no-unreachable": "error",
"no-unused-expressions": "warn",
"no-unused-private-class-members": "warn",
"unused-imports/no-unused-imports": "warn",
"unused-imports/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_",
caughtErrors: "all",
caughtErrorsIgnorePattern: "^_",
},
],
"no-empty-function": "warn",
"no-empty": ["warn", { allowEmptyCatch: true }],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-empty-object-type": "warn",
"@typescript-eslint/no-require-imports": "warn",
"react-hooks/error-boundaries": "warn",
"react-hooks/set-state-in-effect": "warn",
"react-hooks/refs": "warn",
"react-hooks/purity": "warn",
},
},
{
files: ["scripts/**/*.{js,mjs,cjs}"],
rules: {
"no-console": "off",
"@typescript-eslint/no-require-imports": "off",
},
},
{
files: ["src/app/**/*.tsx"],
rules: {
"react-hooks/error-boundaries": "off",
},
},
{
files: [
"src/components/layout/ClientLayout.tsx",
"src/components/layout/Sidebar.tsx",
"src/components/library/LibraryHeader.tsx",
"src/components/reader/components/PageDisplay.tsx",
"src/components/reader/components/Thumbnail.tsx",
"src/components/reader/hooks/useThumbnails.ts",
"src/components/ui/InstallPWA.tsx",
"src/components/ui/cover-client.tsx",
"src/components/series/BookGrid.tsx",
"src/components/series/BookList.tsx",
"src/contexts/ServiceWorkerContext.tsx",
"src/hooks/useNetworkStatus.ts",
],
rules: {
"react-hooks/set-state-in-effect": "off",
"react-hooks/refs": "off",
"react-hooks/purity": "off",
},
},
{
files: ["src/components/ui/cover-client.tsx"],
rules: {
"@next/next/no-img-element": "off",
},
},
]);

14248
komga-openapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -9,7 +9,7 @@
"start:prod": "node scripts/init-db.mjs && pnpm start", "start:prod": "node scripts/init-db.mjs && pnpm start",
"init-db": "node scripts/init-db.mjs", "init-db": "node scripts/init-db.mjs",
"reset-admin-password": "node scripts/reset-admin-password.mjs", "reset-admin-password": "node scripts/reset-admin-password.mjs",
"lint": "next lint", "lint": "eslint .",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"icons": "node scripts/generate-icons.js", "icons": "node scripts/generate-icons.js",
"postinstall": "prisma generate" "postinstall": "prisma generate"
@@ -37,14 +37,14 @@
"i18next-browser-languagedetector": "^8.0.4", "i18next-browser-languagedetector": "^8.0.4",
"lucide-react": "^0.487.0", "lucide-react": "^0.487.0",
"mongodb": "^6.20.0", "mongodb": "^6.20.0",
"next": "^15.5.9", "next": "^16.1.6",
"next-auth": "^5.0.0-beta.30", "next-auth": "^5.0.0-beta.30",
"next-themes": "0.2.1", "next-themes": "0.2.1",
"photoswipe": "^5.4.4", "photoswipe": "^5.4.4",
"pino": "^10.1.0", "pino": "^10.1.0",
"pino-pretty": "^13.1.2", "pino-pretty": "^13.1.2",
"react": "19.2.0", "react": "19.2.4",
"react-dom": "19.2.0", "react-dom": "19.2.4",
"react-i18next": "^15.4.1", "react-i18next": "^15.4.1",
"sharp": "0.33.2", "sharp": "0.33.2",
"tailwind-merge": "^3.0.2", "tailwind-merge": "^3.0.2",
@@ -54,13 +54,13 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "24.7.2", "@types/node": "24.7.2",
"@types/react": "19.2.2", "@types/react": "19.2.14",
"@types/react-dom": "19.2.2", "@types/react-dom": "19.2.3",
"@typescript-eslint/eslint-plugin": "^8.24.0", "@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "6.21.0", "@typescript-eslint/parser": "8.56.1",
"autoprefixer": "10.4.17", "autoprefixer": "10.4.17",
"eslint": "8.56.0", "eslint": "9.39.3",
"eslint-config-next": "15.2.0", "eslint-config-next": "16.1.6",
"eslint-config-prettier": "10.0.1", "eslint-config-prettier": "10.0.1",
"eslint-plugin-typescript-sort-keys": "^3.3.0", "eslint-plugin-typescript-sort-keys": "^3.3.0",
"eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-unused-imports": "^4.1.4",

1841
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "preferences" ADD COLUMN "anonymousMode" BOOLEAN NOT NULL DEFAULT false;

View File

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

View File

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

View File

@@ -0,0 +1,65 @@
<!-- Context: project-intelligence/business | Priority: high | Version: 1.0 | Updated: 2026-02-27 -->
# Business Domain
**Purpose**: Business context, problems solved, and value created for the comic/manga reader app.
**Last Updated**: 2026-02-27
## Quick Reference
- **Update When**: Business direction changes, new features shipped
- **Audience**: Developers needing context, stakeholders
## Project Identity
```
Project Name: Stripstream
Tagline: Modern web reader for digital comics and manga
Problem: Need a responsive, feature-rich web interface for reading comics from Komga servers
Solution: Next.js PWA that syncs with Komga API, supports offline reading, multiple reading modes
```
## Target Users
| Segment | Who | Needs | Pain Points |
|---------|-----|-------|--------------|
| Comic/Manga Readers | Users with Komga servers | Read comics in browser | Komga web UI is limited |
| Mobile Readers | iPad/Android users | Offline reading, touch gestures | No native mobile app |
| Library Organizers | Users with large collections | Search, filter, track progress | Hard to manage |
## Value Proposition
**For Users**:
- Read comics anywhere via responsive PWA
- Offline reading with local storage
- Multiple viewing modes (single/double page, RTL, scroll)
- Progress sync with Komga server
- Light/Dark mode, language support (EN/FR)
**For Business**:
- Open source showcase
- Demonstrates Next.js + Komga integration patterns
## Key Features
- **Sync**: Read progress, series/books lists with Komga
- **Reader**: RTL, double/single page, zoom, thumbnails, fullscreen
- **Offline**: PWA with local book downloads
- **UI**: Dark/light, responsive, loading/error states
- **Lists**: Pagination, search, mark read/unread, favorites
- **Settings**: Cache TTL, Komga config, display preferences
## Tech Stack Context
- Integrates with **Komga** (comic server API)
- Uses **MongoDB** for local caching/preferences
- **NextAuth** for authentication (session-based)
## Success Metrics
| Metric | Target |
|--------|--------|
| Page load | <2s |
| Reader responsiveness | 60fps |
| PWA install rate | Track via analytics |
## Constraints
- Requires Komga server (not standalone)
- Mobile storage limits for offline books
## Related Files
- `technical-domain.md` - Tech stack and code patterns
- `business-tech-bridge.md` - Business-technical mapping

View File

@@ -0,0 +1,65 @@
<!-- Context: project-intelligence/bridge | Priority: medium | Version: 1.0 | Updated: 2026-02-27 -->
# Business-Tech Bridge
**Purpose**: Map business concepts to technical implementation.
**Last Updated**: 2026-02-27
## Quick Reference
- **Update When**: New features that bridge business and tech
- **Audience**: Developers, product
---
## Business → Technical Mapping
| Business Concept | Technical Implementation |
|-----------------|------------------------|
| Read comics | `BookService.getBook()`, PhotoswipeReader component |
| Sync progress | Komga API calls in `ReadProgressService` |
| Offline reading | Service Worker + IndexedDB via `ClientOfflineBookService` |
| User preferences | MongoDB + `PreferencesService` |
| Library management | `LibraryService`, `SeriesService` |
| Authentication | NextAuth v5 + `AuthServerService` |
---
## User Flows → API Routes
| User Action | API Route | Service |
|-------------|-----------|---------|
| View home | `GET /api/komga/home` | `HomeService` |
| Browse series | `GET /api/komga/libraries/:id/series` | `SeriesService` |
| Read book | `GET /api/komga/books/:id` | `BookService` |
| Update progress | `POST /api/komga/books/:id/read-progress` | `BookService` |
| Download book | `GET /api/komga/images/books/:id/pages/:n` | `ImageService` |
---
## Components → Services
| UI Component | Service Layer |
|--------------|---------------|
| HomeContent | HomeService |
| SeriesGrid | SeriesService |
| BookCover | BookService |
| PhotoswipeReader | ImageService |
| FavoritesButton | FavoriteService |
| SettingsPanel | PreferencesService |
---
## Data Flow
```
User Request → API Route → Service → Komga API / MongoDB → Response
Logger (Pino)
```
---
## Related Files
- `technical-domain.md` - Tech stack and code patterns
- `business-domain.md` - Business context
- `decisions-log.md` - Architecture decisions

View File

@@ -0,0 +1,130 @@
<!-- Context: project-intelligence/decisions | Priority: medium | Version: 1.0 | Updated: 2026-02-27 -->
# Decisions Log
**Purpose**: Record architecture decisions with context and rationale.
**Last Updated**: 2026-02-27
## Quick Reference
- **Update When**: New architecture decisions
- **Audience**: Developers, architects
---
## ADR-001: Use Prisma with MongoDB
**Date**: 2024
**Status**: Accepted
**Context**: Need database for caching Komga responses and storing user preferences.
**Decision**: Use Prisma ORM with MongoDB adapter.
**Rationale**:
- Type-safe queries across the app
- Schema migration support
- Works well with MongoDB's flexible schema
**Alternatives Considered**:
- Mongoose: Less type-safe, manual schema management
- Raw MongoDB driver: No type safety, verbose
---
## ADR-002: Service Layer Pattern
**Date**: 2024
**Status**: Accepted
**Context**: API routes need business logic separated from HTTP handling.
**Decision**: Create service classes in `src/lib/services/` (BookService, SeriesService, etc.)
**Rationale**:
- Separation of concerns
- Testable business logic
- Reusable across API routes
**Example**:
```typescript
// API route (thin)
export async function GET(request: NextRequest, { params }) {
const book = await BookService.getBook(bookId);
return NextResponse.json(book);
}
// Service (business logic)
class BookService {
static async getBook(bookId: string) { ... }
}
```
---
## ADR-003: Custom AppError with Error Codes
**Date**: 2024
**Status**: Accepted
**Context**: Need consistent error handling across API.
**Decision**: Custom `AppError` class with error codes from `ERROR_CODES` constant.
**Rationale**:
- Consistent error format: `{ error: { code, name, message } }`
- Typed error codes for client handling
- Centralized error messages via `getErrorMessage()`
---
## ADR-004: Radix UI + Tailwind for Components
**Date**: 2024
**Status**: Accepted
**Context**: Need accessible UI components without fighting a component library.
**Decision**: Use Radix UI primitives with custom Tailwind styling.
**Rationale**:
- Radix provides accessible primitives
- Full control over styling via Tailwind
- Shadcn-like pattern (cva + cn)
---
## ADR-005: Client-Side Request Deduplication
**Date**: 2024
**Status**: Accepted
**Context**: Multiple components may request same data (e.g., home page with series, books, continue reading).
**Decision**: `RequestDeduplicationService` with React query-like deduplication.
**Rationale**:
- Reduces Komga API calls
- Consistent data across components
- Configurable TTL
---
## ADR-006: PWA with Offline Book Storage
**Date**: 2024
**Status**: Accepted
**Context**: Users want to read offline, especially on mobile.
**Decision**: Next PWA + Service Worker + IndexedDB for storing book blobs.
**Rationale**:
- Full offline capability
- Background sync when online
- Local storage limits on mobile
---
## Related Files
- `technical-domain.md` - Tech stack details
- `business-domain.md` - Business context

View File

@@ -0,0 +1,64 @@
<!-- Context: project-intelligence/living-notes | Priority: low | Version: 1.0 | Updated: 2026-02-27 -->
# Living Notes
**Purpose**: Development notes, TODOs, and temporary information.
**Last Updated**: 2026-02-27
## Quick Reference
- **Update When**: Adding dev notes, tracking issues
- **Audience**: Developers
---
## Current Focus
- Performance optimization (see PLAN_OPTIMISATION_PERFORMANCES.md)
- Reducing bundle size
- Image optimization
---
## Development Notes
### Service Layer
All business logic lives in `src/lib/services/`. API routes are thin wrappers.
### API Error Handling
Use `AppError` class from `@/utils/errors`. Always include error code from `ERROR_CODES`.
### Component Patterns
- UI components: `src/components/ui/` (Radix + Tailwind)
- Feature components: `src/components/*/` (by feature)
- Use `cva` for variant props
- Use `cn` from `@/lib/utils` for class merging
### Types
- Komga types: `src/types/komga/`
- App types: `src/types/`
### Database
- Prisma schema: `prisma/schema.prisma`
- MongoDB connection: `src/lib/prisma.ts`
---
## Known Issues
- Large libraries may be slow to load (pagination helps)
- Offline storage limited by device space
---
## Future Ideas
- [ ] Add more reader modes
- [ ] User collections/tags
- [ ] Reading statistics
- [ ] Better caching strategy
---
## Related Files
- `technical-domain.md` - Code patterns
- `decisions-log.md` - Architecture decisions

View File

@@ -0,0 +1,23 @@
<!-- Context: project-intelligence/navigation | Priority: critical | Version: 1.0 | Updated: 2026-02-27 -->
# Project Intelligence
Quick overview of project patterns and context files.
## Quick Routes
| File | Description | Priority |
|------|-------------|----------|
| [technical-domain.md](./technical-domain.md) | Tech stack, architecture, patterns | critical |
| [business-domain.md](./business-domain.md) | Business logic, domain model | high |
| [decisions-log.md](./decisions-log.md) | Architecture decisions | medium |
| [living-notes.md](./living-notes.md) | Development notes | low |
| [business-tech-bridge.md](./business-tech-bridge.md) | Business-technical mapping | medium |
## All Files Complete |
## Usage
- **AI Agents**: Read technical-domain.md for code patterns
- **New Developers**: Start with technical-domain.md + business-domain.md
- **Architecture**: Check decisions-log.md for rationale

View File

@@ -0,0 +1,154 @@
<!-- Context: project-intelligence/technical | Priority: critical | Version: 1.0 | Updated: 2026-02-27 -->
# Technical Domain
**Purpose**: Tech stack, architecture, development patterns for this project.
**Last Updated**: 2026-02-27
## Quick Reference
**Update Triggers**: Tech stack changes | New patterns | Architecture decisions
**Audience**: Developers, AI agents
## Primary Stack
| Layer | Technology | Version | Rationale |
|-------|-----------|---------|-----------|
| Framework | Next.js | 15.5.9 | App Router, Server Components |
| Language | TypeScript | 5.3.3 | Type safety |
| Database | MongoDB | - | Flexible schema for media metadata |
| ORM | Prisma | 6.17.1 | Type-safe DB queries |
| Styling | Tailwind CSS | 3.4.1 | Utility-first |
| UI Library | Radix UI | - | Accessible components |
| Animation | Framer Motion | 12.x | Declarative animations |
| Auth | NextAuth | v5 | Session management |
| Validation | Zod | 3.22.4 | Schema validation |
| Logger | Pino | 10.x | Structured logging |
## Project Structure
```
src/
├── app/ # Next.js App Router pages
├── components/ # React components (ui/, features/)
├── lib/ # Services, utils, config
│ └── services/ # Business logic (BookService, etc.)
├── hooks/ # Custom React hooks
├── types/ # TypeScript type definitions
├── utils/ # Helper functions
├── contexts/ # React contexts
├── constants/ # App constants
└── i18n/ # Internationalization
```
## Code Patterns
### API Endpoint
```typescript
import { NextResponse } from "next/server";
import { BookService } from "@/lib/services/book.service";
import { ERROR_CODES } from "@/constants/errorCodes";
import { getErrorMessage } from "@/utils/errors";
import { AppError } from "@/utils/errors";
import logger from "@/lib/logger";
export async function GET(request: NextRequest, { params }: { params: Promise<{ bookId: string }> }) {
try {
const bookId: string = (await params).bookId;
const data = await BookService.getBook(bookId);
return NextResponse.json(data);
} catch (error) {
logger.error({ err: error }, "API Books - Erreur:");
if (error instanceof AppError) {
const isNotFound = error.code === ERROR_CODES.BOOK.NOT_FOUND;
return NextResponse.json(
{ error: { code: error.code, name: "Error", message: getErrorMessage(error.code) } },
{ status: isNotFound ? 404 : 500 }
);
}
return NextResponse.json(
{ error: { code: ERROR_CODES.BOOK.NOT_FOUND, name: "Error", message: "Internal error" } },
{ status: 500 }
);
}
}
```
### Component with Variants
```typescript
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva("inline-flex items-center justify-center...", {
variants: {
variant: { default: "...", destructive: "...", outline: "..." },
size: { default: "...", sm: "...", lg: "..." },
},
defaultVariants: { variant: "default", size: "default" },
});
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonProps, ButtonProps>(({ className, variant, size, asChild, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
});
Button.displayName = "Button";
export { Button, buttonVariants };
```
### Feature Component
```typescript
import type { KomgaBook } from "@/types/komga";
interface HomeContentProps {
data: HomeData;
}
export function HomeContent({ data }: HomeContentProps) {
return (
<div className="space-y-12">
{data.ongoing && <MediaRow titleKey="home.sections.continue" items={data.ongoing} />}
</div>
);
}
```
## Naming Conventions
| Type | Convention | Example |
|------|-----------|---------|
| Files | kebab-case | book-cover.tsx |
| Components | PascalCase | BookCover |
| Functions | camelCase | getBookById |
| Types | PascalCase | KomgaBook |
| Database | snake_case | read_progress |
| API Routes | kebab-case | /api/komga/books |
## Code Standards
- TypeScript strict mode enabled
- Zod for request/response validation
- Prisma for all database queries (type-safe)
- Server Components by default, Client Components when needed
- Custom AppError class with error codes
- Structured logging with Pino
- Error responses: `{ error: { code, name, message } }`
## Security Requirements
- Validate all user input with Zod
- Parameterized queries via Prisma (prevents SQL injection)
- Sanitize before rendering (React handles this)
- HTTPS only in production
- Auth via NextAuth v5
- Role-based access control (admin, user)
- API routes protected with session checks
## 📂 Codebase References
**API Routes**: `src/app/api/**/route.ts` - All API endpoints
**Services**: `src/lib/services/*.service.ts` - Business logic layer
**Components**: `src/components/ui/`, `src/components/*/` - UI and feature components
**Types**: `src/types/**` - TypeScript definitions
**Config**: package.json, tsconfig.json, prisma/schema.prisma
## Related Files
- business-domain.md - Business logic and domain model
- decisions-log.md - Architecture decisions

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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hors ligne - StripStream</title> <title>Hors ligne - StripStream</title>
<style> <style>
:root {
--bg: #020817;
--panel: rgba(2, 8, 23, 0.66);
--panel-strong: rgba(2, 8, 23, 0.82);
--line: rgba(99, 102, 241, 0.3);
--text: #f1f5f9;
--muted: #cbd5e1;
--primary: #4f46e5;
--primary-2: #06b6d4;
}
* {
box-sizing: border-box;
}
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
font-family: font-family:
system-ui, "Segoe UI",
"SF Pro Text",
-apple-system, -apple-system,
BlinkMacSystemFont,
sans-serif; sans-serif;
background-color: #0f172a; background: var(--bg);
color: #e2e8f0; color: var(--text);
min-height: 100vh; min-height: 100vh;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background:
linear-gradient(180deg, rgba(2, 8, 23, 0.99) 0%, rgba(2, 8, 23, 0.94) 42%, #020817 100%),
radial-gradient(70% 45% at 12% 0%, rgba(79, 70, 229, 0.16), transparent 62%),
radial-gradient(58% 38% at 88% 8%, rgba(6, 182, 212, 0.14), transparent 65%),
radial-gradient(50% 34% at 50% 100%, rgba(236, 72, 153, 0.1), transparent 70%),
repeating-linear-gradient(0deg, rgba(226, 232, 240, 0.02) 0 1px, transparent 1px 24px),
repeating-linear-gradient(90deg, rgba(226, 232, 240, 0.015) 0 1px, transparent 1px 30px);
}
.header {
position: sticky;
top: 0;
z-index: 30;
height: 64px;
border-bottom: 1px solid var(--line);
background: rgba(2, 8, 23, 0.7);
backdrop-filter: blur(12px);
overflow: hidden;
}
.header::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(
112deg,
rgba(79, 70, 229, 0.24) 0%,
rgba(6, 182, 212, 0.2) 30%,
transparent 56%
),
linear-gradient(248deg, rgba(244, 114, 182, 0.16) 0%, transparent 46%),
repeating-linear-gradient(135deg, rgba(226, 232, 240, 0.03) 0 1px, transparent 1px 11px);
}
.header-inner {
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0 1rem;
position: relative;
z-index: 1;
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
}
.brand-logo {
width: 2.4rem;
height: 2.4rem;
border-radius: 0.6rem;
object-fit: cover;
box-shadow: 0 0 20px rgba(34, 211, 238, 0.35);
border: 1px solid rgba(148, 163, 184, 0.35);
}
.menu-btn {
width: 2.25rem;
height: 2.25rem;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.35);
background: rgba(15, 23, 42, 0.6);
color: var(--text);
font-size: 1rem;
}
.brand-title {
font-size: 1.05rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
background: linear-gradient(90deg, var(--primary), var(--primary-2), #d946ef);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.brand-subtitle {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.25em;
color: rgba(226, 232, 240, 0.75);
margin-top: 0.2rem;
}
.pill {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.35);
background: rgba(2, 8, 23, 0.55);
color: var(--muted);
padding: 0.45rem 0.7rem;
font-size: 0.78rem;
}
.layout {
position: relative;
min-height: calc(100vh - 64px);
z-index: 1;
}
.sidebar {
position: fixed;
top: 64px;
left: 0;
bottom: 0;
width: 280px;
transform: translateX(-100%);
transition: transform 0.25s ease;
border-right: 1px solid var(--line);
background: var(--panel);
backdrop-filter: blur(12px);
padding: 1rem;
overflow: hidden;
z-index: 35;
}
.sidebar-open .sidebar {
transform: translateX(0);
}
.sidebar-overlay {
position: fixed;
inset: 64px 0 0 0;
background: rgba(2, 6, 23, 0.48);
backdrop-filter: blur(1px);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
z-index: 32;
}
.sidebar-open .sidebar-overlay {
opacity: 1;
pointer-events: auto;
}
.sidebar::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(
160deg,
rgba(79, 70, 229, 0.12) 0%,
rgba(6, 182, 212, 0.08) 32%,
transparent 58%
),
linear-gradient(332deg, rgba(244, 114, 182, 0.06) 0%, transparent 42%),
repeating-linear-gradient(135deg, rgba(226, 232, 240, 0.02) 0 1px, transparent 1px 11px);
}
.sidebar-content {
position: relative;
z-index: 1;
}
.section {
border: 1px solid rgba(148, 163, 184, 0.25);
background: rgba(2, 8, 23, 0.45);
border-radius: 0.9rem;
padding: 0.7rem;
margin-bottom: 0.8rem;
}
.section h2 {
margin: 0.25rem 0.45rem 0.6rem;
font-size: 0.67rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: rgba(148, 163, 184, 0.95);
}
.nav-link {
appearance: none;
width: 100%;
border: 1px solid transparent;
border-radius: 0.65rem;
background: transparent;
color: var(--text);
text-align: left;
padding: 0.62rem 0.78rem;
margin: 0.14rem 0;
font-size: 0.93rem;
transition: background-color 0.2s ease;
}
.nav-link:hover {
background: rgba(148, 163, 184, 0.08);
}
.nav-link.active {
border-color: rgba(79, 70, 229, 0.45);
background: rgba(79, 70, 229, 0.16);
}
.main {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
text-align: center; padding: 2rem;
min-height: calc(100vh - 64px);
}
.card {
width: min(720px, 100%);
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 1rem;
background: var(--panel-strong);
box-shadow: 0 25px 60px -35px rgba(2, 6, 23, 0.92);
padding: 1.5rem;
}
.status {
display: inline-flex;
align-items: center;
gap: 0.45rem;
color: #fecaca;
background: rgba(127, 29, 29, 0.3);
border: 1px solid rgba(248, 113, 113, 0.35);
border-radius: 999px;
padding: 0.35rem 0.7rem;
font-size: 0.82rem;
margin-bottom: 1rem;
}
h1 {
margin: 0 0 0.7rem;
font-size: clamp(1.35rem, 2.4vw, 1.95rem);
line-height: 1.24;
}
p {
margin: 0;
color: var(--muted);
line-height: 1.6;
}
.actions {
display: flex;
gap: 0.7rem;
margin-top: 1.35rem;
}
.btn {
appearance: none;
border: 1px solid transparent;
border-radius: 0.65rem;
padding: 0.7rem 1rem;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s ease;
}
.btn-primary {
background: var(--primary);
color: #fff;
}
.btn-secondary {
background: rgba(2, 8, 23, 0.45);
border-color: rgba(148, 163, 184, 0.35);
color: var(--text);
}
.btn-secondary:hover {
background: rgba(30, 41, 59, 0.65);
}
.btn-primary:hover {
background: #4338ca;
}
.hint {
margin-top: 1rem;
font-size: 0.82rem;
color: rgba(148, 163, 184, 0.95);
}
.footer {
margin-top: 1.2rem;
padding-top: 0.85rem;
border-top: 1px dashed rgba(148, 163, 184, 0.28);
font-size: 0.8rem;
color: rgba(148, 163, 184, 0.88);
}
@media (max-width: 900px) {
.main {
min-height: calc(100vh - 64px);
padding: 1rem; padding: 1rem;
} }
.container {
max-width: 600px; .actions {
margin: 0 auto; flex-direction: column;
} }
h1 {
font-size: 2rem;
margin-bottom: 1rem;
color: #4f46e5;
}
p {
font-size: 1.1rem;
line-height: 1.5;
color: #94a3b8;
margin-bottom: 2rem;
}
.buttons {
display: flex;
gap: 1rem;
justify-content: center;
}
button {
background-color: #4f46e5;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover {
background-color: #4338ca;
}
button.secondary {
background-color: #475569;
}
button.secondary:hover {
background-color: #334155;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <header class="header">
<h1>Vous êtes hors ligne</h1> <div class="header-inner">
<div class="brand">
<button class="menu-btn" id="sidebar-toggle" type="button" aria-label="Menu"></button>
<img class="brand-logo" src="/images/logostripstream.png" alt="StripStream logo" />
<div>
<div class="brand-title">StripStream</div>
<div class="brand-subtitle">comic reader</div>
</div>
</div>
<span class="pill">Mode hors ligne</span>
</div>
</header>
<div class="layout">
<button class="sidebar-overlay" id="sidebar-overlay" aria-label="Fermer le menu"></button>
<aside class="sidebar">
<div class="sidebar-content">
<div class="section">
<h2>Navigation</h2>
<button class="nav-link active" type="button">Accueil</button>
<button class="nav-link" type="button">Telechargements</button>
</div>
<div class="section">
<h2>Compte</h2>
<button class="nav-link" type="button">Mon compte</button>
<button class="nav-link" type="button">Preferences</button>
</div>
</div>
</aside>
<main class="main">
<div class="card">
<div class="status">● Hors ligne</div>
<h1>Cette page n'est pas encore disponible hors ligne.</h1>
<p> <p>
Il semble que vous n'ayez pas de connexion internet. Certaines fonctionnalités de Tu peux continuer a naviguer sur les pages deja consultees. Cette route sera disponible
StripStream peuvent ne pas être disponibles en mode hors ligne. hors ligne apres une visite en ligne.
</p> </p>
<div class="buttons"> <div class="actions">
<button class="secondary" onclick="window.history.back()">Retour</button> <button class="btn btn-secondary" onclick="window.history.back()">Retour</button>
<button onclick="window.location.reload()">Réessayer</button> <button class="btn btn-primary" onclick="window.location.reload()">Reessayer</button>
</div> </div>
<div class="hint">
Astuce: visite d'abord Accueil, Bibliotheques, Series et pages de lecture quand tu es en
ligne.
</div> </div>
<div class="footer">StripStream - interface hors ligne</div>
</div>
</main>
</div>
<script>
const toggle = document.getElementById("sidebar-toggle");
const overlay = document.getElementById("sidebar-overlay");
if (toggle && overlay) {
toggle.addEventListener("click", () => {
document.body.classList.toggle("sidebar-open");
});
overlay.addEventListener("click", () => {
document.body.classList.remove("sidebar-open");
});
}
window.addEventListener("online", () => {
window.location.reload();
});
</script>
</body> </body>
</html> </html>

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 path = require("path");
const sizes = [72, 96, 128, 144, 152, 192, 384, 512]; const sizes = [72, 96, 128, 144, 152, 192, 384, 512];
const inputSvg = path.join(__dirname, "../public/favicon.svg"); const sourceLogo = path.join(__dirname, "../public/images/logostripstream.png");
const inputAppleSvg = path.join(__dirname, "../public/apple-icon.svg");
const outputDir = path.join(__dirname, "../public/images/icons"); const outputDir = path.join(__dirname, "../public/images/icons");
const screenshotsDir = path.join(__dirname, "../public/images/screenshots"); const screenshotsDir = path.join(__dirname, "../public/images/screenshots");
const splashDir = path.join(__dirname, "../public/images/splash"); const splashDir = path.join(__dirname, "../public/images/splash");
const faviconPath = path.join(__dirname, "../public/favicon.png");
// Source pour les splash screens
const splashSource = path.join(__dirname, "../public/images/Gemini_Generated_Image_wyfsoiwyfsoiwyfs.png");
// Configuration des splashscreens pour différents appareils // Configuration des splashscreens pour différents appareils
const splashScreens = [ const splashScreens = [
{ width: 2048, height: 2732, name: "iPad Pro 12.9" }, // iPad Pro 12.9 // iPad (portrait + landscape)
{ width: 1668, height: 2388, name: "iPad Pro 11" }, // iPad Pro 11 { width: 2048, height: 2732, name: "iPad Pro 12.9 portrait" },
{ width: 1536, height: 2048, name: "iPad Mini/Air" }, // iPad Mini, Air { width: 2732, height: 2048, name: "iPad Pro 12.9 landscape" },
{ width: 1125, height: 2436, name: "iPhone X/XS" }, // iPhone X/XS { width: 1668, height: 2388, name: "iPad Pro 11 portrait" },
{ width: 1242, height: 2688, name: "iPhone XS Max" }, // iPhone XS Max { width: 2388, height: 1668, name: "iPad Pro 11 landscape" },
{ width: 828, height: 1792, name: "iPhone XR" }, // iPhone XR { width: 1668, height: 2420, name: "iPad Pro 11 M4 portrait" },
{ width: 750, height: 1334, name: "iPhone 8/SE" }, // iPhone 8, SE { width: 2420, height: 1668, name: "iPad Pro 11 M4 landscape" },
{ width: 1242, height: 2208, name: "iPhone 8 Plus" }, // iPhone 8 Plus { width: 2064, height: 2752, name: "iPad Pro 13 M4 portrait" },
{ width: 2752, height: 2064, name: "iPad Pro 13 M4 landscape" },
{ width: 1536, height: 2048, name: "iPad Mini/Air portrait" },
{ width: 2048, height: 1536, name: "iPad Mini/Air landscape" },
{ width: 1488, height: 2266, name: "iPad Mini 6 portrait" },
{ width: 2266, height: 1488, name: "iPad Mini 6 landscape" },
{ width: 1620, height: 2160, name: "iPad 10.2 portrait" },
{ width: 2160, height: 1620, name: "iPad 10.2 landscape" },
{ width: 1640, height: 2360, name: "iPad Air 10.9 portrait" },
{ width: 2360, height: 1640, name: "iPad Air 10.9 landscape" },
// iPhone legacy
{ width: 1125, height: 2436, name: "iPhone X/XS/11 Pro portrait" },
{ width: 2436, height: 1125, name: "iPhone X/XS/11 Pro landscape" },
{ width: 1242, height: 2688, name: "iPhone XS Max/11 Pro Max portrait" },
{ width: 2688, height: 1242, name: "iPhone XS Max/11 Pro Max landscape" },
{ width: 828, height: 1792, name: "iPhone XR/11 portrait" },
{ width: 1792, height: 828, name: "iPhone XR/11 landscape" },
{ width: 750, height: 1334, name: "iPhone 8/SE portrait" },
{ width: 1334, height: 750, name: "iPhone 8/SE landscape" },
{ width: 1242, height: 2208, name: "iPhone 8 Plus portrait" },
{ width: 2208, height: 1242, name: "iPhone 8 Plus landscape" },
// iPhone modern (12+)
{ width: 1170, height: 2532, name: "iPhone 12/13/14 portrait" },
{ width: 2532, height: 1170, name: "iPhone 12/13/14 landscape" },
{ width: 1284, height: 2778, name: "iPhone 12/13/14 Pro Max portrait" },
{ width: 2778, height: 1284, name: "iPhone 12/13/14 Pro Max landscape" },
{ width: 1179, height: 2556, name: "iPhone 14 Pro/15 portrait" },
{ width: 2556, height: 1179, name: "iPhone 14 Pro/15 landscape" },
{ width: 1290, height: 2796, name: "iPhone 14/15 Pro Max portrait" },
{ width: 2796, height: 1290, name: "iPhone 14/15 Pro Max landscape" },
{ width: 1206, height: 2622, name: "iPhone 16 Pro portrait" },
{ width: 2622, height: 1206, name: "iPhone 16 Pro landscape" },
{ width: 1320, height: 2868, name: "iPhone 16 Pro Max portrait" },
{ width: 2868, height: 1320, name: "iPhone 16 Pro Max landscape" },
{ width: 1170, height: 2532, name: "iPhone 16/16e portrait" },
{ width: 2532, height: 1170, name: "iPhone 16/16e landscape" },
]; ];
async function generateSplashScreens() { async function generateSplashScreens() {
await fs.mkdir(splashDir, { recursive: true }); await fs.mkdir(splashDir, { recursive: true });
console.log(`\n📱 Génération des splash screens...`);
// Créer le SVG de base pour la splashscreen avec le même style que le favicon
const splashSvg = `
<svg width="100%" height="100%" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<rect width="100" height="100" fill="#4F46E5"/>
<g transform="translate(25, 20) scale(1.5)">
<!-- Lettre S stylisée -->
<path
d="M21 12C21 10.3431 19.6569 9 18 9H14C12.3431 9 11 10.3431 11 12V12.5C11 14.1569 12.3431 15.5 14 15.5H18C19.6569 15.5 21 16.8431 21 18.5V19C21 20.6569 19.6569 22 18 22H14C12.3431 22 11 20.6569 11 19"
stroke="white"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Points décoratifs -->
<circle cx="11" cy="24" r="1.5" fill="white"/>
<circle cx="21" cy="8" r="1.5" fill="white"/>
</g>
</svg>
`;
for (const screen of splashScreens) { for (const screen of splashScreens) {
const outputPath = path.join(splashDir, `splash-${screen.width}x${screen.height}.png`); const outputPath = path.join(splashDir, `splash-${screen.width}x${screen.height}.png`);
await sharp(Buffer.from(splashSvg)) await sharp(splashSource)
.resize(screen.width, screen.height, { .resize(screen.width, screen.height, {
fit: "contain", fit: "cover",
background: "#4F46E5", position: "center",
})
.png({
compressionLevel: 9,
}) })
.png()
.toFile(outputPath); .toFile(outputPath);
console.log(` Splashscreen ${screen.name} (${screen.width}x${screen.height}) générée`); console.log(` ${screen.name} (${screen.width}x${screen.height})`);
} }
} }
async function generateIcons() { async function generateIcons() {
try { try {
await fs.access(sourceLogo);
// Créer les dossiers de sortie s'ils n'existent pas // Créer les dossiers de sortie s'ils n'existent pas
await fs.mkdir(outputDir, { recursive: true }); await fs.mkdir(outputDir, { recursive: true });
await fs.mkdir(screenshotsDir, { recursive: true }); await fs.mkdir(screenshotsDir, { recursive: true });
// Générer les icônes Android (avec bords arrondis) // Générer les icônes Android
for (const size of sizes) { for (const size of sizes) {
const outputPath = path.join(outputDir, `icon-${size}x${size}.png`); const outputPath = path.join(outputDir, `icon-${size}x${size}.png`);
await sharp(inputSvg) await sharp(sourceLogo)
.resize(size, size, { .resize(size, size, {
fit: "contain", fit: "cover",
background: { r: 0, g: 0, b: 0, alpha: 0 }, // Fond transparent background: { r: 0, g: 0, b: 0, alpha: 0 }, // Fond transparent
}) })
.png({ .png({
@@ -88,9 +113,9 @@ async function generateIcons() {
for (const size of appleSizes) { for (const size of appleSizes) {
const outputPath = path.join(outputDir, `apple-icon-${size}x${size}.png`); const outputPath = path.join(outputDir, `apple-icon-${size}x${size}.png`);
await sharp(inputAppleSvg) await sharp(sourceLogo)
.resize(size, size, { .resize(size, size, {
fit: "contain", fit: "cover",
background: { r: 0, g: 0, b: 0, alpha: 0 }, // Fond transparent background: { r: 0, g: 0, b: 0, alpha: 0 }, // Fond transparent
}) })
.png({ .png({
@@ -102,26 +127,25 @@ async function generateIcons() {
console.log(`✓ Icône Apple ${size}x${size} générée`); console.log(`✓ Icône Apple ${size}x${size} générée`);
} }
// Générer le favicon principal utilisé par Next metadata
await sharp(sourceLogo)
.resize(64, 64, {
fit: "cover",
})
.png({
compressionLevel: 9,
palette: true,
})
.toFile(faviconPath);
console.log("✓ Favicon principal généré");
// Générer les icônes de raccourcis // Générer les icônes de raccourcis
const shortcutIcons = [ const shortcutIcons = ["home", "library"];
{ name: "home", icon: "Home" },
{ name: "library", icon: "Library" },
];
for (const shortcut of shortcutIcons) { for (const shortcut of shortcutIcons) {
const outputPath = path.join(outputDir, `${shortcut.name}.png`); const outputPath = path.join(outputDir, `${shortcut}.png`);
await sharp(sourceLogo)
// Créer une image carrée avec fond indigo et icône blanche
const svg = `
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="96" height="96" rx="20" fill="#4F46E5"/>
<path d="${getIconPath(
shortcut.icon
)}" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
await sharp(Buffer.from(svg))
.resize(96, 96) .resize(96, 96)
.png({ .png({
compressionLevel: 9, compressionLevel: 9,
@@ -129,7 +153,7 @@ async function generateIcons() {
}) })
.toFile(outputPath); .toFile(outputPath);
console.log(`✓ Icône de raccourci ${shortcut.name} générée`); console.log(`✓ Icône de raccourci ${shortcut} générée`);
} }
// Générer les screenshots de démonstration // Générer les screenshots de démonstration
@@ -166,14 +190,4 @@ async function generateIcons() {
} }
} }
// Fonction helper pour obtenir les chemins SVG des icônes
function getIconPath(iconName) {
const paths = {
Home: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6",
Library:
"M4 19.5A2.5 2.5 0 0 1 6.5 17H20M4 19.5A2.5 2.5 0 0 0 6.5 22H20M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20",
};
return paths[iconName] || "";
}
generateIcons(); generateIcons();

View File

@@ -15,7 +15,7 @@ export default async function AccountPage() {
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto space-y-8"> <div className="mx-auto max-w-4xl space-y-8">
<div> <div>
<h1 className="text-3xl font-bold">Mon compte</h1> <h1 className="text-3xl font-bold">Mon compte</h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">
@@ -25,7 +25,7 @@ export default async function AccountPage() {
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
<UserProfileCard profile={{ ...profile, stats }} /> <UserProfileCard profile={{ ...profile, stats }} />
<ChangePasswordForm /> <ChangePasswordForm username={profile.email} />
</div> </div>
</div> </div>
</div> </div>

98
src/app/actions/admin.ts Normal file
View File

@@ -0,0 +1,98 @@
"use server";
import { AdminService } from "@/lib/services/admin.service";
import type { AdminUserData } from "@/lib/services/admin.service";
import { AppError } from "@/utils/errors";
import { AuthServerService } from "@/lib/services/auth-server.service";
export interface AdminStatsData {
totalUsers: number;
totalAdmins: number;
usersWithKomga: number;
usersWithPreferences: number;
}
export async function getAdminDashboardData(): Promise<{
success: boolean;
users?: AdminUserData[];
stats?: AdminStatsData;
message?: string;
}> {
try {
const [users, stats] = await Promise.all([AdminService.getAllUsers(), AdminService.getUserStats()]);
return { success: true, users, stats };
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors de la récupération des données admin" };
}
}
/**
* Met à jour les rôles d'un utilisateur
*/
export async function updateUserRoles(
userId: string,
roles: string[]
): Promise<{ success: boolean; message: string }> {
try {
if (roles.length === 0) {
return { success: false, message: "L'utilisateur doit avoir au moins un rôle" };
}
await AdminService.updateUserRoles(userId, roles);
return { success: true, message: "Rôles mis à jour" };
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors de la mise à jour des rôles" };
}
}
/**
* Supprime un utilisateur
*/
export async function deleteUser(
userId: string
): Promise<{ success: boolean; message: string }> {
try {
await AdminService.deleteUser(userId);
return { success: true, message: "Utilisateur supprimé" };
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors de la suppression" };
}
}
/**
* Réinitialise le mot de passe d'un utilisateur
*/
export async function resetUserPassword(
userId: string,
newPassword: string
): Promise<{ success: boolean; message: string }> {
try {
if (!AuthServerService.isPasswordStrong(newPassword)) {
return {
success: false,
message: "Le mot de passe doit contenir au moins 8 caractères, une majuscule et un chiffre",
};
}
await AdminService.resetUserPassword(userId, newPassword);
return { success: true, message: "Mot de passe réinitialisé" };
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors de la réinitialisation du mot de passe" };
}
}

22
src/app/actions/auth.ts Normal file
View File

@@ -0,0 +1,22 @@
"use server";
import { AuthServerService } from "@/lib/services/auth-server.service";
import { AppError } from "@/utils/errors";
/**
* Inscrit un nouvel utilisateur
*/
export async function registerUser(
email: string,
password: string
): Promise<{ success: boolean; message: string }> {
try {
await AuthServerService.registerUser(email, password);
return { success: true, message: "Inscription réussie" };
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors de l'inscription" };
}
}

46
src/app/actions/books.ts Normal file
View File

@@ -0,0 +1,46 @@
"use server";
import { getProvider } from "@/lib/providers/provider.factory";
import { AppError } from "@/utils/errors";
import type { NormalizedBook } from "@/lib/providers/types";
import logger from "@/lib/logger";
interface BookDataResult {
success: boolean;
data?: {
book: NormalizedBook;
pages: number[];
nextBook: NormalizedBook | null;
};
message?: string;
}
export async function getBookData(bookId: string): Promise<BookDataResult> {
try {
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: { book, pages, nextBook },
};
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.code };
}
return { success: false, message: "BOOK_DATA_FETCH_ERROR" };
}
}

67
src/app/actions/config.ts Normal file
View File

@@ -0,0 +1,67 @@
"use server";
import { revalidatePath } from "next/cache";
import { ConfigDBService } from "@/lib/services/config-db.service";
import { AppError } from "@/utils/errors";
import { ERROR_CODES } from "@/constants/errorCodes";
import type { KomgaConfig, KomgaConfigData, KomgaLibrary } from "@/types/komga";
interface SaveConfigInput {
url: string;
username: string;
password?: string;
authHeader?: string;
}
export async function testKomgaConnection(
serverUrl: string,
username: string,
password: string
): Promise<{ success: boolean; message: string }> {
try {
const authHeader = Buffer.from(`${username}:${password}`).toString("base64");
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) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors de la connexion" };
}
}
export async function saveKomgaConfig(
config: SaveConfigInput
): Promise<{ success: boolean; message: string; data?: KomgaConfig }> {
try {
const configData: KomgaConfigData = {
url: config.url,
username: config.username,
password: config.password,
authHeader: config.authHeader || "",
};
const mongoConfig = await ConfigDBService.saveConfig(configData);
revalidatePath("/settings");
return { success: true, message: "Configuration sauvegardée", data: mongoConfig };
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors de la sauvegarde" };
}
}

View File

@@ -0,0 +1,38 @@
"use server";
import { FavoriteService } from "@/lib/services/favorite.service";
import { AppError } from "@/utils/errors";
/**
* Ajoute une série aux favoris
*/
export async function addToFavorites(
seriesId: string
): Promise<{ success: boolean; message: string }> {
try {
await FavoriteService.addToFavorites(seriesId);
return { success: true, message: "Série ajoutée aux favoris" };
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors de l'ajout aux favoris" };
}
}
/**
* Retire une série des favoris
*/
export async function removeFromFavorites(
seriesId: string
): Promise<{ success: boolean; message: string }> {
try {
await FavoriteService.removeFromFavorites(seriesId);
return { success: true, message: "Série retirée des favoris" };
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors de la suppression des favoris" };
}
}

View File

@@ -0,0 +1,47 @@
"use server";
import { revalidatePath } from "next/cache";
import { getProvider } from "@/lib/providers/provider.factory";
import { AppError } from "@/utils/errors";
export async function scanLibrary(
libraryId: string
): Promise<{ success: boolean; message: string }> {
try {
const provider = await getProvider();
if (!provider) return { success: false, message: "Provider non configuré" };
await provider.scanLibrary(libraryId);
revalidatePath(`/libraries/${libraryId}`);
revalidatePath("/libraries");
return { success: true, message: "Scan lancé" };
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors du scan" };
}
}
export async function getRandomBookFromLibraries(
libraryIds: string[]
): Promise<{ success: boolean; bookId?: string; message?: string }> {
try {
if (!libraryIds.length) {
return { success: false, message: "Au moins une bibliothèque doit être sélectionnée" };
}
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

@@ -0,0 +1,32 @@
"use server";
import { UserService } from "@/lib/services/user.service";
import { AuthServerService } from "@/lib/services/auth-server.service";
import { AppError } from "@/utils/errors";
/**
* Change le mot de passe de l'utilisateur
*/
export async function changePassword(
currentPassword: string,
newPassword: string
): Promise<{ success: boolean; message: string }> {
try {
// Vérifier que le nouveau mot de passe est fort
if (!AuthServerService.isPasswordStrong(newPassword)) {
return {
success: false,
message: "Le nouveau mot de passe doit contenir au moins 8 caractères, une majuscule et un chiffre",
};
}
await UserService.changePassword(currentPassword, newPassword);
return { success: true, message: "Mot de passe modifié avec succès" };
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors du changement de mot de passe" };
}
}

View File

@@ -0,0 +1,29 @@
"use server";
import { revalidatePath } from "next/cache";
import { PreferencesService } from "@/lib/services/preferences.service";
import { AppError } from "@/utils/errors";
import type { UserPreferences } from "@/types/preferences";
/**
* Met à jour les préférences utilisateur
*/
export async function updatePreferences(
newPreferences: Partial<UserPreferences>
): Promise<{ success: boolean; message: string; data?: UserPreferences }> {
try {
const updatedPreferences = await PreferencesService.updatePreferences(newPreferences);
// Invalider les pages qui utilisent les préférences
revalidatePath("/");
revalidatePath("/libraries");
revalidatePath("/series");
return { success: true, message: "Préférences mises à jour", data: updatedPreferences };
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors de la mise à jour des préférences" };
}
}

View File

@@ -0,0 +1,52 @@
"use server";
import { revalidateTag } from "next/cache";
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";
function revalidateReadCaches() {
revalidateTag(HOME_CACHE_TAG, "max");
revalidateTag(LIBRARY_SERIES_CACHE_TAG, "max");
revalidateTag(SERIES_BOOKS_CACHE_TAG, "max");
}
export async function updateReadProgress(
bookId: string,
page: number,
completed: boolean = false
): Promise<{ success: boolean; message: string }> {
try {
const provider = await getProvider();
if (!provider) return { success: false, message: "Provider non configuré" };
await provider.saveReadProgress(bookId, page, completed);
revalidateReadCaches();
return { success: true, message: "Progression mise à jour" };
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors de la mise à jour" };
}
}
export async function deleteReadProgress(
bookId: string
): Promise<{ success: boolean; message: string }> {
try {
const provider = await getProvider();
if (!provider) return { success: false, message: "Provider non configuré" };
await provider.resetReadProgress(bookId);
revalidateReadCaches();
return { success: true, message: "Progression supprimée" };
} catch (error) {
if (error instanceof AppError) {
return { success: false, message: error.message };
}
return { success: false, message: "Erreur lors de la suppression" };
}
}

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

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