Compare commits
87 Commits
034aa69f8d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d9ffacc124 | |||
| 8cdbebaafb | |||
| c5da33d6b2 | |||
| a82ce024ee | |||
| f48d894eca | |||
| a1a986f462 | |||
| 894ea7114c | |||
| 32757a8723 | |||
| 11da2335cd | |||
| feceb61e30 | |||
| 701a02b55c | |||
| b2664cce08 | |||
| ff44a781c8 | |||
| d535f9f28e | |||
| 2174579cc1 | |||
| e6eab32473 | |||
| 86b7382a04 | |||
| 53af9db046 | |||
| 1d03cfc177 | |||
| b0d56948a3 | |||
| fc9c220be6 | |||
| 100d8b37e7 | |||
| f9651676a5 | |||
| 539bb34716 | |||
| 8d1f91d636 | |||
| 7e4c48469a | |||
| e74b02e3a2 | |||
| 7d0f1c4457 | |||
| a1a95775db | |||
| 3d7ac0c13e | |||
| 818fe67c99 | |||
| 06848d2c3a | |||
| 4e8c8ebac0 | |||
| 23fa884af7 | |||
| 6a06e5a7d3 | |||
| 3e5687441d | |||
| 99d9f41299 | |||
| 30e3529be3 | |||
| 4288e4c541 | |||
| fdc9da7f8f | |||
| 4441c59584 | |||
| fead5ff6a0 | |||
| e6fe5ac27f | |||
| c704e24a53 | |||
| 5a3b0ace61 | |||
| 844cd3f58e | |||
| 6a1f208e66 | |||
| b8961b85c5 | |||
| 8e7c46de23 | |||
| dc9f90f78f | |||
| 0cb51ce99d | |||
| 41faa30453 | |||
| 25ede2532e | |||
| 6ce8a6e38d | |||
| 83212434f2 | |||
| 9b679a4db2 | |||
| 01951c806d | |||
| 26021ea907 | |||
| 5eba969846 | |||
| 9a11ab16bb | |||
| 70a77481e5 | |||
| b1e0e18d9e | |||
| e5497b4f58 | |||
| 612a70ffbe | |||
| 1a88efc46b | |||
| 29f5324bd7 | |||
| 7f361ce0a2 | |||
| eec51b7ef8 | |||
| b40f59bec6 | |||
| 7134c069d7 | |||
| b815202529 | |||
| 0548215096 | |||
| 6180f9abb1 | |||
| d56b0fd7ae | |||
| 7308c0aa63 | |||
| 7e3fb22d3a | |||
| 546f3769c2 | |||
| 03cb46f81b | |||
| ecce0a9738 | |||
| 7523ec06e1 | |||
| 2908172777 | |||
| 2669fb9865 | |||
| fcbd9d0533 | |||
| 0c3a54c62c | |||
| bcfd602353 | |||
| 38c7e59366 | |||
| b9c8b05bc8 |
@@ -5,4 +5,8 @@ MONGODB_URI=mongodb://admin:password@host.docker.internal:27017/stripstream?auth
|
||||
|
||||
NEXTAUTH_SECRET=SECRET
|
||||
#openssl rand -base64 32
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
|
||||
# Stripstream Librarian (optionnel : fallback si l'utilisateur n'a pas sauvegardé d'URL/token en base)
|
||||
# STRIPSTREAM_URL=https://librarian.example.com
|
||||
# STRIPSTREAM_TOKEN=stl_xxxx_xxxxxxxx
|
||||
@@ -1,26 +1,33 @@
|
||||
name: Deploy with Docker Compose
|
||||
name: Build, Push & Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main # adapte la branche que tu veux déployer
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: mac-orbstack-runner # le nom que tu as donné au runner
|
||||
runs-on: mac-orbstack-runner
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy stack
|
||||
- name: Login to DockerHub
|
||||
run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Build Docker image
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
COMPOSE_DOCKER_CLI_BUILD: 1
|
||||
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL }}
|
||||
ADMIN_DEFAULT_PASSWORD: ${{ secrets.ADMIN_DEFAULT_PASSWORD }}
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
PRISMA_DATA_PATH: ${{ vars.PRISMA_DATA_PATH }}
|
||||
NODE_ENV: production
|
||||
run: docker build -t julienfroidefond32/stripstream:latest .
|
||||
|
||||
- name: Push to DockerHub
|
||||
run: docker push julienfroidefond32/stripstream:latest
|
||||
|
||||
- name: Pull new image and restart container
|
||||
run: |
|
||||
docker compose up -d --build
|
||||
docker pull julienfroidefond32/stripstream:latest
|
||||
cd /Users/julienfroidefond/Sites/docker-stack
|
||||
./scripts/stack.sh up stripstream
|
||||
|
||||
- name: Cleanup old images
|
||||
run: docker image prune -f
|
||||
|
||||
52
AGENTS.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- `src/app/`: Next.js App Router pages, layouts, API routes, and server actions.
|
||||
- `src/components/`: UI and feature components (`home/`, `reader/`, `layout/`, `ui/`).
|
||||
- `src/lib/`: shared services (Komga/API access), auth, logger, utilities.
|
||||
- `src/hooks/`, `src/contexts/`, `src/types/`, `src/constants/`: reusable runtime logic and typing.
|
||||
- `src/i18n/messages/{en,fr}/`: translation dictionaries.
|
||||
- `prisma/`: database schema and Prisma artifacts.
|
||||
- `public/`: static files and PWA assets.
|
||||
- `scripts/`: maintenance scripts (DB init, admin password reset, icon generation).
|
||||
- `docs/` and `devbook.md`: implementation notes and architecture decisions.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
Use `pnpm` (lockfile and `packageManager` are configured for it).
|
||||
- `pnpm dev`: start local dev server.
|
||||
- `pnpm build`: create production build.
|
||||
- `pnpm start`: run production server.
|
||||
- `pnpm lint`: run ESLint across the repo.
|
||||
- `pnpm typecheck` or `pnpm -s tsc --noEmit`: strict TypeScript checks.
|
||||
- `pnpm init-db`: initialize database data.
|
||||
- `pnpm reset-admin-password`: reset admin credentials.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Language: TypeScript (`.ts/.tsx`) with React function components.
|
||||
- Architecture priority: **server-first**. Default to React Server Components (RSC) for pages and feature composition.
|
||||
- Data mutations: prefer **Server Actions** (`src/app/actions/`) over client-side fetch patterns when possible.
|
||||
- Client components (`"use client"`): use only for browser-only concerns (event handlers, local UI state, effects, DOM APIs).
|
||||
- Data fetching: do it on the server first (`page.tsx`, server components, services in `src/lib/services`), then pass serialized props down.
|
||||
- Indentation: 2 spaces; keep imports grouped and sorted logically.
|
||||
- Components/hooks/services: `PascalCase` for components, `camelCase` for hooks/functions, `*.service.ts` for service modules.
|
||||
- Styling: Tailwind utility classes; prefer existing `src/components/ui` primitives before creating new ones.
|
||||
- Quality gates: ESLint (`eslint.config.mjs`) + TypeScript must pass before merge.
|
||||
|
||||
## Testing Guidelines
|
||||
- No dedicated unit test framework is currently configured.
|
||||
- Minimum validation for each change: `pnpm lint` and `pnpm typecheck`.
|
||||
- For UI changes, perform a quick manual smoke test on affected routes (home, libraries, series, reader) and both themes.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Follow Conventional Commit style seen in history: `fix: ...`, `refactor: ...`, `feat: ...`.
|
||||
- Keep subjects imperative and specific (e.g., `fix: reduce header/home spacing overlap`).
|
||||
- PRs should include:
|
||||
- short problem/solution summary,
|
||||
- linked issue (if any),
|
||||
- screenshots or short video for UI updates,
|
||||
- verification steps/commands run.
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Never commit secrets; use `.env` based on `.env.example`.
|
||||
- Validate Komga and auth-related config through settings flows before merging.
|
||||
- Prefer server-side data fetching/services for sensitive operations.
|
||||
23
Dockerfile
@@ -17,7 +17,7 @@ COPY package.json pnpm-lock.yaml ./
|
||||
COPY prisma ./prisma
|
||||
|
||||
# Copy configuration files
|
||||
COPY tsconfig.json .eslintrc.json ./
|
||||
COPY tsconfig.json .eslintrc.json next.config.js ./
|
||||
COPY tailwind.config.ts postcss.config.js ./
|
||||
|
||||
# Install dependencies with pnpm using cache mount for store
|
||||
@@ -43,22 +43,20 @@ WORKDIR /app
|
||||
# Install OpenSSL (required by Prisma)
|
||||
RUN apk add --no-cache openssl libc6-compat
|
||||
|
||||
# Copy package files and prisma schema
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY prisma ./prisma
|
||||
# Copy standalone output (server.js + minimal node_modules)
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
|
||||
# Enable pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
|
||||
# Copy static assets and public directory
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Copy the entire node_modules from builder (includes Prisma Client)
|
||||
# Copy full node_modules for Prisma CLI (pnpm symlinks prevent cherry-picking)
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/next-env.d.ts ./
|
||||
COPY --from=builder /app/tailwind.config.ts ./
|
||||
# Copy prisma schema and init scripts
|
||||
COPY prisma ./prisma
|
||||
COPY --from=builder /app/scripts ./scripts
|
||||
COPY package.json ./
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY docker-entrypoint.sh ./
|
||||
@@ -76,6 +74,7 @@ USER nextjs
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
68
README.md
@@ -74,7 +74,7 @@ A modern web application for reading digital comics, built with Next.js 14 and t
|
||||
## 🛠 Prerequisites
|
||||
|
||||
- Node.js 20.x or higher
|
||||
- Yarn 1.22.x or higher
|
||||
- pnpm 9.x or higher
|
||||
- Docker and Docker Compose (optional)
|
||||
|
||||
## 📦 Installation
|
||||
@@ -91,7 +91,7 @@ cd stripstream
|
||||
2. Install dependencies
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. Copy the example environment file and adjust it to your needs
|
||||
@@ -103,10 +103,10 @@ cp .env.example .env.local
|
||||
4. Start the development server
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### With Docker
|
||||
### With Docker (Build Local)
|
||||
|
||||
1. Clone the repository and navigate to the folder
|
||||
|
||||
@@ -121,15 +121,65 @@ cd stripstream
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
The application will be accessible at `http://localhost:3020`
|
||||
|
||||
### With Docker (DockerHub Image)
|
||||
|
||||
You can also use the pre-built image from DockerHub without cloning the repository:
|
||||
|
||||
1. Create a `docker-compose.yml` file:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: julienfroidefond32/stripstream:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
# Required
|
||||
- NEXTAUTH_SECRET=your_secret_here # openssl rand -base64 32
|
||||
- NEXTAUTH_URL=http://localhost:3000
|
||||
|
||||
# Optional — defaults shown
|
||||
# - NODE_ENV=production
|
||||
# - DATABASE_URL=file:/app/prisma/data/stripstream.db
|
||||
# - ADMIN_DEFAULT_PASSWORD=Admin@2025
|
||||
# - AUTH_TRUST_HOST=true
|
||||
# - KOMGA_MAX_CONCURRENT_REQUESTS=5
|
||||
volumes:
|
||||
- ./data:/app/prisma/data
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
2. Run the container:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
The application will be accessible at `http://localhost:3000`
|
||||
|
||||
## 🔧 Available Scripts
|
||||
|
||||
- `yarn dev` - Starts the development server
|
||||
- `yarn build` - Creates a production build
|
||||
- `yarn start` - Runs the production version
|
||||
- `yarn lint` - Checks code with ESLint
|
||||
- `yarn format` - Formats code with Prettier
|
||||
- `pnpm dev` - Starts the development server
|
||||
- `pnpm build` - Creates a production build
|
||||
- `pnpm start` - Runs the production version
|
||||
- `pnpm lint` - Checks code with ESLint
|
||||
- `./docker-push.sh [tag]` - Build and push Docker image to DockerHub (default tag: `latest`)
|
||||
|
||||
### Docker Push Script
|
||||
|
||||
The `docker-push.sh` script automates building and pushing the Docker image to DockerHub:
|
||||
|
||||
```bash
|
||||
# Push with 'latest' tag
|
||||
./docker-push.sh
|
||||
|
||||
# Push with a specific version tag
|
||||
./docker-push.sh v1.0.0
|
||||
```
|
||||
|
||||
**Prerequisite:** You must be logged in to DockerHub (`docker login`) before running the script.
|
||||
|
||||
## 🌐 Komga API
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ version: "3.8"
|
||||
|
||||
services:
|
||||
stripstream-app:
|
||||
image: julienfroidefond32/stripstream:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "📁 Ensuring data directory exists..."
|
||||
mkdir -p /app/data
|
||||
|
||||
echo "🔄 Pushing Prisma schema to database..."
|
||||
npx prisma db push --skip-generate --accept-data-loss
|
||||
echo "🔄 Applying database migrations..."
|
||||
./node_modules/.bin/prisma migrate deploy
|
||||
|
||||
echo "🔧 Initializing database..."
|
||||
node scripts/init-db.mjs
|
||||
|
||||
echo "🚀 Starting application..."
|
||||
exec pnpm start
|
||||
|
||||
exec node server.js
|
||||
|
||||
24
docker-push.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script pour builder et push l'image Docker vers DockerHub
|
||||
# Usage: ./docker-push.sh [tag]
|
||||
|
||||
set -e
|
||||
|
||||
DOCKER_USERNAME="julienfroidefond32"
|
||||
IMAGE_NAME="stripstream"
|
||||
|
||||
# Utiliser le tag fourni ou 'latest' par défaut
|
||||
TAG=${1:-latest}
|
||||
|
||||
FULL_IMAGE_NAME="$DOCKER_USERNAME/$IMAGE_NAME:$TAG"
|
||||
|
||||
echo "=== Building Docker image: $FULL_IMAGE_NAME ==="
|
||||
docker build -t $FULL_IMAGE_NAME .
|
||||
|
||||
echo ""
|
||||
echo "=== Pushing to DockerHub: $FULL_IMAGE_NAME ==="
|
||||
docker push $FULL_IMAGE_NAME
|
||||
|
||||
echo ""
|
||||
echo "=== Successfully pushed: $FULL_IMAGE_NAME ==="
|
||||
72
docs/api-get-cleanup.md
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,5 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
webpack: (config) => {
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
|
||||
20
package.json
@@ -9,7 +9,7 @@
|
||||
"start:prod": "node scripts/init-db.mjs && pnpm start",
|
||||
"init-db": "node scripts/init-db.mjs",
|
||||
"reset-admin-password": "node scripts/reset-admin-password.mjs",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"icons": "node scripts/generate-icons.js",
|
||||
"postinstall": "prisma generate"
|
||||
@@ -37,14 +37,14 @@
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"lucide-react": "^0.487.0",
|
||||
"mongodb": "^6.20.0",
|
||||
"next": "^15.5.9",
|
||||
"next": "^16.1.6",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"next-themes": "0.2.1",
|
||||
"photoswipe": "^5.4.4",
|
||||
"pino": "^10.1.0",
|
||||
"pino-pretty": "^13.1.2",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-i18next": "^15.4.1",
|
||||
"sharp": "0.33.2",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
@@ -54,13 +54,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.7.2",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.24.0",
|
||||
"@typescript-eslint/parser": "6.21.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"autoprefixer": "10.4.17",
|
||||
"eslint": "8.56.0",
|
||||
"eslint-config-next": "15.2.0",
|
||||
"eslint": "9.39.3",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"eslint-config-prettier": "10.0.1",
|
||||
"eslint-plugin-typescript-sort-keys": "^3.3.0",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
|
||||
1841
pnpm-lock.yaml
generated
77
prisma/migrations/20260311203728_init/migration.sql
Normal file
@@ -0,0 +1,77 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"roles" JSONB NOT NULL DEFAULT ["ROLE_USER"],
|
||||
"authenticated" BOOLEAN NOT NULL DEFAULT true,
|
||||
"activeProvider" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "komgaconfigs" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"authHeader" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "komgaconfigs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "stripstreamconfigs" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "stripstreamconfigs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "preferences" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"showThumbnails" BOOLEAN NOT NULL DEFAULT true,
|
||||
"showOnlyUnread" BOOLEAN NOT NULL DEFAULT false,
|
||||
"displayMode" JSONB NOT NULL,
|
||||
"background" JSONB NOT NULL,
|
||||
"readerPrefetchCount" INTEGER NOT NULL DEFAULT 5,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "preferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "favorites" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"seriesId" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL DEFAULT 'komga',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "favorites_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "komgaconfigs_userId_key" ON "komgaconfigs"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "stripstreamconfigs_userId_key" ON "stripstreamconfigs"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "preferences_userId_key" ON "preferences"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "favorites_userId_idx" ON "favorites"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "favorites_userId_provider_seriesId_key" ON "favorites"("userId", "provider", "seriesId");
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
@@ -11,18 +11,20 @@ datasource db {
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
password String
|
||||
roles Json @default("[\"ROLE_USER\"]")
|
||||
authenticated Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
password String
|
||||
roles Json @default("[\"ROLE_USER\"]")
|
||||
authenticated Boolean @default(true)
|
||||
activeProvider String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
config KomgaConfig?
|
||||
preferences Preferences?
|
||||
favorites Favorite[]
|
||||
config KomgaConfig?
|
||||
stripstreamConfig StripstreamConfig?
|
||||
preferences Preferences?
|
||||
favorites Favorite[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -41,6 +43,19 @@ model KomgaConfig {
|
||||
@@map("komgaconfigs")
|
||||
}
|
||||
|
||||
model StripstreamConfig {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int @unique
|
||||
url String
|
||||
token String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("stripstreamconfigs")
|
||||
}
|
||||
|
||||
model Preferences {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int @unique
|
||||
@@ -49,6 +64,7 @@ model Preferences {
|
||||
displayMode Json
|
||||
background Json
|
||||
readerPrefetchCount Int @default(5)
|
||||
anonymousMode Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -61,12 +77,13 @@ model Favorite {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
seriesId String
|
||||
provider String @default("komga")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, seriesId])
|
||||
@@unique([userId, provider, seriesId])
|
||||
@@index([userId])
|
||||
@@map("favorites")
|
||||
}
|
||||
|
||||
65
project-intelligence/business-domain.md
Normal 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
|
||||
65
project-intelligence/business-tech-bridge.md
Normal 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
|
||||
130
project-intelligence/decisions-log.md
Normal 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
|
||||
64
project-intelligence/living-notes.md
Normal 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
|
||||
23
project-intelligence/navigation.md
Normal 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
|
||||
154
project-intelligence/technical-domain.md
Normal 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
|
After Width: | Height: | Size: 4.0 KiB |
BIN
public/images/Gemini_Generated_Image_wyfsoiwyfsoiwyfs.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 21 KiB |
BIN
public/images/icons/home.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 7.3 KiB |
BIN
public/images/icons/library.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
public/images/logostripstream-white.png
Normal file
|
After Width: | Height: | Size: 889 KiB |
BIN
public/images/logostripstream.png
Normal file
|
After Width: | Height: | Size: 895 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 4.1 MiB |
BIN
public/images/splash/splash-1170x2532.png
Normal file
|
After Width: | Height: | Size: 4.3 MiB |
BIN
public/images/splash/splash-1179x2556.png
Normal file
|
After Width: | Height: | Size: 4.4 MiB |
BIN
public/images/splash/splash-1206x2622.png
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 4.7 MiB |
BIN
public/images/splash/splash-1284x2778.png
Normal file
|
After Width: | Height: | Size: 4.9 MiB |
BIN
public/images/splash/splash-1290x2796.png
Normal file
|
After Width: | Height: | Size: 5.0 MiB |
BIN
public/images/splash/splash-1320x2868.png
Normal file
|
After Width: | Height: | Size: 5.1 MiB |
BIN
public/images/splash/splash-1334x750.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/images/splash/splash-1488x2266.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 4.5 MiB |
BIN
public/images/splash/splash-1620x2160.png
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
BIN
public/images/splash/splash-1640x2360.png
Normal file
|
After Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 5.3 MiB |
BIN
public/images/splash/splash-1668x2420.png
Normal file
|
After Width: | Height: | Size: 5.3 MiB |
BIN
public/images/splash/splash-1792x828.png
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
public/images/splash/splash-2048x1536.png
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 6.8 MiB |
BIN
public/images/splash/splash-2064x2752.png
Normal file
|
After Width: | Height: | Size: 6.9 MiB |
BIN
public/images/splash/splash-2160x1620.png
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
BIN
public/images/splash/splash-2208x1242.png
Normal file
|
After Width: | Height: | Size: 4.0 MiB |
BIN
public/images/splash/splash-2266x1488.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
BIN
public/images/splash/splash-2360x1640.png
Normal file
|
After Width: | Height: | Size: 5.2 MiB |
BIN
public/images/splash/splash-2388x1668.png
Normal file
|
After Width: | Height: | Size: 5.3 MiB |
BIN
public/images/splash/splash-2420x1668.png
Normal file
|
After Width: | Height: | Size: 5.3 MiB |
BIN
public/images/splash/splash-2436x1125.png
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
BIN
public/images/splash/splash-2532x1170.png
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
public/images/splash/splash-2556x1179.png
Normal file
|
After Width: | Height: | Size: 4.2 MiB |
BIN
public/images/splash/splash-2622x1206.png
Normal file
|
After Width: | Height: | Size: 4.4 MiB |
BIN
public/images/splash/splash-2688x1242.png
Normal file
|
After Width: | Height: | Size: 4.6 MiB |
BIN
public/images/splash/splash-2732x2048.png
Normal file
|
After Width: | Height: | Size: 6.7 MiB |
BIN
public/images/splash/splash-2752x2064.png
Normal file
|
After Width: | Height: | Size: 6.8 MiB |
BIN
public/images/splash/splash-2778x1284.png
Normal file
|
After Width: | Height: | Size: 4.8 MiB |
BIN
public/images/splash/splash-2796x1290.png
Normal file
|
After Width: | Height: | Size: 4.9 MiB |
BIN
public/images/splash/splash-2868x1320.png
Normal file
|
After Width: | Height: | Size: 5.0 MiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 2.5 MiB |
@@ -5,75 +5,417 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hors ligne - StripStream</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #020817;
|
||||
--panel: rgba(2, 8, 23, 0.66);
|
||||
--panel-strong: rgba(2, 8, 23, 0.82);
|
||||
--line: rgba(99, 102, 241, 0.3);
|
||||
--text: #f1f5f9;
|
||||
--muted: #cbd5e1;
|
||||
--primary: #4f46e5;
|
||||
--primary-2: #06b6d4;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family:
|
||||
system-ui,
|
||||
"Segoe UI",
|
||||
"SF Pro Text",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
sans-serif;
|
||||
background-color: #0f172a;
|
||||
color: #e2e8f0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(2, 8, 23, 0.99) 0%, rgba(2, 8, 23, 0.94) 42%, #020817 100%),
|
||||
radial-gradient(70% 45% at 12% 0%, rgba(79, 70, 229, 0.16), transparent 62%),
|
||||
radial-gradient(58% 38% at 88% 8%, rgba(6, 182, 212, 0.14), transparent 65%),
|
||||
radial-gradient(50% 34% at 50% 100%, rgba(236, 72, 153, 0.1), transparent 70%),
|
||||
repeating-linear-gradient(0deg, rgba(226, 232, 240, 0.02) 0 1px, transparent 1px 24px),
|
||||
repeating-linear-gradient(90deg, rgba(226, 232, 240, 0.015) 0 1px, transparent 1px 30px);
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
height: 64px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(2, 8, 23, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(
|
||||
112deg,
|
||||
rgba(79, 70, 229, 0.24) 0%,
|
||||
rgba(6, 182, 212, 0.2) 30%,
|
||||
transparent 56%
|
||||
),
|
||||
linear-gradient(248deg, rgba(244, 114, 182, 0.16) 0%, transparent 46%),
|
||||
repeating-linear-gradient(135deg, rgba(226, 232, 240, 0.03) 0 1px, transparent 1px 11px);
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0 1rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
border-radius: 0.6rem;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 0 20px rgba(34, 211, 238, 0.35);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
background: linear-gradient(90deg, var(--primary), var(--primary-2), #d946ef);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.25em;
|
||||
color: rgba(226, 232, 240, 0.75);
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
background: rgba(2, 8, 23, 0.55);
|
||||
color: var(--muted);
|
||||
padding: 0.45rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.layout {
|
||||
position: relative;
|
||||
min-height: calc(100vh - 64px);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 64px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 280px;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.25s ease;
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
backdrop-filter: blur(12px);
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
z-index: 35;
|
||||
}
|
||||
|
||||
.sidebar-open .sidebar {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
inset: 64px 0 0 0;
|
||||
background: rgba(2, 6, 23, 0.48);
|
||||
backdrop-filter: blur(1px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 32;
|
||||
}
|
||||
|
||||
.sidebar-open .sidebar-overlay {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.sidebar::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(
|
||||
160deg,
|
||||
rgba(79, 70, 229, 0.12) 0%,
|
||||
rgba(6, 182, 212, 0.08) 32%,
|
||||
transparent 58%
|
||||
),
|
||||
linear-gradient(332deg, rgba(244, 114, 182, 0.06) 0%, transparent 42%),
|
||||
repeating-linear-gradient(135deg, rgba(226, 232, 240, 0.02) 0 1px, transparent 1px 11px);
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.section {
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
background: rgba(2, 8, 23, 0.45);
|
||||
border-radius: 0.9rem;
|
||||
padding: 0.7rem;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
margin: 0.25rem 0.45rem 0.6rem;
|
||||
font-size: 0.67rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(148, 163, 184, 0.95);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.65rem;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
padding: 0.62rem 0.78rem;
|
||||
margin: 0.14rem 0;
|
||||
font-size: 0.93rem;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
border-color: rgba(79, 70, 229, 0.45);
|
||||
background: rgba(79, 70, 229, 0.16);
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
padding: 2rem;
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
|
||||
.card {
|
||||
width: min(720px, 100%);
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: 1rem;
|
||||
background: var(--panel-strong);
|
||||
box-shadow: 0 25px 60px -35px rgba(2, 6, 23, 0.92);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
color: #fecaca;
|
||||
background: rgba(127, 29, 29, 0.3);
|
||||
border: 1px solid rgba(248, 113, 113, 0.35);
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
font-size: 0.82rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.7rem;
|
||||
font-size: clamp(1.35rem, 2.4vw, 1.95rem);
|
||||
line-height: 1.24;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.5;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 2rem;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.buttons {
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
gap: 0.7rem;
|
||||
margin-top: 1.35rem;
|
||||
}
|
||||
button {
|
||||
background-color: #4f46e5;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.65rem;
|
||||
padding: 0.7rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #4338ca;
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
button.secondary {
|
||||
background-color: #475569;
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(2, 8, 23, 0.45);
|
||||
border-color: rgba(148, 163, 184, 0.35);
|
||||
color: var(--text);
|
||||
}
|
||||
button.secondary:hover {
|
||||
background-color: #334155;
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(30, 41, 59, 0.65);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #4338ca;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.82rem;
|
||||
color: rgba(148, 163, 184, 0.95);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 1.2rem;
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px dashed rgba(148, 163, 184, 0.28);
|
||||
font-size: 0.8rem;
|
||||
color: rgba(148, 163, 184, 0.88);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.main {
|
||||
min-height: calc(100vh - 64px);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Vous êtes hors ligne</h1>
|
||||
<p>
|
||||
Il semble que vous n'ayez pas de connexion internet. Certaines fonctionnalités de
|
||||
StripStream peuvent ne pas être disponibles en mode hors ligne.
|
||||
</p>
|
||||
<div class="buttons">
|
||||
<button class="secondary" onclick="window.history.back()">Retour</button>
|
||||
<button onclick="window.location.reload()">Réessayer</button>
|
||||
<header class="header">
|
||||
<div class="header-inner">
|
||||
<div class="brand">
|
||||
<button class="menu-btn" id="sidebar-toggle" type="button" aria-label="Menu">☰</button>
|
||||
<img class="brand-logo" src="/images/logostripstream.png" alt="StripStream logo" />
|
||||
<div>
|
||||
<div class="brand-title">StripStream</div>
|
||||
<div class="brand-subtitle">comic reader</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="pill">Mode hors ligne</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<button class="sidebar-overlay" id="sidebar-overlay" aria-label="Fermer le menu"></button>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-content">
|
||||
<div class="section">
|
||||
<h2>Navigation</h2>
|
||||
<button class="nav-link active" type="button">Accueil</button>
|
||||
<button class="nav-link" type="button">Telechargements</button>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Compte</h2>
|
||||
<button class="nav-link" type="button">Mon compte</button>
|
||||
<button class="nav-link" type="button">Preferences</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<div class="card">
|
||||
<div class="status">● Hors ligne</div>
|
||||
<h1>Cette page n'est pas encore disponible hors ligne.</h1>
|
||||
<p>
|
||||
Tu peux continuer a naviguer sur les pages deja consultees. Cette route sera disponible
|
||||
hors ligne apres une visite en ligne.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button class="btn btn-secondary" onclick="window.history.back()">Retour</button>
|
||||
<button class="btn btn-primary" onclick="window.location.reload()">Reessayer</button>
|
||||
</div>
|
||||
<div class="hint">
|
||||
Astuce: visite d'abord Accueil, Bibliotheques, Series et pages de lecture quand tu es en
|
||||
ligne.
|
||||
</div>
|
||||
<div class="footer">StripStream - interface hors ligne</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const toggle = document.getElementById("sidebar-toggle");
|
||||
const overlay = document.getElementById("sidebar-overlay");
|
||||
|
||||
if (toggle && overlay) {
|
||||
toggle.addEventListener("click", () => {
|
||||
document.body.classList.toggle("sidebar-open");
|
||||
});
|
||||
|
||||
overlay.addEventListener("click", () => {
|
||||
document.body.classList.remove("sidebar-open");
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("online", () => {
|
||||
window.location.reload();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
234
public/sw.js
@@ -3,75 +3,100 @@ const fs = require("fs").promises;
|
||||
const path = require("path");
|
||||
|
||||
const sizes = [72, 96, 128, 144, 152, 192, 384, 512];
|
||||
const inputSvg = path.join(__dirname, "../public/favicon.svg");
|
||||
const inputAppleSvg = path.join(__dirname, "../public/apple-icon.svg");
|
||||
const sourceLogo = path.join(__dirname, "../public/images/logostripstream.png");
|
||||
const outputDir = path.join(__dirname, "../public/images/icons");
|
||||
const screenshotsDir = path.join(__dirname, "../public/images/screenshots");
|
||||
const splashDir = path.join(__dirname, "../public/images/splash");
|
||||
const faviconPath = path.join(__dirname, "../public/favicon.png");
|
||||
|
||||
// Source pour les splash screens
|
||||
const splashSource = path.join(__dirname, "../public/images/Gemini_Generated_Image_wyfsoiwyfsoiwyfs.png");
|
||||
|
||||
// Configuration des splashscreens pour différents appareils
|
||||
const splashScreens = [
|
||||
{ width: 2048, height: 2732, name: "iPad Pro 12.9" }, // iPad Pro 12.9
|
||||
{ width: 1668, height: 2388, name: "iPad Pro 11" }, // iPad Pro 11
|
||||
{ width: 1536, height: 2048, name: "iPad Mini/Air" }, // iPad Mini, Air
|
||||
{ width: 1125, height: 2436, name: "iPhone X/XS" }, // iPhone X/XS
|
||||
{ width: 1242, height: 2688, name: "iPhone XS Max" }, // iPhone XS Max
|
||||
{ width: 828, height: 1792, name: "iPhone XR" }, // iPhone XR
|
||||
{ width: 750, height: 1334, name: "iPhone 8/SE" }, // iPhone 8, SE
|
||||
{ width: 1242, height: 2208, name: "iPhone 8 Plus" }, // iPhone 8 Plus
|
||||
// iPad (portrait + landscape)
|
||||
{ width: 2048, height: 2732, name: "iPad Pro 12.9 portrait" },
|
||||
{ width: 2732, height: 2048, name: "iPad Pro 12.9 landscape" },
|
||||
{ width: 1668, height: 2388, name: "iPad Pro 11 portrait" },
|
||||
{ width: 2388, height: 1668, name: "iPad Pro 11 landscape" },
|
||||
{ width: 1668, height: 2420, name: "iPad Pro 11 M4 portrait" },
|
||||
{ width: 2420, height: 1668, name: "iPad Pro 11 M4 landscape" },
|
||||
{ width: 2064, height: 2752, name: "iPad Pro 13 M4 portrait" },
|
||||
{ width: 2752, height: 2064, name: "iPad Pro 13 M4 landscape" },
|
||||
{ width: 1536, height: 2048, name: "iPad Mini/Air portrait" },
|
||||
{ width: 2048, height: 1536, name: "iPad Mini/Air landscape" },
|
||||
{ width: 1488, height: 2266, name: "iPad Mini 6 portrait" },
|
||||
{ width: 2266, height: 1488, name: "iPad Mini 6 landscape" },
|
||||
{ width: 1620, height: 2160, name: "iPad 10.2 portrait" },
|
||||
{ width: 2160, height: 1620, name: "iPad 10.2 landscape" },
|
||||
{ width: 1640, height: 2360, name: "iPad Air 10.9 portrait" },
|
||||
{ width: 2360, height: 1640, name: "iPad Air 10.9 landscape" },
|
||||
|
||||
// iPhone legacy
|
||||
{ width: 1125, height: 2436, name: "iPhone X/XS/11 Pro portrait" },
|
||||
{ width: 2436, height: 1125, name: "iPhone X/XS/11 Pro landscape" },
|
||||
{ width: 1242, height: 2688, name: "iPhone XS Max/11 Pro Max portrait" },
|
||||
{ width: 2688, height: 1242, name: "iPhone XS Max/11 Pro Max landscape" },
|
||||
{ width: 828, height: 1792, name: "iPhone XR/11 portrait" },
|
||||
{ width: 1792, height: 828, name: "iPhone XR/11 landscape" },
|
||||
{ width: 750, height: 1334, name: "iPhone 8/SE portrait" },
|
||||
{ width: 1334, height: 750, name: "iPhone 8/SE landscape" },
|
||||
{ width: 1242, height: 2208, name: "iPhone 8 Plus portrait" },
|
||||
{ width: 2208, height: 1242, name: "iPhone 8 Plus landscape" },
|
||||
|
||||
// iPhone modern (12+)
|
||||
{ width: 1170, height: 2532, name: "iPhone 12/13/14 portrait" },
|
||||
{ width: 2532, height: 1170, name: "iPhone 12/13/14 landscape" },
|
||||
{ width: 1284, height: 2778, name: "iPhone 12/13/14 Pro Max portrait" },
|
||||
{ width: 2778, height: 1284, name: "iPhone 12/13/14 Pro Max landscape" },
|
||||
{ width: 1179, height: 2556, name: "iPhone 14 Pro/15 portrait" },
|
||||
{ width: 2556, height: 1179, name: "iPhone 14 Pro/15 landscape" },
|
||||
{ width: 1290, height: 2796, name: "iPhone 14/15 Pro Max portrait" },
|
||||
{ width: 2796, height: 1290, name: "iPhone 14/15 Pro Max landscape" },
|
||||
{ width: 1206, height: 2622, name: "iPhone 16 Pro portrait" },
|
||||
{ width: 2622, height: 1206, name: "iPhone 16 Pro landscape" },
|
||||
{ width: 1320, height: 2868, name: "iPhone 16 Pro Max portrait" },
|
||||
{ width: 2868, height: 1320, name: "iPhone 16 Pro Max landscape" },
|
||||
{ width: 1170, height: 2532, name: "iPhone 16/16e portrait" },
|
||||
{ width: 2532, height: 1170, name: "iPhone 16/16e landscape" },
|
||||
];
|
||||
|
||||
async function generateSplashScreens() {
|
||||
await fs.mkdir(splashDir, { recursive: true });
|
||||
|
||||
// Créer le SVG de base pour la splashscreen avec le même style que le favicon
|
||||
const splashSvg = `
|
||||
<svg width="100%" height="100%" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100" height="100" fill="#4F46E5"/>
|
||||
<g transform="translate(25, 20) scale(1.5)">
|
||||
<!-- Lettre S stylisée -->
|
||||
<path
|
||||
d="M21 12C21 10.3431 19.6569 9 18 9H14C12.3431 9 11 10.3431 11 12V12.5C11 14.1569 12.3431 15.5 14 15.5H18C19.6569 15.5 21 16.8431 21 18.5V19C21 20.6569 19.6569 22 18 22H14C12.3431 22 11 20.6569 11 19"
|
||||
stroke="white"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<!-- Points décoratifs -->
|
||||
<circle cx="11" cy="24" r="1.5" fill="white"/>
|
||||
<circle cx="21" cy="8" r="1.5" fill="white"/>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
console.log(`\n📱 Génération des splash screens...`);
|
||||
|
||||
for (const screen of splashScreens) {
|
||||
const outputPath = path.join(splashDir, `splash-${screen.width}x${screen.height}.png`);
|
||||
|
||||
await sharp(Buffer.from(splashSvg))
|
||||
await sharp(splashSource)
|
||||
.resize(screen.width, screen.height, {
|
||||
fit: "contain",
|
||||
background: "#4F46E5",
|
||||
fit: "cover",
|
||||
position: "center",
|
||||
})
|
||||
.png({
|
||||
compressionLevel: 9,
|
||||
})
|
||||
.png()
|
||||
.toFile(outputPath);
|
||||
|
||||
console.log(`✓ Splashscreen ${screen.name} (${screen.width}x${screen.height}) générée`);
|
||||
console.log(` ✓ ${screen.name} (${screen.width}x${screen.height})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateIcons() {
|
||||
try {
|
||||
await fs.access(sourceLogo);
|
||||
|
||||
// Créer les dossiers de sortie s'ils n'existent pas
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
await fs.mkdir(screenshotsDir, { recursive: true });
|
||||
|
||||
// Générer les icônes Android (avec bords arrondis)
|
||||
// Générer les icônes Android
|
||||
for (const size of sizes) {
|
||||
const outputPath = path.join(outputDir, `icon-${size}x${size}.png`);
|
||||
|
||||
await sharp(inputSvg)
|
||||
await sharp(sourceLogo)
|
||||
.resize(size, size, {
|
||||
fit: "contain",
|
||||
fit: "cover",
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 }, // Fond transparent
|
||||
})
|
||||
.png({
|
||||
@@ -88,9 +113,9 @@ async function generateIcons() {
|
||||
for (const size of appleSizes) {
|
||||
const outputPath = path.join(outputDir, `apple-icon-${size}x${size}.png`);
|
||||
|
||||
await sharp(inputAppleSvg)
|
||||
await sharp(sourceLogo)
|
||||
.resize(size, size, {
|
||||
fit: "contain",
|
||||
fit: "cover",
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 }, // Fond transparent
|
||||
})
|
||||
.png({
|
||||
@@ -102,26 +127,25 @@ async function generateIcons() {
|
||||
console.log(`✓ Icône Apple ${size}x${size} générée`);
|
||||
}
|
||||
|
||||
// Générer le favicon principal utilisé par Next metadata
|
||||
await sharp(sourceLogo)
|
||||
.resize(64, 64, {
|
||||
fit: "cover",
|
||||
})
|
||||
.png({
|
||||
compressionLevel: 9,
|
||||
palette: true,
|
||||
})
|
||||
.toFile(faviconPath);
|
||||
|
||||
console.log("✓ Favicon principal généré");
|
||||
|
||||
// Générer les icônes de raccourcis
|
||||
const shortcutIcons = [
|
||||
{ name: "home", icon: "Home" },
|
||||
{ name: "library", icon: "Library" },
|
||||
];
|
||||
const shortcutIcons = ["home", "library"];
|
||||
|
||||
for (const shortcut of shortcutIcons) {
|
||||
const outputPath = path.join(outputDir, `${shortcut.name}.png`);
|
||||
|
||||
// Créer une image carrée avec fond indigo et icône blanche
|
||||
const svg = `
|
||||
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="96" height="96" rx="20" fill="#4F46E5"/>
|
||||
<path d="${getIconPath(
|
||||
shortcut.icon
|
||||
)}" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
await sharp(Buffer.from(svg))
|
||||
const outputPath = path.join(outputDir, `${shortcut}.png`);
|
||||
await sharp(sourceLogo)
|
||||
.resize(96, 96)
|
||||
.png({
|
||||
compressionLevel: 9,
|
||||
@@ -129,7 +153,7 @@ async function generateIcons() {
|
||||
})
|
||||
.toFile(outputPath);
|
||||
|
||||
console.log(`✓ Icône de raccourci ${shortcut.name} générée`);
|
||||
console.log(`✓ Icône de raccourci ${shortcut} générée`);
|
||||
}
|
||||
|
||||
// Générer les screenshots de démonstration
|
||||
@@ -166,14 +190,4 @@ async function generateIcons() {
|
||||
}
|
||||
}
|
||||
|
||||
// Fonction helper pour obtenir les chemins SVG des icônes
|
||||
function getIconPath(iconName) {
|
||||
const paths = {
|
||||
Home: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6",
|
||||
Library:
|
||||
"M4 19.5A2.5 2.5 0 0 1 6.5 17H20M4 19.5A2.5 2.5 0 0 0 6.5 22H20M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20",
|
||||
};
|
||||
return paths[iconName] || "";
|
||||
}
|
||||
|
||||
generateIcons();
|
||||
|
||||
@@ -15,7 +15,7 @@ export default async function AccountPage() {
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<div className="mx-auto max-w-4xl space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Mon compte</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
@@ -25,7 +25,7 @@ export default async function AccountPage() {
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<UserProfileCard profile={{ ...profile, stats }} />
|
||||
<ChangePasswordForm />
|
||||
<ChangePasswordForm username={profile.email} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
98
src/app/actions/admin.ts
Normal 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
@@ -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
@@ -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
@@ -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" };
|
||||
}
|
||||
}
|
||||
38
src/app/actions/favorites.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
47
src/app/actions/library.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
32
src/app/actions/password.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
29
src/app/actions/preferences.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
52
src/app/actions/read-progress.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
30
src/app/actions/refresh.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath, revalidateTag } from "next/cache";
|
||||
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG } from "@/constants/cacheConstants";
|
||||
|
||||
export type RefreshScope = "home" | "library" | "series";
|
||||
|
||||
/**
|
||||
* Invalide le cache Next.js pour forcer un re-fetch au prochain router.refresh().
|
||||
* À appeler côté client avant router.refresh() sur les boutons / pull-to-refresh.
|
||||
*/
|
||||
export async function revalidateForRefresh(scope: RefreshScope, id: string): Promise<void> {
|
||||
switch (scope) {
|
||||
case "home":
|
||||
revalidateTag(HOME_CACHE_TAG, "max");
|
||||
revalidatePath("/");
|
||||
break;
|
||||
case "library":
|
||||
revalidateTag(LIBRARY_SERIES_CACHE_TAG, "max");
|
||||
revalidatePath(`/libraries/${id}`);
|
||||
revalidatePath("/libraries");
|
||||
break;
|
||||
case "series":
|
||||
revalidatePath(`/series/${id}`);
|
||||
revalidatePath("/series");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
181
src/app/actions/stripstream-config.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getCurrentUser } from "@/lib/auth-utils";
|
||||
import { StripstreamProvider } from "@/lib/providers/stripstream/stripstream.provider";
|
||||
import { getResolvedStripstreamConfig } from "@/lib/providers/stripstream/stripstream-config-resolver";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import type { ProviderType } from "@/lib/providers/types";
|
||||
|
||||
/**
|
||||
* Sauvegarde la configuration Stripstream
|
||||
*/
|
||||
export async function saveStripstreamConfig(
|
||||
url: string,
|
||||
token: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return { success: false, message: "Non authentifié" };
|
||||
}
|
||||
const userId = parseInt(user.id, 10);
|
||||
|
||||
await prisma.stripstreamConfig.upsert({
|
||||
where: { userId },
|
||||
update: { url, token },
|
||||
create: { userId, url, token },
|
||||
});
|
||||
|
||||
revalidatePath("/settings");
|
||||
return { success: true, message: "Configuration Stripstream sauvegardée" };
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
return { success: false, message: "Erreur lors de la sauvegarde" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste la connexion à Stripstream Librarian
|
||||
*/
|
||||
export async function testStripstreamConnection(
|
||||
url: string,
|
||||
token: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const provider = new StripstreamProvider(url, token);
|
||||
const result = await provider.testConnection();
|
||||
|
||||
if (!result.ok) {
|
||||
return { success: false, message: result.error ?? "Connexion échouée" };
|
||||
}
|
||||
|
||||
return { success: true, message: "Connexion Stripstream réussie !" };
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
return { success: false, message: "Erreur lors du test de connexion" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Définit le provider actif de l'utilisateur
|
||||
*/
|
||||
export async function setActiveProvider(
|
||||
provider: ProviderType
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return { success: false, message: "Non authentifié" };
|
||||
}
|
||||
const userId = parseInt(user.id, 10);
|
||||
|
||||
// Vérifier que le provider est configuré avant de l'activer
|
||||
if (provider === "komga") {
|
||||
const config = await prisma.komgaConfig.findUnique({ where: { userId } });
|
||||
if (!config) {
|
||||
return { success: false, message: "Komga n'est pas encore configuré" };
|
||||
}
|
||||
} else if (provider === "stripstream") {
|
||||
const config = await getResolvedStripstreamConfig(userId);
|
||||
if (!config) {
|
||||
return { success: false, message: "Stripstream n'est pas encore configuré" };
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { activeProvider: provider },
|
||||
});
|
||||
|
||||
revalidatePath("/");
|
||||
revalidatePath("/settings");
|
||||
return {
|
||||
success: true,
|
||||
message: `Provider actif : ${provider === "komga" ? "Komga" : "Stripstream Librarian"}`,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
return { success: false, message: "Erreur lors du changement de provider" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la configuration Stripstream de l'utilisateur (affichage settings).
|
||||
* Priorité : config en base, sinon env STRIPSTREAM_URL / STRIPSTREAM_TOKEN.
|
||||
*/
|
||||
export async function getStripstreamConfig(): Promise<{
|
||||
url?: string;
|
||||
hasToken: boolean;
|
||||
} | null> {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return null;
|
||||
const userId = parseInt(user.id, 10);
|
||||
|
||||
const resolved = await getResolvedStripstreamConfig(userId);
|
||||
if (!resolved) return null;
|
||||
return { url: resolved.url, hasToken: true };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le provider actif de l'utilisateur
|
||||
*/
|
||||
export async function getActiveProvider(): Promise<ProviderType> {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return "komga";
|
||||
const userId = parseInt(user.id, 10);
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { activeProvider: true },
|
||||
});
|
||||
|
||||
return (dbUser?.activeProvider as ProviderType) ?? "komga";
|
||||
} catch {
|
||||
return "komga";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie quels providers sont configurés
|
||||
*/
|
||||
export async function getProvidersStatus(): Promise<{
|
||||
komgaConfigured: boolean;
|
||||
stripstreamConfigured: boolean;
|
||||
activeProvider: ProviderType;
|
||||
}> {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return { komgaConfigured: false, stripstreamConfigured: false, activeProvider: "komga" };
|
||||
}
|
||||
const userId = parseInt(user.id, 10);
|
||||
|
||||
const [dbUser, komgaConfig, stripstreamResolved] = await Promise.all([
|
||||
prisma.user.findUnique({ where: { id: userId }, select: { activeProvider: true } }),
|
||||
prisma.komgaConfig.findUnique({ where: { userId }, select: { id: true } }),
|
||||
getResolvedStripstreamConfig(userId),
|
||||
]);
|
||||
|
||||
return {
|
||||
komgaConfigured: !!komgaConfig,
|
||||
stripstreamConfigured: !!stripstreamResolved,
|
||||
activeProvider: (dbUser?.activeProvider as ProviderType) ?? "komga",
|
||||
};
|
||||
} catch {
|
||||
return { komgaConfigured: false, stripstreamConfigured: false, activeProvider: "komga" };
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { AdminService } from "@/lib/services/admin.service";
|
||||
import { AppError } from "@/utils/errors";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const stats = await AdminService.getUserStats();
|
||||
return NextResponse.json(stats);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Erreur lors de la récupération des stats:");
|
||||
|
||||
if (error instanceof AppError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{
|
||||
status:
|
||||
error.code === "AUTH_FORBIDDEN"
|
||||
? 403
|
||||
: error.code === "AUTH_UNAUTHENTICATED"
|
||||
? 401
|
||||
: 500,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Erreur lors de la récupération des stats" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||