init
51
.gitignore
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
# build output
|
||||
dist/
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
.env.development
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
|
||||
# Database
|
||||
data/*.db
|
||||
data/*.db-journal
|
||||
data/*.db-wal
|
||||
data/*.db-shm
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Uploaded files
|
||||
public/uploads/
|
||||
uploads/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Debug
|
||||
*.log
|
||||
4
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
464
AUDIT_GEMINI_REFACTOR.md
Normal file
@@ -0,0 +1,464 @@
|
||||
# 🔍 AUDIT & PLAN DE REFACTORISATION - gemini.ts
|
||||
|
||||
## 📊 ANALYSE DU FICHIER ACTUEL
|
||||
|
||||
### Statistiques
|
||||
- **Lignes de code**: 1627 lignes (trop volumineux, maintenance difficile)
|
||||
- **Fonctions principales**: 8 fonctions majeures
|
||||
- **Types/Interfaces**: ~15 types définis
|
||||
|
||||
---
|
||||
|
||||
## ❌ PROBLÈMES IDENTIFIÉS
|
||||
|
||||
### 1. TYPES TROP LIMITÉS - Ne couvre pas tous les métiers
|
||||
|
||||
**Problème**: `ElementCategory` ne couvre que 8 catégories très basiques
|
||||
|
||||
```typescript
|
||||
// ACTUEL (8 catégories seulement)
|
||||
export type ElementCategory =
|
||||
| "surface" | "furniture" | "lighting" | "decoration"
|
||||
| "equipment" | "outdoor" | "fixture" | "appliance";
|
||||
```
|
||||
|
||||
**Métiers non couverts**:
|
||||
- 🏗️ **BTP/Construction**: charpente, maçonnerie, isolation, fondations, structure
|
||||
- 🪟 **Menuiserie/Fermetures**: portes, fenêtres, volets, portails, stores, pergolas
|
||||
- 🔧 **Plomberie**: tuyauterie, chauffe-eau, robinetterie, sanitaires, évacuation
|
||||
- ⚡ **Électricité**: tableau électrique, câblage, domotique, interphones
|
||||
- 🌡️ **Énergie/CVC**: panneaux solaires, pompes à chaleur, climatisation, VMC
|
||||
- 🏠 **Toiture/Couverture**: tuiles, ardoises, gouttières, zinguerie, cheminées
|
||||
- 🧱 **Façade/Ravalement**: crépi, enduit, bardage, isolation extérieure
|
||||
- 🌿 **Paysagisme**: arbres, haies, massifs, pelouse, bassins, allées
|
||||
- 🏊 **Piscine/Spa**: margelles, liners, terrasses, abris
|
||||
- 🔐 **Sécurité**: alarmes, caméras, contrôle d'accès, clôtures
|
||||
|
||||
---
|
||||
|
||||
### 2. PROMPTS PAS ASSEZ STRICTS - Risque de zoom/recadrage
|
||||
|
||||
**Problème actuel**: Le prompt ne contient pas de contraintes strictes pour:
|
||||
- Conserver EXACTEMENT le cadrage original
|
||||
- Éviter tout zoom avant/arrière
|
||||
- Maintenir la même résolution/proportions
|
||||
- Préserver la perspective exacte
|
||||
|
||||
**Lignes problématiques**:
|
||||
- Ligne 1031: Pas de mention explicite "NO ZOOM"
|
||||
- Le prompt de génération n'insiste pas sur le cadrage identique
|
||||
|
||||
---
|
||||
|
||||
### 3. AGENT ANALYSTE TROP LIMITÉ
|
||||
|
||||
**Problème**: Le prompt d'analyse (ligne 269) ne couvre pas:
|
||||
- Éléments de construction (charpente, toiture, fondations)
|
||||
- Installations techniques (plomberie, électricité, chauffage)
|
||||
- Équipements énergétiques (panneaux solaires, pompes à chaleur)
|
||||
- Éléments de sécurité (portails, clôtures, alarmes)
|
||||
- Aménagements extérieurs (piscines, terrasses, allées)
|
||||
|
||||
**Catégories manquantes dans le prompt**:
|
||||
```
|
||||
CATÉGORIES D'OBJETS À IDENTIFIER:
|
||||
- Meubles: table, chaise, canapé... ← Trop limité!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. AGENT ANALYSE RÉFÉRENCE - Classification binaire insuffisante
|
||||
|
||||
**Problème**: La classification "material" vs "object" est trop simple
|
||||
|
||||
```typescript
|
||||
// ACTUEL
|
||||
type: "material" | "object"
|
||||
```
|
||||
|
||||
**Cas non gérés**:
|
||||
- Panneau solaire → objet technique/énergie
|
||||
- Plante/arbre → élément végétal
|
||||
- Portail → élément de fermeture
|
||||
- Piscine → aménagement extérieur
|
||||
- Modèle 3D → doit être rendu photoréaliste
|
||||
- Image de catalogue → doit être intégrée naturellement
|
||||
|
||||
---
|
||||
|
||||
### 5. INSERTION D'ÉLÉMENTS PAS RÉALISTE
|
||||
|
||||
**Problème**: Quand on insère un objet de référence (meuble de catalogue, modèle 3D):
|
||||
- Pas d'adaptation de l'éclairage/ombres
|
||||
- Pas de gestion de la perspective
|
||||
- Pas de rendu photoréaliste des modèles 3D
|
||||
- Pas d'intégration naturelle dans la scène
|
||||
|
||||
---
|
||||
|
||||
### 6. FICHIER MONOLITHIQUE
|
||||
|
||||
**Problème**: 1627 lignes = difficile à maintenir
|
||||
|
||||
**Blocs identifiés à séparer**:
|
||||
1. Types & Interfaces (~150 lignes)
|
||||
2. Configuration & Utils (~100 lignes)
|
||||
3. Agent Analyste (~200 lignes)
|
||||
4. Agent Référence (~150 lignes)
|
||||
5. Agent Planificateur (~400 lignes)
|
||||
6. Construction Prompt (~200 lignes)
|
||||
7. Agent Générateur (~150 lignes)
|
||||
8. Fonctions principales (~300 lignes)
|
||||
|
||||
---
|
||||
|
||||
## ✅ PLAN DE REFACTORISATION
|
||||
|
||||
### Structure des fichiers proposée
|
||||
|
||||
```
|
||||
src/lib/
|
||||
├── gemini/
|
||||
│ ├── index.ts # Export principal (façade)
|
||||
│ ├── types/
|
||||
│ │ ├── index.ts # Export tous les types
|
||||
│ │ ├── categories.ts # ElementCategory, ModificationType étendus
|
||||
│ │ ├── analysis.ts # ImageAnalysis, ZoneInfo, ObjectInfo
|
||||
│ │ ├── reference.ts # ReferenceAnalysis étendu
|
||||
│ │ └── generation.ts # GenerationResult, Options
|
||||
│ ├── agents/
|
||||
│ │ ├── analyzer.ts # Agent d'analyse d'image
|
||||
│ │ ├── reference-analyzer.ts # Agent d'analyse de référence
|
||||
│ │ ├── planner.ts # Agent planificateur
|
||||
│ │ └── generator.ts # Agent générateur (Nano Banana)
|
||||
│ ├── prompts/
|
||||
│ │ ├── analyzer-prompt.ts # Prompt d'analyse
|
||||
│ │ ├── reference-prompt.ts # Prompt analyse référence
|
||||
│ │ └── generation-prompt.ts # Construction du prompt final
|
||||
│ ├── utils/
|
||||
│ │ ├── image.ts # prepareImageForAPI, conversion HEIC
|
||||
│ │ └── helpers.ts # sleep, getMimeType
|
||||
│ └── config.ts # Configuration API, modèles, retry
|
||||
├── gemini.ts # Fichier rétrocompatible (re-export)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 TYPES ÉTENDUS PROPOSÉS
|
||||
|
||||
### ElementCategory - 50+ catégories pour tous les métiers
|
||||
|
||||
```typescript
|
||||
export type ElementCategory =
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SURFACES & REVÊTEMENTS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
| "wall" // Murs intérieurs
|
||||
| "floor" // Sols
|
||||
| "ceiling" // Plafonds
|
||||
| "facade" // Façades extérieures
|
||||
| "roof" // Toiture
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// STRUCTURE & GROS ŒUVRE
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
| "foundation" // Fondations
|
||||
| "framework" // Charpente, ossature
|
||||
| "beam" // Poutres
|
||||
| "column" // Poteaux, colonnes
|
||||
| "staircase" // Escaliers
|
||||
| "partition" // Cloisons
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// MENUISERIE & FERMETURES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
| "window" // Fenêtres
|
||||
| "door" // Portes intérieures
|
||||
| "exterior_door" // Portes extérieures, entrées
|
||||
| "garage_door" // Portes de garage
|
||||
| "shutter" // Volets
|
||||
| "blind" // Stores
|
||||
| "gate" // Portails
|
||||
| "fence" // Clôtures
|
||||
| "railing" // Garde-corps, rambardes
|
||||
| "pergola" // Pergolas, tonnelles
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// MOBILIER & AGENCEMENT
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
| "furniture" // Mobilier général
|
||||
| "seating" // Assises (canapés, fauteuils, chaises)
|
||||
| "table" // Tables
|
||||
| "storage" // Rangements (armoires, commodes, étagères)
|
||||
| "bed" // Literie
|
||||
| "desk" // Bureaux
|
||||
| "kitchen_furniture" // Meubles de cuisine
|
||||
| "bathroom_furniture" // Meubles de salle de bain
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ÉCLAIRAGE
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
| "ceiling_light" // Plafonniers, lustres
|
||||
| "pendant_light" // Suspensions
|
||||
| "wall_light" // Appliques
|
||||
| "floor_lamp" // Lampadaires
|
||||
| "table_lamp" // Lampes de table
|
||||
| "spotlight" // Spots
|
||||
| "outdoor_light" // Éclairage extérieur
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PLOMBERIE & SANITAIRES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
| "sink" // Éviers, lavabos
|
||||
| "toilet" // WC
|
||||
| "bathtub" // Baignoires
|
||||
| "shower" // Douches
|
||||
| "faucet" // Robinetterie
|
||||
| "water_heater" // Chauffe-eau
|
||||
| "radiator" // Radiateurs
|
||||
| "piping" // Tuyauterie visible
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ÉLECTRICITÉ & DOMOTIQUE
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
| "electrical_panel" // Tableau électrique
|
||||
| "outlet" // Prises électriques
|
||||
| "switch" // Interrupteurs
|
||||
| "thermostat" // Thermostats
|
||||
| "intercom" // Interphones, vidéophones
|
||||
| "alarm" // Systèmes d'alarme
|
||||
| "camera" // Caméras de surveillance
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ÉNERGIE & CLIMATISATION
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
| "solar_panel" // Panneaux solaires
|
||||
| "heat_pump" // Pompes à chaleur
|
||||
| "air_conditioning" // Climatisation
|
||||
| "ventilation" // VMC, ventilation
|
||||
| "fireplace" // Cheminées, poêles
|
||||
| "boiler" // Chaudières
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// TOITURE & COUVERTURE
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
| "roof_tiles" // Tuiles
|
||||
| "slate" // Ardoises
|
||||
| "gutter" // Gouttières
|
||||
| "chimney" // Conduits de cheminée
|
||||
| "skylight" // Velux, fenêtres de toit
|
||||
| "roof_terrace" // Toiture terrasse
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// AMÉNAGEMENT EXTÉRIEUR & PAYSAGISME
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
| "terrace" // Terrasses
|
||||
| "deck" // Terrasses bois
|
||||
| "patio" // Patios
|
||||
| "pathway" // Allées
|
||||
| "driveway" // Entrées de garage
|
||||
| "lawn" // Pelouses
|
||||
| "flower_bed" // Massifs floraux
|
||||
| "hedge" // Haies
|
||||
| "tree" // Arbres
|
||||
| "shrub" // Arbustes
|
||||
| "planter" // Jardinières
|
||||
| "outdoor_furniture" // Mobilier de jardin
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PISCINE & SPA
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
| "pool" // Piscines
|
||||
| "pool_deck" // Plages de piscine
|
||||
| "pool_cover" // Couvertures, abris piscine
|
||||
| "spa" // Spas, jacuzzis
|
||||
| "pool_house" // Pool houses
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// DÉCORATION
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
| "artwork" // Tableaux, art mural
|
||||
| "mirror" // Miroirs
|
||||
| "curtain" // Rideaux
|
||||
| "rug" // Tapis
|
||||
| "cushion" // Coussins
|
||||
| "vase" // Vases
|
||||
| "sculpture" // Sculptures
|
||||
| "clock" // Horloges
|
||||
| "plant_indoor" // Plantes d'intérieur
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ÉLECTROMÉNAGER
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
| "refrigerator" // Réfrigérateurs
|
||||
| "oven" // Fours
|
||||
| "cooktop" // Plaques de cuisson
|
||||
| "range_hood" // Hottes
|
||||
| "dishwasher" // Lave-vaisselle
|
||||
| "washing_machine" // Lave-linge
|
||||
| "dryer" // Sèche-linge
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CUISINE
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
| "countertop" // Plans de travail
|
||||
| "backsplash" // Crédences
|
||||
| "kitchen_island" // Îlots de cuisine
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// AUTRES
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
| "custom"; // Élément personnalisé
|
||||
```
|
||||
|
||||
### ModificationType - Types de modification étendus
|
||||
|
||||
```typescript
|
||||
export type ModificationType =
|
||||
// Surfaces
|
||||
| "floor" | "wall" | "ceiling" | "facade" | "roof"
|
||||
// Structure
|
||||
| "framework" | "staircase" | "partition"
|
||||
// Menuiserie
|
||||
| "window" | "door" | "shutter" | "gate" | "fence" | "railing"
|
||||
// Mobilier
|
||||
| "furniture" | "seating" | "table" | "storage" | "bed"
|
||||
// Éclairage
|
||||
| "lighting" | "ceiling_light" | "wall_light" | "outdoor_light"
|
||||
// Technique
|
||||
| "plumbing" | "electrical" | "heating" | "cooling" | "energy"
|
||||
// Toiture
|
||||
| "roofing" | "gutter" | "chimney"
|
||||
// Extérieur
|
||||
| "outdoor" | "terrace" | "garden" | "pool" | "landscape"
|
||||
// Décoration
|
||||
| "decoration" | "artwork" | "textile" | "plant"
|
||||
// Cuisine/SDB
|
||||
| "kitchen" | "bathroom" | "appliance"
|
||||
// Ajout/Suppression
|
||||
| "add_element" | "remove_element"
|
||||
// Personnalisé
|
||||
| "custom";
|
||||
```
|
||||
|
||||
### ReferenceType - Classification étendue des références
|
||||
|
||||
```typescript
|
||||
export type ReferenceType =
|
||||
// Textures & Matériaux
|
||||
| "texture" // Texture pure (carrelage, parquet, peinture)
|
||||
| "material_sample" // Échantillon de matériau
|
||||
// Objets & Produits
|
||||
| "product_photo" // Photo de produit (catalogue)
|
||||
| "3d_render" // Rendu 3D / modèle 3D
|
||||
| "furniture" // Meuble
|
||||
| "lighting_fixture" // Luminaire
|
||||
| "decoration_item" // Objet déco
|
||||
// Végétation
|
||||
| "plant" // Plante, arbre, végétation
|
||||
// Équipements
|
||||
| "technical_equipment" // Équipement technique (panneau solaire, PAC)
|
||||
| "appliance" // Électroménager
|
||||
// Structure
|
||||
| "architectural_element" // Élément architectural (porte, fenêtre)
|
||||
// Autre
|
||||
| "scene_reference" // Image d'ambiance/style
|
||||
| "custom";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 CONTRAINTES ANTI-ZOOM À AJOUTER
|
||||
|
||||
### Ajouts au prompt de génération
|
||||
|
||||
```typescript
|
||||
const STRICT_FRAMING_RULES = `
|
||||
## ⚠️ CONTRAINTES ABSOLUES DE CADRAGE
|
||||
|
||||
### INTERDICTIONS STRICTES:
|
||||
❌ NE JAMAIS zoomer avant ou arrière
|
||||
❌ NE JAMAIS recadrer l'image
|
||||
❌ NE JAMAIS modifier l'angle de vue
|
||||
❌ NE JAMAIS changer les proportions
|
||||
❌ NE JAMAIS ajouter/supprimer des éléments hors instructions
|
||||
|
||||
### OBLIGATIONS:
|
||||
✅ Conserver EXACTEMENT le même cadrage que l'IMAGE 1
|
||||
✅ Conserver EXACTEMENT la même perspective
|
||||
✅ Conserver EXACTEMENT les mêmes proportions (aspect ratio)
|
||||
✅ Conserver EXACTEMENT la même position de caméra
|
||||
✅ Les bords de l'image générée doivent correspondre PIXEL À PIXEL avec l'originale
|
||||
|
||||
### VÉRIFICATION:
|
||||
Si tu superposes l'image originale et l'image générée:
|
||||
- Les contours architecturaux doivent se superposer parfaitement
|
||||
- Les angles des murs/plafonds doivent être identiques
|
||||
- Aucun décalage ne doit être visible
|
||||
`;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🖼️ INSERTION RÉALISTE D'ÉLÉMENTS
|
||||
|
||||
### Règles pour les images de catalogue/3D
|
||||
|
||||
```typescript
|
||||
const REALISTIC_INSERTION_RULES = `
|
||||
## 🎨 INSERTION PHOTORÉALISTE D'ÉLÉMENTS
|
||||
|
||||
### POUR LES PRODUITS DE CATALOGUE / MODÈLES 3D:
|
||||
1. **Éclairage**: Adapter l'éclairage de l'objet à celui de la scène
|
||||
- Observer la direction de la lumière principale
|
||||
- Ajouter des ombres cohérentes avec les autres objets
|
||||
- Ajuster la luminosité/contraste pour homogénéité
|
||||
|
||||
2. **Perspective**: Adapter la perspective de l'objet
|
||||
- Calculer le point de fuite de la scène
|
||||
- Déformer légèrement l'objet si nécessaire
|
||||
- Respecter l'échelle par rapport aux éléments environnants
|
||||
|
||||
3. **Ombres & Reflets**:
|
||||
- Générer une ombre portée réaliste
|
||||
- Ajouter des reflets sur les surfaces brillantes
|
||||
- Créer une ombre de contact au sol
|
||||
|
||||
4. **Intégration chromatique**:
|
||||
- Adapter la température de couleur
|
||||
- Ajouter un léger color grading cohérent
|
||||
- Gérer les inter-réflexions de couleur
|
||||
|
||||
5. **Finition**:
|
||||
- Ajouter un léger flou de profondeur si nécessaire
|
||||
- Gérer le grain/bruit photo cohérent
|
||||
- Pas de bords durs visibles autour de l'objet
|
||||
`;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 PROCHAINES ÉTAPES
|
||||
|
||||
1. **Créer la structure de fichiers** `/src/lib/gemini/`
|
||||
2. **Implémenter les types étendus** dans `types/categories.ts`
|
||||
3. **Refactoriser les agents** dans des fichiers séparés
|
||||
4. **Améliorer les prompts** avec les contraintes anti-zoom
|
||||
5. **Ajouter les règles d'insertion réaliste**
|
||||
6. **Mettre à jour le fichier principal** pour re-exporter
|
||||
7. **Tests de non-régression**
|
||||
|
||||
---
|
||||
|
||||
## ⏱️ ESTIMATION
|
||||
|
||||
- **Refactorisation structure**: 2-3 heures
|
||||
- **Extension des types**: 1 heure
|
||||
- **Amélioration prompts**: 2 heures
|
||||
- **Tests**: 1 heure
|
||||
|
||||
**Total estimé**: ~6-8 heures de travail
|
||||
|
||||
---
|
||||
|
||||
*Document généré le 8 janvier 2026*
|
||||
980
IMPLEMENTATION_EXAMPLES.md
Normal file
@@ -0,0 +1,980 @@
|
||||
# 💻 EXEMPLES D'IMPLÉMENTATION - Sélection de Zones
|
||||
|
||||
## 📦 Installation des Dépendances
|
||||
|
||||
```bash
|
||||
# Frontend - Éditeur de canvas
|
||||
npm install fabric
|
||||
npm install @types/fabric --save-dev
|
||||
|
||||
# Alternative React-friendly
|
||||
# npm install react-konva konva
|
||||
|
||||
# Backend - Déjà installé
|
||||
# sharp (optimisation images)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 FRONTEND : Composant Éditeur de Masques
|
||||
|
||||
### 1. Composant `ZoneMaskEditor.tsx`
|
||||
|
||||
```tsx
|
||||
// src/components/ZoneMaskEditor.tsx
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { fabric } from "fabric";
|
||||
|
||||
interface ZoneMaskEditorProps {
|
||||
imageUrl: string;
|
||||
onMaskComplete: (maskDataUrl: string) => void;
|
||||
onCancel: () => void;
|
||||
instructionColor?: string; // Pour différencier les instructions
|
||||
}
|
||||
|
||||
export default function ZoneMaskEditor({
|
||||
imageUrl,
|
||||
onMaskComplete,
|
||||
onCancel,
|
||||
instructionColor = "#ff0000",
|
||||
}: ZoneMaskEditorProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [canvas, setCanvas] = useState<fabric.Canvas | null>(null);
|
||||
const [tool, setTool] = useState<
|
||||
"brush" | "rectangle" | "polygon" | "eraser"
|
||||
>("brush");
|
||||
const [brushSize, setBrushSize] = useState(20);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
// Initialiser Fabric.js
|
||||
const fabricCanvas = new fabric.Canvas(canvasRef.current, {
|
||||
width: 800,
|
||||
height: 600,
|
||||
backgroundColor: "#f0f0f0",
|
||||
});
|
||||
|
||||
// Charger l'image de fond
|
||||
fabric.Image.fromURL(imageUrl, (img) => {
|
||||
// Redimensionner l'image pour tenir dans le canvas
|
||||
const scale = Math.min(
|
||||
fabricCanvas.width! / img.width!,
|
||||
fabricCanvas.height! / img.height!
|
||||
);
|
||||
|
||||
img.scale(scale);
|
||||
img.set({
|
||||
left: (fabricCanvas.width! - img.width! * scale) / 2,
|
||||
top: (fabricCanvas.height! - img.height! * scale) / 2,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
});
|
||||
|
||||
fabricCanvas.setBackgroundImage(
|
||||
img,
|
||||
fabricCanvas.renderAll.bind(fabricCanvas)
|
||||
);
|
||||
});
|
||||
|
||||
// Configuration du pinceau
|
||||
fabricCanvas.isDrawingMode = true;
|
||||
fabricCanvas.freeDrawingBrush.width = brushSize;
|
||||
fabricCanvas.freeDrawingBrush.color = instructionColor;
|
||||
|
||||
setCanvas(fabricCanvas);
|
||||
|
||||
return () => {
|
||||
fabricCanvas.dispose();
|
||||
};
|
||||
}, [imageUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvas) return;
|
||||
|
||||
// Mettre à jour le mode de dessin selon l'outil
|
||||
if (tool === "brush" || tool === "eraser") {
|
||||
canvas.isDrawingMode = true;
|
||||
canvas.freeDrawingBrush.width = brushSize;
|
||||
canvas.freeDrawingBrush.color =
|
||||
tool === "eraser" ? "#ffffff" : instructionColor;
|
||||
} else {
|
||||
canvas.isDrawingMode = false;
|
||||
}
|
||||
}, [tool, brushSize, canvas, instructionColor]);
|
||||
|
||||
const addRectangle = () => {
|
||||
if (!canvas) return;
|
||||
|
||||
const rect = new fabric.Rect({
|
||||
left: 100,
|
||||
top: 100,
|
||||
width: 200,
|
||||
height: 150,
|
||||
fill: instructionColor,
|
||||
opacity: 0.5,
|
||||
stroke: instructionColor,
|
||||
strokeWidth: 2,
|
||||
});
|
||||
|
||||
canvas.add(rect);
|
||||
};
|
||||
|
||||
const addPolygon = () => {
|
||||
if (!canvas) return;
|
||||
|
||||
const points = [
|
||||
{ x: 100, y: 100 },
|
||||
{ x: 200, y: 50 },
|
||||
{ x: 300, y: 100 },
|
||||
{ x: 250, y: 200 },
|
||||
{ x: 150, y: 200 },
|
||||
];
|
||||
|
||||
const polygon = new fabric.Polygon(points, {
|
||||
fill: instructionColor,
|
||||
opacity: 0.5,
|
||||
stroke: instructionColor,
|
||||
strokeWidth: 2,
|
||||
});
|
||||
|
||||
canvas.add(polygon);
|
||||
};
|
||||
|
||||
const clearMask = () => {
|
||||
if (!canvas) return;
|
||||
canvas.getObjects().forEach((obj) => {
|
||||
if (obj !== canvas.backgroundImage) {
|
||||
canvas.remove(obj);
|
||||
}
|
||||
});
|
||||
canvas.renderAll();
|
||||
};
|
||||
|
||||
const undo = () => {
|
||||
if (!canvas) return;
|
||||
const objects = canvas.getObjects();
|
||||
if (objects.length > 0) {
|
||||
canvas.remove(objects[objects.length - 1]);
|
||||
canvas.renderAll();
|
||||
}
|
||||
};
|
||||
|
||||
const exportMask = () => {
|
||||
if (!canvas) return;
|
||||
|
||||
// Créer un canvas temporaire pour le masque noir/blanc
|
||||
const maskCanvas = document.createElement("canvas");
|
||||
maskCanvas.width = canvas.width!;
|
||||
maskCanvas.height = canvas.height!;
|
||||
const ctx = maskCanvas.getContext("2d")!;
|
||||
|
||||
// Fond noir
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
|
||||
// Dessiner les objets en blanc
|
||||
canvas.getObjects().forEach((obj) => {
|
||||
ctx.save();
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
|
||||
if (obj.type === "path") {
|
||||
// Dessin à main levée
|
||||
const path = obj as fabric.Path;
|
||||
const pathData = path.path!;
|
||||
ctx.beginPath();
|
||||
pathData.forEach((segment: any) => {
|
||||
const cmd = segment[0];
|
||||
if (cmd === "M") {
|
||||
ctx.moveTo(segment[1] + obj.left!, segment[2] + obj.top!);
|
||||
} else if (cmd === "L") {
|
||||
ctx.lineTo(segment[1] + obj.left!, segment[2] + obj.top!);
|
||||
} else if (cmd === "Q") {
|
||||
ctx.quadraticCurveTo(
|
||||
segment[1] + obj.left!,
|
||||
segment[2] + obj.top!,
|
||||
segment[3] + obj.left!,
|
||||
segment[4] + obj.top!
|
||||
);
|
||||
}
|
||||
});
|
||||
ctx.lineWidth = obj.strokeWidth || 1;
|
||||
ctx.stroke();
|
||||
} else if (obj.type === "rect") {
|
||||
ctx.fillRect(
|
||||
obj.left!,
|
||||
obj.top!,
|
||||
obj.width! * obj.scaleX!,
|
||||
obj.height! * obj.scaleY!
|
||||
);
|
||||
} else if (obj.type === "polygon") {
|
||||
const polygon = obj as fabric.Polygon;
|
||||
ctx.beginPath();
|
||||
polygon.points!.forEach((point, index) => {
|
||||
const x = obj.left! + point.x * obj.scaleX!;
|
||||
const y = obj.top! + point.y * obj.scaleY!;
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
});
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
});
|
||||
|
||||
// Exporter en data URL PNG
|
||||
const maskDataUrl = maskCanvas.toDataURL("image/png");
|
||||
onMaskComplete(maskDataUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/90 z-50 flex flex-col">
|
||||
{/* Barre d'outils */}
|
||||
<div className="bg-slate-800 p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-white text-lg font-semibold">
|
||||
Délimiter la zone
|
||||
</h2>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setTool("brush")}
|
||||
className={`px-3 py-2 rounded ${
|
||||
tool === "brush"
|
||||
? "bg-indigo-600 text-white"
|
||||
: "bg-slate-700 text-slate-300"
|
||||
}`}
|
||||
>
|
||||
🖌️ Pinceau
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTool("rectangle");
|
||||
addRectangle();
|
||||
}}
|
||||
className={`px-3 py-2 rounded ${
|
||||
tool === "rectangle"
|
||||
? "bg-indigo-600 text-white"
|
||||
: "bg-slate-700 text-slate-300"
|
||||
}`}
|
||||
>
|
||||
▭ Rectangle
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTool("polygon");
|
||||
addPolygon();
|
||||
}}
|
||||
className={`px-3 py-2 rounded ${
|
||||
tool === "polygon"
|
||||
? "bg-indigo-600 text-white"
|
||||
: "bg-slate-700 text-slate-300"
|
||||
}`}
|
||||
>
|
||||
⬠ Polygone
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTool("eraser")}
|
||||
className={`px-3 py-2 rounded ${
|
||||
tool === "eraser"
|
||||
? "bg-indigo-600 text-white"
|
||||
: "bg-slate-700 text-slate-300"
|
||||
}`}
|
||||
>
|
||||
🧹 Gomme
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-white text-sm">Taille:</label>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="50"
|
||||
value={brushSize}
|
||||
onChange={(e) => setBrushSize(Number(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-white text-sm">{brushSize}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={undo}
|
||||
className="px-4 py-2 bg-slate-700 text-white rounded hover:bg-slate-600"
|
||||
>
|
||||
↩️ Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={clearMask}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
||||
>
|
||||
🗑️ Effacer tout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<canvas ref={canvasRef} className="border-2 border-white shadow-2xl" />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="bg-slate-800 p-4 flex justify-between items-center">
|
||||
<p className="text-slate-300 text-sm">
|
||||
💡 Conseil : Dessinez la zone où vous souhaitez appliquer la
|
||||
référence. Soyez précis pour de meilleurs résultats.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-6 py-2 border border-slate-600 text-white rounded hover:bg-slate-700"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={exportMask}
|
||||
className="px-6 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 font-medium"
|
||||
>
|
||||
Valider le masque
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Intégration dans `generate.astro`
|
||||
|
||||
```astro
|
||||
---
|
||||
// ... imports existants
|
||||
import ZoneMaskEditor from "../components/ZoneMaskEditor";
|
||||
|
||||
// ... code serveur existant
|
||||
---
|
||||
|
||||
<Layout title="Générer">
|
||||
<!-- ... contenu existant ... -->
|
||||
|
||||
<!-- Modal d'instruction - MODIFIÉ -->
|
||||
<div id="instructionModal" class="...">
|
||||
<div class="...">
|
||||
<h2>Ajouter une instruction</h2>
|
||||
|
||||
<!-- Zone à modifier -->
|
||||
<div class="mb-4">
|
||||
<!-- ... existant ... -->
|
||||
</div>
|
||||
|
||||
<!-- Référence -->
|
||||
<div class="mb-4">
|
||||
<!-- ... existant ... -->
|
||||
</div>
|
||||
|
||||
<!-- NOUVEAU : Délimitation de zone -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-slate-700 mb-2">
|
||||
Délimiter la zone (recommandé)
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
id="openMaskEditorBtn"
|
||||
class="w-full px-4 py-3 border-2 border-dashed border-indigo-300 rounded-lg hover:border-indigo-500 hover:bg-indigo-50 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<span class="text-2xl">🎨</span>
|
||||
<span class="text-indigo-700 font-medium">Dessiner la zone sur l'image</span>
|
||||
</button>
|
||||
<canvas id="maskPreview" class="hidden mt-2 w-full rounded border border-slate-300"></canvas>
|
||||
<p id="maskStatus" class="text-xs text-slate-500 mt-1 hidden">
|
||||
✓ Zone délimitée
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Boutons -->
|
||||
<div class="flex gap-3">
|
||||
<!-- ... existant ... -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NOUVEAU : Modal éditeur de masque -->
|
||||
<div id="maskEditorContainer"></div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { createElement } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import ZoneMaskEditor from '../components/ZoneMaskEditor';
|
||||
|
||||
// ... code existant ...
|
||||
|
||||
let currentMaskDataUrl: string | null = null;
|
||||
|
||||
// Ouvrir l'éditeur de masque
|
||||
const openMaskEditorBtn = document.getElementById('openMaskEditorBtn')!;
|
||||
openMaskEditorBtn.addEventListener('click', () => {
|
||||
const container = document.getElementById('maskEditorContainer')!;
|
||||
const imageUrl = step2PreviewImg.src;
|
||||
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
createElement(ZoneMaskEditor, {
|
||||
imageUrl,
|
||||
instructionColor: '#ff6b6b', // Couleur différente par instruction
|
||||
onMaskComplete: (maskDataUrl: string) => {
|
||||
currentMaskDataUrl = maskDataUrl;
|
||||
|
||||
// Afficher aperçu miniature
|
||||
const maskPreview = document.getElementById('maskPreview') as HTMLCanvasElement;
|
||||
const ctx = maskPreview.getContext('2d')!;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
maskPreview.width = 200;
|
||||
maskPreview.height = 150;
|
||||
ctx.drawImage(img, 0, 0, 200, 150);
|
||||
maskPreview.classList.remove('hidden');
|
||||
};
|
||||
img.src = maskDataUrl;
|
||||
|
||||
document.getElementById('maskStatus')!.classList.remove('hidden');
|
||||
|
||||
// Fermer l'éditeur
|
||||
root.unmount();
|
||||
},
|
||||
onCancel: () => {
|
||||
root.unmount();
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Modifier confirmInstructionBtn pour inclure le masque
|
||||
confirmInstructionBtn.addEventListener("click", async () => {
|
||||
const location = instructionLocation.value.trim();
|
||||
if (!location) {
|
||||
alert("Veuillez entrer un emplacement");
|
||||
return;
|
||||
}
|
||||
|
||||
if (useLibrary) {
|
||||
const selected = document.querySelector(
|
||||
'input[name="selectedReference"]:checked'
|
||||
) as HTMLInputElement;
|
||||
if (!selected) {
|
||||
alert("Veuillez sélectionner une référence");
|
||||
return;
|
||||
}
|
||||
instructions.push({
|
||||
id: crypto.randomUUID(),
|
||||
location,
|
||||
referenceId: selected.value,
|
||||
referencePath: selected.dataset.path || "",
|
||||
referenceName: selected.dataset.name || undefined,
|
||||
maskDataUrl: currentMaskDataUrl || undefined, // NOUVEAU
|
||||
});
|
||||
} else {
|
||||
if (!newReferenceFile) {
|
||||
alert("Veuillez sélectionner une image");
|
||||
return;
|
||||
}
|
||||
instructions.push({
|
||||
id: crypto.randomUUID(),
|
||||
location,
|
||||
referencePath: URL.createObjectURL(newReferenceFile),
|
||||
referenceName: newReferenceName.value.trim() || undefined,
|
||||
isNew: true,
|
||||
newFile: newReferenceFile,
|
||||
maskDataUrl: currentMaskDataUrl || undefined, // NOUVEAU
|
||||
});
|
||||
}
|
||||
|
||||
// Réinitialiser le masque
|
||||
currentMaskDataUrl = null;
|
||||
document.getElementById('maskPreview')!.classList.add('hidden');
|
||||
document.getElementById('maskStatus')!.classList.add('hidden');
|
||||
|
||||
updateInstructionsList();
|
||||
instructionModal.classList.add("hidden");
|
||||
instructionModal.classList.remove("flex");
|
||||
});
|
||||
|
||||
// Modifier toStep3Btn pour envoyer les masques
|
||||
toStep3Btn.addEventListener("click", async () => {
|
||||
goToStep(3);
|
||||
// ... reste du code existant ...
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("image", selectedImage!);
|
||||
formData.append(
|
||||
"instructions",
|
||||
JSON.stringify(
|
||||
instructions.map((i) => ({
|
||||
location: i.location,
|
||||
referenceId: i.referenceId,
|
||||
referenceName: i.referenceName,
|
||||
isNew: i.isNew,
|
||||
hasMask: !!i.maskDataUrl, // NOUVEAU
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
// Ajouter les nouvelles références
|
||||
instructions.forEach((instr, idx) => {
|
||||
if (instr.isNew && instr.newFile) {
|
||||
formData.append(`newRef_${idx}`, instr.newFile);
|
||||
}
|
||||
// NOUVEAU : Ajouter les masques
|
||||
if (instr.maskDataUrl) {
|
||||
// Convertir dataURL en Blob
|
||||
fetch(instr.maskDataUrl)
|
||||
.then(res => res.blob())
|
||||
.then(blob => {
|
||||
formData.append(`mask_${idx}`, blob, `mask_${idx}.png`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ... reste du code existant (fetch API) ...
|
||||
} catch (error) {
|
||||
// ... gestion erreur ...
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 BACKEND : Support des Masques
|
||||
|
||||
### 1. Types TypeScript - `src/lib/gemini.ts`
|
||||
|
||||
```typescript
|
||||
// MODIFIER l'interface existante
|
||||
export interface GenerationInstruction {
|
||||
location: string;
|
||||
referenceImagePath: string;
|
||||
referenceName?: string;
|
||||
modificationType?: ModificationType;
|
||||
additionalDetails?: string;
|
||||
|
||||
// NOUVEAU
|
||||
maskImagePath?: string; // Chemin S3 du masque PNG
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Migration Base de Données - `migrations/add_mask_support.sql`
|
||||
|
||||
```sql
|
||||
-- Migration pour ajouter le support des masques
|
||||
ALTER TABLE instructions ADD COLUMN mask_image_path TEXT;
|
||||
|
||||
-- Index pour recherche rapide
|
||||
CREATE INDEX idx_instructions_mask ON instructions(mask_image_path) WHERE mask_image_path IS NOT NULL;
|
||||
```
|
||||
|
||||
### 3. API Generate Stream - `src/pages/api/generate-stream.ts`
|
||||
|
||||
```typescript
|
||||
// MODIFIER la section de traitement des instructions
|
||||
|
||||
// Traiter chaque instruction
|
||||
for (let i = 0; i < parsedInstructions.length; i++) {
|
||||
const instr = parsedInstructions[i];
|
||||
let referenceId = instr.referenceId;
|
||||
let referencePath = "";
|
||||
let referenceName = "";
|
||||
let maskPath = ""; // NOUVEAU
|
||||
|
||||
if (instr.isNew) {
|
||||
// ... code existant pour nouvelles références ...
|
||||
} else {
|
||||
// ... code existant pour références existantes ...
|
||||
}
|
||||
|
||||
// NOUVEAU : Traiter le masque si présent
|
||||
if (instr.hasMask) {
|
||||
const maskFile = formData.get(`mask_${i}`) as File;
|
||||
if (maskFile) {
|
||||
await sendEvent("log", {
|
||||
icon: "🎨",
|
||||
message: `Masque ${i + 1}: délimitation de zone`,
|
||||
});
|
||||
|
||||
// Sauvegarder le masque sur S3
|
||||
maskPath = await saveImage(maskFile, userId, "masks");
|
||||
|
||||
await sendEvent("log", {
|
||||
icon: "✓",
|
||||
message: `Masque ${i + 1} sauvegardé`,
|
||||
type: "success",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (referenceId && referencePath) {
|
||||
// MODIFIER : Ajouter mask_image_path
|
||||
createInstruction(generation.id, instr.location, referenceId, maskPath);
|
||||
|
||||
const instruction: GenerationInstruction = {
|
||||
location: instr.location,
|
||||
referenceImagePath: referencePath,
|
||||
referenceName: referenceName,
|
||||
maskImagePath: maskPath || undefined, // NOUVEAU
|
||||
};
|
||||
|
||||
if (instr.modificationType) {
|
||||
instruction.modificationType = instr.modificationType;
|
||||
}
|
||||
|
||||
geminiInstructions.push(instruction);
|
||||
// ... reste du code ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Fonction `createInstruction` - `src/lib/db.ts`
|
||||
|
||||
```typescript
|
||||
// MODIFIER la fonction existante
|
||||
export function createInstruction(
|
||||
generationId: string,
|
||||
location: string,
|
||||
referenceId: string,
|
||||
maskImagePath?: string // NOUVEAU paramètre optionnel
|
||||
): Instruction {
|
||||
const id = crypto.randomUUID();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO instructions (id, generation_id, location, reference_id, mask_image_path)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(id, generationId, location, referenceId, maskImagePath || null);
|
||||
return {
|
||||
id,
|
||||
generation_id: generationId,
|
||||
location,
|
||||
reference_id: referenceId,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Génération avec Masques - `src/lib/gemini.ts`
|
||||
|
||||
```typescript
|
||||
// MODIFIER la fonction generateWithNanoBanana
|
||||
|
||||
async function generateWithNanoBanana(
|
||||
originalImage: { base64: string; mimeType: string },
|
||||
referenceImages: { base64: string; mimeType: string }[],
|
||||
masks: { base64: string; mimeType: string }[], // NOUVEAU
|
||||
prompt: string,
|
||||
outputDir: string,
|
||||
generationId: string
|
||||
): Promise<{ imagePath: string; description: string }> {
|
||||
console.log(" 🎨 Agent Générateur avec masques...");
|
||||
|
||||
// Construire le contenu
|
||||
const contents: any[] = [
|
||||
{ text: prompt },
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: originalImage.mimeType,
|
||||
data: originalImage.base64,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Ajouter références + masques
|
||||
for (let i = 0; i < referenceImages.length; i++) {
|
||||
// Référence
|
||||
contents.push({
|
||||
inlineData: {
|
||||
mimeType: referenceImages[i].mimeType,
|
||||
data: referenceImages[i].base64,
|
||||
},
|
||||
});
|
||||
|
||||
// Masque associé (si présent)
|
||||
if (masks[i]) {
|
||||
contents.push({
|
||||
inlineData: {
|
||||
mimeType: "image/png",
|
||||
data: masks[i].base64,
|
||||
},
|
||||
});
|
||||
console.log(` 🎨 Masque ${i + 1} ajouté`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
` 🖼️ ${1 + referenceImages.length} images + ${
|
||||
masks.length
|
||||
} masques envoyés`
|
||||
);
|
||||
|
||||
// Enrichir le prompt avec instructions de masque
|
||||
const maskedPrompt = buildPromptWithMasks(prompt, masks.length);
|
||||
|
||||
// Appel API
|
||||
const response = await ai.models.generateContent({
|
||||
model: MODELS.GENERATOR,
|
||||
contents: [{ text: maskedPrompt }, ...contents.slice(1)],
|
||||
config: {
|
||||
responseModalities: ["TEXT", "IMAGE"],
|
||||
imageConfig: {
|
||||
aspectRatio: IMAGE_CONFIG.aspectRatio,
|
||||
imageSize: IMAGE_CONFIG.imageSize,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// ... reste identique ...
|
||||
}
|
||||
|
||||
// NOUVELLE fonction pour enrichir le prompt
|
||||
function buildPromptWithMasks(basePrompt: string, maskCount: number): string {
|
||||
if (maskCount === 0) return basePrompt;
|
||||
|
||||
const maskInstructions = `
|
||||
|
||||
## ⚠️ MASQUES DE ZONES (INSTRUCTIONS CRITIQUES)
|
||||
|
||||
${maskCount} masque(s) de zone sont fournis avec les références.
|
||||
|
||||
**Format des masques** :
|
||||
- BLANC (#FFFFFF) = zone où appliquer la modification
|
||||
- NOIR (#000000) = zone à NE PAS modifier
|
||||
|
||||
**Ordre des images** :
|
||||
IMAGE 1 : Image originale
|
||||
${Array.from(
|
||||
{ length: maskCount },
|
||||
(_, i) =>
|
||||
`IMAGE ${2 + i * 2} : Référence ${i + 1}
|
||||
IMAGE ${3 + i * 2} : Masque ${i + 1} pour la référence ${i + 1}`
|
||||
).join("\n")}
|
||||
|
||||
**RÈGLES ABSOLUES DES MASQUES** :
|
||||
1. Applique chaque référence UNIQUEMENT dans les zones BLANCHES de son masque
|
||||
2. Ne modifie JAMAIS les zones NOIRES des masques
|
||||
3. Respecte les LIMITES PRÉCISES des masques (pas de débordement)
|
||||
4. Les transitions entre zone modifiée et zone préservée doivent être NATURELLES
|
||||
5. Si un masque chevauche un objet, coupe proprement l'objet
|
||||
|
||||
**Priorité** : Les masques ont la PRIORITÉ ABSOLUE sur les instructions textuelles.`;
|
||||
|
||||
return basePrompt + maskInstructions;
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Fonction Principale - `src/lib/gemini.ts`
|
||||
|
||||
```typescript
|
||||
// MODIFIER generateBeforeAfterWithProgress
|
||||
|
||||
export async function generateBeforeAfterWithProgress(
|
||||
originalImagePath: string,
|
||||
instructions: GenerationInstruction[],
|
||||
outputDir: string,
|
||||
generationId: string,
|
||||
onProgress: ProgressCallback,
|
||||
options: GenerationOptions = {}
|
||||
): Promise<GenerationResult> {
|
||||
// ... code existant jusqu'au chargement des images ...
|
||||
|
||||
const originalImage = await prepareImageForAPI(originalImagePath);
|
||||
const referenceImages: { base64: string; mimeType: string }[] = [];
|
||||
const masks: { base64: string; mimeType: string }[] = []; // NOUVEAU
|
||||
|
||||
for (let i = 0; i < instructions.length; i++) {
|
||||
// Référence
|
||||
const refImage = await prepareImageForAPI(
|
||||
instructions[i].referenceImagePath
|
||||
);
|
||||
referenceImages.push(refImage);
|
||||
|
||||
// NOUVEAU : Masque
|
||||
if (instructions[i].maskImagePath) {
|
||||
const maskImage = await prepareImageForAPI(
|
||||
instructions[i].maskImagePath!
|
||||
);
|
||||
masks.push(maskImage);
|
||||
log(
|
||||
"✓",
|
||||
`Masque ${i + 1}: ${(maskImage.base64.length / 1024).toFixed(0)} KB`
|
||||
);
|
||||
} else {
|
||||
// Pas de masque pour cette instruction
|
||||
masks.push({ base64: "", mimeType: "" });
|
||||
}
|
||||
}
|
||||
|
||||
// ... reste du code identique sauf l'appel à generateWithNanoBanana ...
|
||||
|
||||
const result = await generateWithNanoBanana(
|
||||
originalImage,
|
||||
referenceImages,
|
||||
masks, // NOUVEAU
|
||||
prompt,
|
||||
outputDir,
|
||||
generationId
|
||||
);
|
||||
|
||||
// ... reste identique ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 TESTS
|
||||
|
||||
### Test Unitaire - Génération de Masque
|
||||
|
||||
```typescript
|
||||
// tests/mask-editor.test.ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("Mask Editor", () => {
|
||||
it("should export valid PNG data URL", () => {
|
||||
const mockCanvas = document.createElement("canvas");
|
||||
mockCanvas.width = 800;
|
||||
mockCanvas.height = 600;
|
||||
const ctx = mockCanvas.getContext("2d")!;
|
||||
|
||||
// Dessiner un rectangle blanc sur fond noir
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, 800, 600);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(100, 100, 200, 150);
|
||||
|
||||
const dataUrl = mockCanvas.toDataURL("image/png");
|
||||
|
||||
expect(dataUrl).toMatch(/^data:image\/png;base64,/);
|
||||
expect(dataUrl.length).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Test d'Intégration - API avec Masques
|
||||
|
||||
```typescript
|
||||
// tests/api-masks.test.ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { generateBeforeAfterWithProgress } from "../src/lib/gemini";
|
||||
|
||||
describe("API Generation with Masks", () => {
|
||||
it("should generate with mask constraint", async () => {
|
||||
const instructions = [
|
||||
{
|
||||
location: "mur de gauche",
|
||||
referenceImagePath: "references/test-ref.jpg",
|
||||
referenceName: "Peinture bleue",
|
||||
maskImagePath: "masks/test-mask.png",
|
||||
},
|
||||
];
|
||||
|
||||
const result = await generateBeforeAfterWithProgress(
|
||||
"originals/test-room.jpg",
|
||||
instructions,
|
||||
"generated",
|
||||
"test-gen-123",
|
||||
(event) => console.log(event)
|
||||
);
|
||||
|
||||
expect(result.imagePath).toBeDefined();
|
||||
expect(result.imagePath).toMatch(/\.png$/);
|
||||
}, 180000); // Timeout 3 min
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 MÉTRIQUES & MONITORING
|
||||
|
||||
### Ajout de Logs
|
||||
|
||||
```typescript
|
||||
// src/lib/gemini.ts
|
||||
|
||||
function logMaskUsage(instructions: GenerationInstruction[]) {
|
||||
const withMask = instructions.filter((i) => i.maskImagePath).length;
|
||||
const total = instructions.length;
|
||||
|
||||
console.log(`\n📊 Utilisation des masques:`);
|
||||
console.log(` Total instructions: ${total}`);
|
||||
console.log(
|
||||
` Avec masque: ${withMask} (${((withMask / total) * 100).toFixed(0)}%)`
|
||||
);
|
||||
console.log(` Sans masque: ${total - withMask}`);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 CHECKLIST DE DÉPLOIEMENT
|
||||
|
||||
### Phase 1 : POC Masques (Semaine 1-2)
|
||||
|
||||
- [ ] Installer Fabric.js
|
||||
- [ ] Créer `ZoneMaskEditor.tsx`
|
||||
- [ ] Tester export PNG noir/blanc
|
||||
- [ ] Intégrer dans modal
|
||||
- [ ] Tests manuels (5 cas)
|
||||
|
||||
### Phase 2 : Backend (Semaine 3-4)
|
||||
|
||||
- [ ] Migration BDD
|
||||
- [ ] Modifier `createInstruction()`
|
||||
- [ ] Modifier API `/api/generate-stream.ts`
|
||||
- [ ] Modifier `generateWithNanoBanana()`
|
||||
- [ ] Tests d'intégration
|
||||
|
||||
### Phase 3 : Tests Qualité (Semaine 5)
|
||||
|
||||
- [ ] 10 générations test avec masques
|
||||
- [ ] Validation précision des masques
|
||||
- [ ] Comparaison avec/sans masques
|
||||
- [ ] Ajustement prompts si nécessaire
|
||||
|
||||
### Phase 4 : Déploiement (Semaine 6)
|
||||
|
||||
- [ ] Documentation utilisateur
|
||||
- [ ] Tutoriel vidéo
|
||||
- [ ] Déploiement staging
|
||||
- [ ] Déploiement production
|
||||
- [ ] Monitoring erreurs
|
||||
|
||||
---
|
||||
|
||||
## 💡 BONNES PRATIQUES
|
||||
|
||||
### UX
|
||||
|
||||
- ✅ Toujours afficher un aperçu du masque avant validation
|
||||
- ✅ Permettre l'édition du masque après création
|
||||
- ✅ Couleurs différentes par instruction (rouge, bleu, vert)
|
||||
- ✅ Message de confirmation : "Masque validé ✓"
|
||||
|
||||
### Performance
|
||||
|
||||
- ✅ Compresser les masques PNG (8-bit, pas 24-bit)
|
||||
- ✅ Limiter la résolution des masques (max 2048x2048)
|
||||
- ✅ Cache les masques côté client (localStorage)
|
||||
|
||||
### Qualité
|
||||
|
||||
- ✅ Validation : masque noir/blanc uniquement
|
||||
- ✅ Validation : dimensions identiques à l'image
|
||||
- ✅ Alerte si masque trop petit (<5% de l'image)
|
||||
|
||||
---
|
||||
|
||||
Vous avez maintenant tous les éléments pour implémenter la fonctionnalité de sélection de zones ! 🚀
|
||||
776
PLAN_SELECTION_ZONES.md
Normal file
@@ -0,0 +1,776 @@
|
||||
# 📋 PLAN COMPLET : Sélection de Zones + Optimisation Multi-Métiers BTP
|
||||
|
||||
## 🔍 AUDIT DE LA CODEBASE ACTUELLE
|
||||
|
||||
### Architecture Système
|
||||
|
||||
**Fichier principal** : `/src/lib/gemini.ts` (1624 lignes)
|
||||
|
||||
**Stack technique** :
|
||||
|
||||
- **Frontend** : Astro + TypeScript
|
||||
- **IA** : Google Gemini (multi-modèles)
|
||||
- `gemini-2.5-flash` : Analyse & Planification
|
||||
- `gemini-3-pro-image-preview` (Nano Banana Pro) : Génération d'images
|
||||
- **Stockage** : AWS S3
|
||||
- **Base de données** : SQLite (local)
|
||||
|
||||
### Architecture Agentique Actuelle
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SYSTÈME MULTI-AGENT │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 1. AGENT ANALYSTE (gemini-2.5-flash) │
|
||||
│ ├─ Analyse de l'image originale │
|
||||
│ ├─ Identification des surfaces (murs, sols, plafonds) │
|
||||
│ ├─ Identification des objets (meubles, déco, plantes) │
|
||||
│ └─ Analyse des références (matériau vs objet) │
|
||||
│ │
|
||||
│ 2. AGENT PLANIFICATEUR (gemini-2.5-flash) │
|
||||
│ ├─ Mapping instructions utilisateur → zones/objets │
|
||||
│ ├─ Classification des actions │
|
||||
│ │ ├─ apply_texture (matériaux sur surfaces) │
|
||||
│ │ ├─ replace_object (remplacement d'objets) │
|
||||
│ │ └─ add_element (ajout d'éléments) │
|
||||
│ └─ Génération du prompt optimisé │
|
||||
│ │
|
||||
│ 3. AGENT GÉNÉRATEUR (gemini-3-pro-image-preview) │
|
||||
│ ├─ Génération de l'image finale │
|
||||
│ ├─ Thinking mode (images intermédiaires) │
|
||||
│ ├─ Résolution : 2K (configurable jusqu'à 4K) │
|
||||
│ └─ Aspect ratio : 4:3 (configurable) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Types de Modifications Supportés
|
||||
|
||||
**Actuellement implémentés** :
|
||||
|
||||
- ✅ `apply_texture` : Application de matériaux sur surfaces
|
||||
- ✅ `replace_object` : Remplacement d'objets (meubles, déco)
|
||||
- ✅ `add_element` : Ajout de nouveaux éléments
|
||||
- ✅ Support matériaux : sols, murs, plafonds
|
||||
- ✅ Support objets : meubles, luminaires, décoration, plantes
|
||||
|
||||
**Catégories d'éléments** :
|
||||
|
||||
```typescript
|
||||
type ElementCategory =
|
||||
| "surface" // Murs, sols, plafonds, façades
|
||||
| "furniture" // Meubles (tables, chaises, canapés, lits)
|
||||
| "lighting" // Luminaires (lustres, lampes, spots)
|
||||
| "decoration" // Déco (tableaux, miroirs, vases, rideaux)
|
||||
| "equipment" // Équipements (prises, interrupteurs, radiateurs)
|
||||
| "outdoor" // Extérieur (plantes, pergolas, clôtures, terrasses)
|
||||
| "fixture" // Éléments fixes (éviers, baignoires, sanitaires)
|
||||
| "appliance"; // Électroménager (cuisine, buanderie)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ PROBLÈMES IDENTIFIÉS
|
||||
|
||||
### 1. **Absence de Sélection de Zones**
|
||||
|
||||
❌ L'utilisateur ne peut pas définir précisément où appliquer chaque référence
|
||||
❌ Pas de système de masque/délimitation de zones
|
||||
❌ L'IA devine la zone basée uniquement sur le texte descriptif
|
||||
|
||||
### 2. **Support Extérieur Limité**
|
||||
|
||||
⚠️ Catégorie "outdoor" existe mais pas optimisée
|
||||
⚠️ Pas de prompts spécifiques pour :
|
||||
|
||||
- Panneaux solaires
|
||||
- Façades
|
||||
- Revêtements extérieurs (terrasse, allée)
|
||||
- Pergolas, vérandas
|
||||
- Paysagisme complexe
|
||||
|
||||
### 3. **Adaptation Images 3D/Catalogue**
|
||||
|
||||
❌ Pas de détection de type d'image (photo réelle vs render 3D vs catalogue)
|
||||
❌ Pas d'adaptation automatique du style
|
||||
|
||||
### 4. **Gestion des Angles de Référence**
|
||||
|
||||
❌ Pas de correction d'angle/perspective des références
|
||||
❌ Si la référence est de face et l'image cible de côté → risque de mauvais rendu
|
||||
|
||||
### 5. **Métiers BTP Non Optimisés**
|
||||
|
||||
⚠️ Prompts génériques ne couvrent pas tous les cas :
|
||||
|
||||
- Électricité (tableaux électriques, câblages)
|
||||
- Plomberie (tuyauterie visible)
|
||||
- Isolation (extérieure, intérieure)
|
||||
- Menuiserie (fenêtres, portes, volets)
|
||||
- Toiture (tuiles, ardoises, zinc)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 SOLUTION PROPOSÉE : SYSTÈME DE SÉLECTION DE ZONES
|
||||
|
||||
### Architecture Technique
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ NOUVEAU FLOW AVEC MASQUES │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ 1. UPLOAD IMAGE │
|
||||
│ └─ Affichage dans canvas interactif │
|
||||
│ │
|
||||
│ 2. POUR CHAQUE INSTRUCTION │
|
||||
│ ├─ Sélection de la référence │
|
||||
│ ├─ NOUVEAU : Dessin du masque sur canvas │
|
||||
│ │ ├─ Outil pinceau (brush) │
|
||||
│ │ ├─ Outil rectangle │
|
||||
│ │ ├─ Outil polygone │
|
||||
│ │ ├─ Outil lasso magnétique (optionnel) │
|
||||
│ │ └─ Gomme pour corriger │
|
||||
│ └─ Sauvegarde du masque en base64 PNG │
|
||||
│ │
|
||||
│ 3. GÉNÉRATION │
|
||||
│ ├─ Envoi image originale + masques + références │
|
||||
│ ├─ Gemini utilise les masques pour contraindre │
|
||||
│ └─ Génération ciblée par zone │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Implémentation Frontend
|
||||
|
||||
#### Nouveau Composant : `ZoneMaskEditor.tsx`
|
||||
|
||||
```typescript
|
||||
interface ZoneMaskEditorProps {
|
||||
imageUrl: string;
|
||||
onMaskComplete: (maskData: string) => void; // base64 PNG
|
||||
instructionIndex: number;
|
||||
}
|
||||
|
||||
// Bibliothèques suggérées :
|
||||
// - Fabric.js : canvas manipulation avancée
|
||||
// - Konva.js : alternative React-friendly
|
||||
// - react-canvas-draw : simple et léger
|
||||
```
|
||||
|
||||
**Fonctionnalités** :
|
||||
|
||||
- 🎨 Dessin de zones en superposition sur l'image
|
||||
- 🖱️ Outils : pinceau, rectangle, polygone, gomme
|
||||
- 🎨 Couleur par instruction (rouge, bleu, vert, etc.)
|
||||
- 👁️ Transparence ajustable (overlay à 50%)
|
||||
- ↩️ Undo/Redo
|
||||
- 💾 Export du masque en PNG noir/blanc
|
||||
|
||||
#### Modifications du Modal d'Instruction
|
||||
|
||||
```astro
|
||||
<!-- Dans generate.astro -->
|
||||
<div id="instructionModal">
|
||||
<!-- Existant -->
|
||||
<input id="instructionLocation" />
|
||||
<div id="librarySelection">...</div>
|
||||
|
||||
<!-- NOUVEAU -->
|
||||
<div id="zoneMaskSection" class="mt-4">
|
||||
<label>Délimiter la zone (optionnel mais recommandé)</label>
|
||||
<button id="openMaskEditor">
|
||||
🎨 Dessiner la zone sur l'image
|
||||
</button>
|
||||
<canvas id="maskPreview" class="hidden"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal plein écran pour l'éditeur de masque -->
|
||||
<div id="maskEditorModal" class="hidden">
|
||||
<ZoneMaskEditor
|
||||
imageUrl={step2PreviewImg.src}
|
||||
onMaskComplete={handleMaskComplete}
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Modifications Backend
|
||||
|
||||
#### 1. Nouveau Type : `MaskedInstruction`
|
||||
|
||||
```typescript
|
||||
// src/lib/gemini.ts
|
||||
export interface GenerationInstruction {
|
||||
location: string;
|
||||
referenceImagePath: string;
|
||||
referenceName?: string;
|
||||
modificationType?: ModificationType;
|
||||
|
||||
// NOUVEAU
|
||||
maskImagePath?: string; // Chemin S3 du masque PNG
|
||||
maskBase64?: string; // Alternative : base64 direct
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Base de Données : Nouvelle Colonne
|
||||
|
||||
```sql
|
||||
-- Migration
|
||||
ALTER TABLE instructions ADD COLUMN mask_image_path TEXT;
|
||||
```
|
||||
|
||||
#### 3. API Gemini avec Masques
|
||||
|
||||
D'après la documentation fournie, Gemini supporte l'inpainting avec masques :
|
||||
|
||||
```typescript
|
||||
async function generateWithMasks(
|
||||
originalImage: { base64: string; mimeType: string },
|
||||
referenceImages: { base64: string; mimeType: string }[],
|
||||
masks: { base64: string }[], // Masques PNG noir/blanc
|
||||
prompt: string,
|
||||
outputDir: string,
|
||||
generationId: string
|
||||
): Promise<{ imagePath: string; description: string }> {
|
||||
// Construction du payload selon l'exemple fourni
|
||||
const contents: any[] = [
|
||||
{ text: prompt },
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: originalImage.mimeType,
|
||||
data: originalImage.base64,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Ajouter les références avec leurs masques
|
||||
for (let i = 0; i < referenceImages.length; i++) {
|
||||
contents.push({
|
||||
inlineData: {
|
||||
mimeType: referenceImages[i].mimeType,
|
||||
data: referenceImages[i].base64,
|
||||
},
|
||||
});
|
||||
|
||||
// Ajouter le masque associé
|
||||
if (masks[i]) {
|
||||
contents.push({
|
||||
inlineData: {
|
||||
mimeType: "image/png",
|
||||
data: masks[i].base64,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Modifier le prompt pour indiquer l'utilisation des masques
|
||||
const maskedPrompt = `${prompt}
|
||||
|
||||
IMPORTANT : Des masques de zone sont fournis.
|
||||
- Masque ${i + 1} (IMAGE ${
|
||||
contents.length
|
||||
}): zone blanche = appliquer référence ${i + 1}, zone noire = ne pas toucher
|
||||
- Respecte STRICTEMENT les limites des masques
|
||||
- N'applique les modifications QUE dans les zones blanches des masques`;
|
||||
|
||||
// Reste identique
|
||||
const response = await ai.models.generateContent({
|
||||
model: MODELS.GENERATOR,
|
||||
contents: contents,
|
||||
config: {
|
||||
responseModalities: ["TEXT", "IMAGE"],
|
||||
imageConfig: {
|
||||
aspectRatio: IMAGE_CONFIG.aspectRatio,
|
||||
imageSize: IMAGE_CONFIG.imageSize,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// ... traitement de la réponse
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ OPTIMISATION MULTI-MÉTIERS BTP
|
||||
|
||||
### 1. Détection Automatique du Type de Projet
|
||||
|
||||
```typescript
|
||||
// src/lib/gemini.ts
|
||||
|
||||
interface ProjectContext {
|
||||
projectType:
|
||||
| "interior_residential" // Intérieur résidentiel
|
||||
| "exterior_residential" // Extérieur résidentiel
|
||||
| "commercial" // Commercial (bureau, magasin)
|
||||
| "industrial" // Industriel
|
||||
| "landscape" // Paysagisme
|
||||
| "renovation" // Rénovation lourde
|
||||
| "energy"; // Énergétique (panneaux solaires, isolation)
|
||||
|
||||
trades: // Métiers détectés
|
||||
(| "flooring" // Revêtement sols
|
||||
| "painting" // Peinture
|
||||
| "tiling" // Carrelage
|
||||
| "carpentry" // Menuiserie
|
||||
| "roofing" // Toiture
|
||||
| "facade" // Façade
|
||||
| "electricity" // Électricité
|
||||
| "plumbing" // Plomberie
|
||||
| "hvac" // Chauffage/Clim
|
||||
| "solar" // Solaire
|
||||
| "landscaping" // Paysagisme
|
||||
| "furniture" // Ameublement
|
||||
| "lighting" // Éclairage
|
||||
| "decoration"
|
||||
)[]; // Décoration
|
||||
|
||||
environment: "indoor" | "outdoor" | "mixed";
|
||||
}
|
||||
|
||||
async function detectProjectContext(
|
||||
imageData: { base64: string; mimeType: string },
|
||||
instructions: GenerationInstruction[]
|
||||
): Promise<ProjectContext> {
|
||||
const detectionPrompt = `Analyse cette image et ces instructions utilisateur.
|
||||
Détermine le TYPE DE PROJET et les MÉTIERS impliqués.
|
||||
|
||||
Instructions utilisateur :
|
||||
${instructions
|
||||
.map((i, idx) => `${idx + 1}. ${i.location} → ${i.referenceName}`)
|
||||
.join("\n")}
|
||||
|
||||
Réponds en JSON :
|
||||
{
|
||||
"projectType": "interior_residential|exterior_residential|commercial|industrial|landscape|renovation|energy",
|
||||
"trades": ["flooring", "painting", ...],
|
||||
"environment": "indoor|outdoor|mixed",
|
||||
"reasoning": "Explication courte"
|
||||
}
|
||||
|
||||
INDICES :
|
||||
- Panneaux solaires → energy, exterior
|
||||
- Façade, revêtement extérieur → facade, exterior
|
||||
- Pergola, terrasse → landscaping, outdoor
|
||||
- Parquet, carrelage intérieur → flooring, interior
|
||||
- Murs intérieurs → painting, interior`;
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: MODELS.ANALYZER,
|
||||
contents: [
|
||||
{ text: detectionPrompt },
|
||||
{ inlineData: { mimeType: imageData.mimeType, data: imageData.base64 } },
|
||||
],
|
||||
});
|
||||
|
||||
// Parser la réponse JSON
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Prompts Spécialisés par Métier
|
||||
|
||||
```typescript
|
||||
const TRADE_SPECIFIC_RULES: Record<string, string> = {
|
||||
solar: `
|
||||
## RÈGLES PANNEAUX SOLAIRES
|
||||
- Orientation : respecter l'inclinaison du toit
|
||||
- Espacement : 5-10cm entre panneaux
|
||||
- Éviter les ombres portées
|
||||
- Intégration visuelle : respecter la symétrie du toit
|
||||
- Réalisme : reflets du soleil sur le verre`,
|
||||
|
||||
facade: `
|
||||
## RÈGLES FAÇADE
|
||||
- Respecter les joints entre panneaux/briques
|
||||
- Adaptation aux ouvertures (fenêtres, portes)
|
||||
- Cohérence de la texture sur toute la surface
|
||||
- Respect des reliefs et modénatures`,
|
||||
|
||||
roofing: `
|
||||
## RÈGLES TOITURE
|
||||
- Respect des lignes de faîtage
|
||||
- Pattern de pose : en quinconce pour tuiles
|
||||
- Éviter les ruptures de motif
|
||||
- Adapter aux pentes et arrêtes`,
|
||||
|
||||
landscaping: `
|
||||
## RÈGLES PAYSAGISME
|
||||
- Respect de la perspective et des échelles
|
||||
- Ombres portées des plantes cohérentes
|
||||
- Intégration naturelle avec le sol existant
|
||||
- Profondeur de champ réaliste`,
|
||||
|
||||
flooring: `
|
||||
## RÈGLES REVÊTEMENT SOL
|
||||
- Direction de pose (horizontale, verticale, diagonale)
|
||||
- Joints alignés ou décalés selon le matériau
|
||||
- Transition avec les murs (plinthes)
|
||||
- Reflets et brillance selon le matériau`,
|
||||
|
||||
carpentry: `
|
||||
## RÈGLES MENUISERIE
|
||||
- Respect des dimensions standards (fenêtres, portes)
|
||||
- Intégration dans les tableaux
|
||||
- Jeux de lumière sur le bois/PVC/alu
|
||||
- Poignées et ferrures cohérentes`,
|
||||
|
||||
electricity: `
|
||||
## RÈGLES ÉLECTRICITÉ
|
||||
- Position réglementaire des prises/interrupteurs
|
||||
- Respect de l'alignement (hauteur standard)
|
||||
- Intégration discrète ou design selon le style
|
||||
- Câblages si apparents : organisation`,
|
||||
|
||||
tiling: `
|
||||
## RÈGLES CARRELAGE
|
||||
- Joints réguliers et alignés
|
||||
- Coupe aux angles cohérente
|
||||
- Respect du calepinage (départ centré ou coin)
|
||||
- Brillance et reflets selon le type de carrelage`,
|
||||
};
|
||||
|
||||
function enrichPromptWithTradeRules(
|
||||
basePrompt: string,
|
||||
context: ProjectContext
|
||||
): string {
|
||||
let enrichedPrompt = basePrompt;
|
||||
|
||||
for (const trade of context.trades) {
|
||||
if (TRADE_SPECIFIC_RULES[trade]) {
|
||||
enrichedPrompt += `\n\n${TRADE_SPECIFIC_RULES[trade]}`;
|
||||
}
|
||||
}
|
||||
|
||||
return enrichedPrompt;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Adaptation Images 3D/Catalogue
|
||||
|
||||
```typescript
|
||||
interface ReferenceAnalysis {
|
||||
type: "material" | "object";
|
||||
category: string;
|
||||
|
||||
// NOUVEAU
|
||||
imageType:
|
||||
| "real_photo"
|
||||
| "3d_render"
|
||||
| "catalog_cutout"
|
||||
| "technical_drawing";
|
||||
adaptationNeeded: boolean;
|
||||
adaptationInstructions?: string;
|
||||
}
|
||||
|
||||
async function analyzeReferenceWithStyle(imageData: {
|
||||
base64: string;
|
||||
mimeType: string;
|
||||
}): Promise<ReferenceAnalysis> {
|
||||
const stylePrompt = `Analyse cette image de référence.
|
||||
Détermine si c'est :
|
||||
- Une PHOTO RÉELLE (vraie photo prise dans un contexte réel)
|
||||
- Un RENDER 3D (image générée par ordinateur, trop lisse)
|
||||
- Une IMAGE CATALOGUE (produit sur fond blanc/uni, détouré)
|
||||
- Un DESSIN TECHNIQUE (schéma, plan)
|
||||
|
||||
Si c'est un render 3D ou catalogue, l'image finale doit :
|
||||
- Ajouter des imperfections réalistes
|
||||
- Adapter l'éclairage à la scène cible
|
||||
- Intégrer des micro-détails (poussière, usure légère)
|
||||
- Éviter l'aspect "trop parfait"
|
||||
|
||||
Réponds en JSON :
|
||||
{
|
||||
"type": "material|object",
|
||||
"category": "...",
|
||||
"imageType": "real_photo|3d_render|catalog_cutout|technical_drawing",
|
||||
"adaptationNeeded": true|false,
|
||||
"adaptationInstructions": "Ajouter imperfections, adapter éclairage..."
|
||||
}`;
|
||||
|
||||
// ... appel API et parsing
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Correction d'Angle/Perspective
|
||||
|
||||
```typescript
|
||||
const PERSPECTIVE_ADAPTATION = `
|
||||
## ADAPTATION DE PERSPECTIVE
|
||||
|
||||
Si la référence et l'image cible ont des ANGLES DIFFÉRENTS :
|
||||
- Vue de face → Vue de côté : projeter en 3D mentalement
|
||||
- Plongée → Contre-plongée : adapter la déformation
|
||||
- Proche → Lointain : ajuster la taille apparente
|
||||
|
||||
RÈGLES D'ADAPTATION :
|
||||
1. Identifier l'angle de la caméra dans l'image originale
|
||||
2. Identifier l'angle de vue de la référence
|
||||
3. Projeter mentalement la référence dans l'angle cible
|
||||
4. Adapter proportions, déformations perspectives
|
||||
5. Conserver l'identité visuelle de la référence (couleur, texture)
|
||||
|
||||
EXEMPLE :
|
||||
- Référence : table vue de face
|
||||
- Cible : pièce vue en 3/4
|
||||
→ Appliquer une rotation 3D mentale de la table pour l'afficher en 3/4`;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 PLAN D'IMPLÉMENTATION PAR PHASES
|
||||
|
||||
### 🎯 PHASE 1 : Sélection de Zones (Prioritaire)
|
||||
|
||||
**Durée estimée** : 2-3 semaines
|
||||
|
||||
#### Étape 1.1 : Frontend - Éditeur de Masques
|
||||
|
||||
- [ ] Installer Fabric.js ou Konva.js
|
||||
- [ ] Créer `ZoneMaskEditor.tsx`
|
||||
- [ ] Canvas interactif avec image de fond
|
||||
- [ ] Outils de dessin (pinceau, rectangle, polygone)
|
||||
- [ ] Outil gomme
|
||||
- [ ] Undo/Redo
|
||||
- [ ] Export PNG noir/blanc
|
||||
- [ ] Intégrer dans `generate.astro`
|
||||
- [ ] Bouton "Délimiter la zone"
|
||||
- [ ] Modal plein écran pour l'éditeur
|
||||
- [ ] Prévisualisation du masque
|
||||
|
||||
#### Étape 1.2 : Backend - Support des Masques
|
||||
|
||||
- [ ] Modifier `GenerationInstruction` (ajouter `maskImagePath`)
|
||||
- [ ] Migration BDD : `ALTER TABLE instructions ADD COLUMN mask_image_path`
|
||||
- [ ] API : Sauvegarder les masques sur S3
|
||||
- [ ] Modifier `generateWithNanoBanana` pour inclure les masques
|
||||
- [ ] Enrichir le prompt avec instructions de masque
|
||||
|
||||
#### Étape 1.3 : Tests & Validation
|
||||
|
||||
- [ ] Test cas simple : 1 zone, 1 référence
|
||||
- [ ] Test cas complexe : 3 zones, 3 références
|
||||
- [ ] Test sans masque (backward compatibility)
|
||||
- [ ] Validation qualité des générations
|
||||
|
||||
---
|
||||
|
||||
### 🏗️ PHASE 2 : Optimisation Multi-Métiers BTP
|
||||
|
||||
**Durée estimée** : 2 semaines
|
||||
|
||||
#### Étape 2.1 : Détection de Contexte
|
||||
|
||||
- [ ] Implémenter `detectProjectContext()`
|
||||
- [ ] Tester sur 20 images variées (intérieur, extérieur, différents métiers)
|
||||
- [ ] Ajuster les prompts de détection
|
||||
|
||||
#### Étape 2.2 : Prompts Spécialisés
|
||||
|
||||
- [ ] Créer `TRADE_SPECIFIC_RULES` pour 10 métiers
|
||||
- [ ] Implémenter `enrichPromptWithTradeRules()`
|
||||
- [ ] Tests A/B : avec/sans règles spécialisées
|
||||
|
||||
#### Étape 2.3 : Cas d'Usage Prioritaires
|
||||
|
||||
- [ ] Panneaux solaires sur toiture
|
||||
- [ ] Façade extérieure (bardage, enduit)
|
||||
- [ ] Terrasse/Revêtement extérieur
|
||||
- [ ] Pergola/Véranda
|
||||
- [ ] Paysagisme (plantes, gazon, arbres)
|
||||
|
||||
---
|
||||
|
||||
### 🎨 PHASE 3 : Adaptation Images 3D/Catalogue
|
||||
|
||||
**Durée estimée** : 1 semaine
|
||||
|
||||
#### Étape 3.1 : Détection Type d'Image
|
||||
|
||||
- [ ] Implémenter `analyzeReferenceWithStyle()`
|
||||
- [ ] Classifier : photo réelle vs 3D vs catalogue
|
||||
- [ ] Générer instructions d'adaptation
|
||||
|
||||
#### Étape 3.2 : Prompts d'Adaptation
|
||||
|
||||
- [ ] Règles pour "réaliser" les renders 3D
|
||||
- [ ] Règles pour intégrer images catalogue
|
||||
- [ ] Tests qualité
|
||||
|
||||
---
|
||||
|
||||
### 🔄 PHASE 4 : Correction de Perspective
|
||||
|
||||
**Durée estimée** : 1 semaine
|
||||
|
||||
#### Étape 4.1 : Détection d'Angle
|
||||
|
||||
- [ ] Analyser perspective de l'image originale
|
||||
- [ ] Analyser perspective de la référence
|
||||
- [ ] Détecter décalage d'angle
|
||||
|
||||
#### Étape 4.2 : Instructions d'Adaptation
|
||||
|
||||
- [ ] Ajouter bloc `PERSPECTIVE_ADAPTATION` au prompt
|
||||
- [ ] Tests sur cas critiques (face → 3/4, plongée → contre-plongée)
|
||||
|
||||
---
|
||||
|
||||
## 🔬 TESTS & VALIDATION
|
||||
|
||||
### Scénarios de Test Prioritaires
|
||||
|
||||
#### Test 1 : Intérieur Résidentiel
|
||||
|
||||
- **Image** : Salon avec parquet, murs blancs, canapé
|
||||
- **Instructions** :
|
||||
1. Sol → Carrelage gris (avec masque précis du sol)
|
||||
2. Mur gauche → Papier peint géométrique (avec masque)
|
||||
3. Canapé → Canapé moderne bleu (avec masque)
|
||||
|
||||
#### Test 2 : Extérieur - Façade
|
||||
|
||||
- **Image** : Maison avec façade crépi
|
||||
- **Instructions** :
|
||||
1. Façade principale → Bardage bois (avec masque)
|
||||
2. Toiture → Panneaux solaires (avec masque)
|
||||
|
||||
#### Test 3 : Paysagisme
|
||||
|
||||
- **Image** : Jardin avec pelouse
|
||||
- **Instructions** :
|
||||
1. Zone gauche → Massif de plantes (avec masque)
|
||||
2. Sol → Terrasse bois (avec masque)
|
||||
3. Fond → Pergola moderne (ajout)
|
||||
|
||||
#### Test 4 : Image Catalogue
|
||||
|
||||
- **Image** : Cuisine réelle
|
||||
- **Référence** : Crédence catalogue (fond blanc)
|
||||
- **Validation** : L'IA doit adapter l'image catalogue au contexte réel
|
||||
|
||||
---
|
||||
|
||||
## 📈 MÉTRIQUES DE SUCCÈS
|
||||
|
||||
### KPI Techniques
|
||||
|
||||
- ✅ Précision du masque : >95% de respect des limites
|
||||
- ✅ Temps de génération : <180 secondes
|
||||
- ✅ Taux de succès : >85% de générations satisfaisantes
|
||||
- ✅ Support multi-métiers : 15+ métiers BTP couverts
|
||||
|
||||
### KPI Utilisateur
|
||||
|
||||
- ✅ Satisfaction : >4/5 sur la précision des zones
|
||||
- ✅ Facilité d'utilisation : temps pour définir un masque <2 min
|
||||
- ✅ Taux de régénération : <30% (réduction grâce aux masques)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 NEXT STEPS IMMÉDIATS
|
||||
|
||||
### Semaine 1-2 : POC Sélection de Zones
|
||||
|
||||
1. Installer Fabric.js : `npm install fabric`
|
||||
2. Créer composant `ZoneMaskEditor.tsx` (version MVP)
|
||||
3. Tester export masque PNG
|
||||
4. Intégrer dans modal d'instruction
|
||||
|
||||
### Semaine 3-4 : Intégration Backend
|
||||
|
||||
1. Modifier schéma BDD
|
||||
2. Adapter API `/api/generate-stream.ts`
|
||||
3. Modifier `generateWithNanoBanana()` pour masques
|
||||
4. Tests end-to-end
|
||||
|
||||
### Semaine 5-6 : Optimisation Métiers
|
||||
|
||||
1. Implémenter détection contexte
|
||||
2. Créer 5 premiers prompts métiers (solaire, façade, paysage, carrelage, menuiserie)
|
||||
3. Tests A/B
|
||||
|
||||
---
|
||||
|
||||
## 💡 RECOMMANDATIONS TECHNIQUES
|
||||
|
||||
### Librairies Suggérées
|
||||
|
||||
**Canvas/Masques** :
|
||||
|
||||
- ✅ **Fabric.js** (recommandé) - Puissant, bien maintenu
|
||||
- Alternative : **Konva.js** (plus React-friendly)
|
||||
- Alternative : **react-canvas-draw** (plus simple mais limité)
|
||||
|
||||
**Traitement d'Image** :
|
||||
|
||||
- Déjà utilisé : **Sharp** (optimisation)
|
||||
- Garder pour manipulation de masques
|
||||
|
||||
### Format des Masques
|
||||
|
||||
- **Format** : PNG 8-bit (noir/blanc)
|
||||
- **Résolution** : Identique à l'image originale
|
||||
- **Compression** : PNG sans perte
|
||||
- **Taille max** : 2 MB (compression si nécessaire)
|
||||
|
||||
### API Gemini - Ordre des Images
|
||||
|
||||
Selon la doc :
|
||||
|
||||
```
|
||||
[prompt_text, image_originale, image_ref_1, mask_1, image_ref_2, mask_2, ...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❓ QUESTIONS À CLARIFIER
|
||||
|
||||
1. **Gemini supporte-t-il nativement les masques ?**
|
||||
|
||||
- La doc fournie mentionne `mask: { uri: "..." }` mais à vérifier la syntaxe exacte pour `inlineData`
|
||||
- Alternative : inclure les masques dans le prompt visuel avec instructions explicites
|
||||
|
||||
2. **Budget API Gemini**
|
||||
|
||||
- Coût par génération avec masques ?
|
||||
- Limites de taille/nombre de masques ?
|
||||
|
||||
3. **UX Prioritaire**
|
||||
- Les utilisateurs préfèrent-ils dessin libre ou outils géométriques ?
|
||||
- Masques obligatoires ou optionnels ?
|
||||
|
||||
---
|
||||
|
||||
## 📚 RESSOURCES
|
||||
|
||||
### Documentation Gemini
|
||||
|
||||
- [Gemini Image Editing API](https://ai.google.dev/gemini-api/docs/imagen)
|
||||
- [Nano Banana Pro Guide](https://developers.googleblog.com/en/gemini-3-image-generation/)
|
||||
|
||||
### Librairies Canvas
|
||||
|
||||
- [Fabric.js](http://fabricjs.com/)
|
||||
- [Konva.js](https://konvajs.org/)
|
||||
|
||||
### Exemples d'UI de Sélection
|
||||
|
||||
- Adobe Firefly : Masque à main levée
|
||||
- ChatGPT DALL-E : Gomme + brosse
|
||||
- Midjourney Pan : Délimitation rectangle
|
||||
|
||||
---
|
||||
|
||||
## ✅ CONCLUSION
|
||||
|
||||
Ce plan couvre :
|
||||
|
||||
1. ✅ **Sélection de zones** : Architecture complète frontend + backend
|
||||
2. ✅ **Multi-métiers BTP** : Détection contexte + prompts spécialisés
|
||||
3. ✅ **Images 3D/Catalogue** : Adaptation automatique
|
||||
4. ✅ **Correction perspective** : Instructions d'adaptation
|
||||
|
||||
**Priorité absolue** : Phase 1 (Sélection de zones) - Impact max sur la précision.
|
||||
|
||||
**Estimation totale** : 6-8 semaines pour l'implémentation complète.
|
||||
41
astro.config.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
import { defineConfig } from "astro/config";
|
||||
import node from "@astrojs/node";
|
||||
import clerk from "@clerk/astro";
|
||||
import { frFR } from "@clerk/localizations";
|
||||
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
import react from "@astrojs/react";
|
||||
|
||||
import sentry from "@sentry/astro";
|
||||
|
||||
export default defineConfig({
|
||||
site: "https://primadopu.fr",
|
||||
integrations: [
|
||||
clerk({
|
||||
localization: frFR,
|
||||
}),
|
||||
react(),
|
||||
sentry({
|
||||
project: "primadopu-astro",
|
||||
org: "primadopu",
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
sourceMapsUploadOptions: {
|
||||
enabled:
|
||||
process.env.NODE_ENV === "production" &&
|
||||
!!process.env.SENTRY_AUTH_TOKEN,
|
||||
},
|
||||
bundleSizeOptimizations: {
|
||||
excludeDebugStatements: true,
|
||||
excludeReplayIframe: true,
|
||||
excludeReplayShadowDom: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
adapter: node({ mode: "standalone" }),
|
||||
output: "server",
|
||||
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
});
|
||||
4
makefile
Normal file
@@ -0,0 +1,4 @@
|
||||
.PHONY: stripe
|
||||
|
||||
stripe :
|
||||
stripe listen --forward-to localhost:4321/api/stripe/webhook
|
||||
88
nginx-config.txt
Normal file
@@ -0,0 +1,88 @@
|
||||
server {
|
||||
server_name primadopu.fr www.primadopu.fr;
|
||||
client_max_body_size 100M;
|
||||
|
||||
# CRUCIAL pour mobile : timeouts pour les uploads lents
|
||||
client_body_timeout 300;
|
||||
client_header_timeout 300;
|
||||
|
||||
# Configuration spécifique pour les endpoints SSE (Server-Sent Events)
|
||||
location /api/generate-stream {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection '';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header Origin https://$host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# CRITIQUE pour SSE : désactiver tout buffering
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
chunked_transfer_encoding off;
|
||||
|
||||
# CRUCIAL pour mobile : désactiver gzip sur SSE
|
||||
gzip off;
|
||||
|
||||
# Désactiver le buffering au niveau du proxy
|
||||
proxy_request_buffering off;
|
||||
|
||||
# Timeouts très longs pour les générations d'images
|
||||
proxy_connect_timeout 600;
|
||||
proxy_send_timeout 600;
|
||||
proxy_read_timeout 600;
|
||||
send_timeout 600;
|
||||
|
||||
# Keepalive
|
||||
keepalive_timeout 600;
|
||||
|
||||
# IMPORTANT pour mobile : ignorer les fermetures client
|
||||
proxy_ignore_client_abort on;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header Origin https://$host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
|
||||
# Désactiver le buffering pour SSE
|
||||
proxy_buffering off;
|
||||
|
||||
proxy_connect_timeout 600;
|
||||
proxy_send_timeout 600;
|
||||
proxy_read_timeout 600;
|
||||
send_timeout 600;
|
||||
}
|
||||
|
||||
listen 443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/primadopu.fr/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/primadopu.fr/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
}
|
||||
|
||||
server {
|
||||
if ($host = www.primadopu.fr) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
if ($host = primadopu.fr) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
listen 80;
|
||||
server_name primadopu.fr www.primadopu.fr;
|
||||
return 404; # managed by Certbot
|
||||
}
|
||||
11230
package-lock.json
generated
Normal file
42
package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "avant_apres",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^9.5.1",
|
||||
"@astrojs/react": "^4.4.2",
|
||||
"@clerk/astro": "^2.16.10",
|
||||
"@clerk/localizations": "^3.32.1",
|
||||
"@google/genai": "^1.34.0",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@sentry/astro": "^10.32.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/fabric": "^5.3.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"astro": "^5.16.6",
|
||||
"aws4": "^1.13.2",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"fabric": "^7.1.0",
|
||||
"heic-convert": "^2.1.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"minio": "^8.0.6",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"sharp": "^0.34.5",
|
||||
"stripe": "^20.1.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/aws4": "^1.11.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/uuid": "^10.0.0"
|
||||
}
|
||||
}
|
||||
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
10
public/favicon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1e3a5f;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#3b82f6;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100" height="100" rx="20" ry="20" fill="url(#grad)"/>
|
||||
<text x="50" y="65" font-family="Arial, sans-serif" font-size="48" font-weight="bold" fill="white" text-anchor="middle">PD</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 519 B |
67
public/llms.txt
Normal file
@@ -0,0 +1,67 @@
|
||||
# PrimaDopu
|
||||
|
||||
> Outil de visualisation avant/après par intelligence artificielle pour les professionnels de l'aménagement intérieur et extérieur.
|
||||
|
||||
PrimaDopu est une application web française qui permet aux architectes, décorateurs d'intérieur, agences immobilières, artisans du bâtiment et paysagistes de créer des visuels avant/après photoréalistes en moins de 3 minutes grâce à l'IA.
|
||||
|
||||
## Fonctionnalités principales
|
||||
|
||||
- **Génération avant/après par IA** : Téléchargez une photo, décrivez les modifications souhaitées, obtenez un rendu photoréaliste
|
||||
- **Home staging virtuel** : Visualisez le potentiel d'un bien immobilier sans travaux réels
|
||||
- **Changement de matériaux** : Carrelage, parquet, peinture, papier peint, crépi, bardage
|
||||
- **Remplacement de mobilier** : Tables, canapés, lits, armoires, meubles de cuisine
|
||||
- **Décoration et textiles** : Rideaux, tapis, coussins, tableaux, objets déco
|
||||
- **Luminaires et éclairage** : Changement d'ambiance lumineuse
|
||||
- **Paysagisme** : Aménagement de jardins et espaces extérieurs
|
||||
- **Bibliothèque de références** : Sauvegardez vos matériaux et textures préférés
|
||||
|
||||
## Cas d'usage
|
||||
|
||||
- Architectes présentant des projets de rénovation à leurs clients
|
||||
- Décorateurs d'intérieur proposant des options de décoration
|
||||
- Agences immobilières réalisant du home staging virtuel
|
||||
- Cuisinistes visualisant des aménagements de cuisine
|
||||
- Artisans BTP montrant le résultat de travaux avant réalisation
|
||||
- Paysagistes présentant des projets d'aménagement extérieur
|
||||
|
||||
## Informations techniques
|
||||
|
||||
- **Site web** : https://primadopu.fr
|
||||
- **Langue** : Français
|
||||
- **Localisation** : France
|
||||
- **Type d'application** : SaaS (Software as a Service)
|
||||
- **Technologie** : Application web (pas d'installation requise)
|
||||
|
||||
## Tarification
|
||||
|
||||
- **Essai gratuit** : Disponible pour tester le service
|
||||
- **Plan Standard** : 25 projets/mois
|
||||
- **Plan Pro** : 50 projets/mois
|
||||
- Sans engagement, annulation à tout moment
|
||||
|
||||
## Contact
|
||||
|
||||
- **Email** : primadopu.ai@gmail.com
|
||||
- **Éditeur** : Anthony MENGHI (Entrepreneur individuel)
|
||||
- **SIRET** : 914 116 835 00010
|
||||
|
||||
## Pages principales
|
||||
|
||||
- / : Page d'accueil avec démonstration interactive
|
||||
- /pricing : Tarifs et abonnements
|
||||
- /sign-up : Inscription (essai gratuit)
|
||||
- /sign-in : Connexion
|
||||
- /legal/mentions : Mentions légales
|
||||
- /legal/privacy : Politique de confidentialité
|
||||
- /legal/terms : Conditions générales d'utilisation
|
||||
|
||||
## Pages privées (authentification requise)
|
||||
|
||||
- /generate : Interface de génération d'images
|
||||
- /library : Bibliothèque de matériaux de référence
|
||||
- /history : Historique des générations
|
||||
- /profile : Profil utilisateur
|
||||
|
||||
## Mots-clés
|
||||
|
||||
visualisation avant après, home staging virtuel, IA décoration intérieur, outil architecte IA, rénovation virtuelle, simulation aménagement, transformation intérieur IA, design intérieur IA, relooking maison virtuel, staging immobilier
|
||||
BIN
public/logo-primadopu-texte.png
Normal file
|
After Width: | Height: | Size: 298 KiB |
BIN
public/logo-primadopu.png
Normal file
|
After Width: | Height: | Size: 439 KiB |
BIN
public/og-image.png
Normal file
|
After Width: | Height: | Size: 529 KiB |
53
public/robots.txt
Normal file
@@ -0,0 +1,53 @@
|
||||
# Robots.txt pour PrimaDopu
|
||||
# https://primadopu.fr
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
# Pages autorisées explicitement
|
||||
Allow: /
|
||||
Allow: /pricing
|
||||
Allow: /legal/
|
||||
Allow: /sign-in
|
||||
Allow: /sign-up
|
||||
|
||||
# Bloquer les ressources privées et API
|
||||
Disallow: /api/
|
||||
Disallow: /admin/
|
||||
Disallow: /profile
|
||||
Disallow: /history
|
||||
Disallow: /generate
|
||||
Disallow: /library
|
||||
Disallow: /no-credits
|
||||
|
||||
# Bloquer les uploads générés
|
||||
Disallow: /uploads/
|
||||
|
||||
# Sitemap
|
||||
Sitemap: https://primadopu.fr/sitemap.xml
|
||||
|
||||
# Crawl-delay pour éviter la surcharge
|
||||
Crawl-delay: 1
|
||||
|
||||
# Règles spécifiques pour les bots principaux
|
||||
User-agent: Googlebot
|
||||
Allow: /
|
||||
Disallow: /api/
|
||||
Disallow: /admin/
|
||||
Disallow: /uploads/
|
||||
|
||||
User-agent: Bingbot
|
||||
Allow: /
|
||||
Disallow: /api/
|
||||
Disallow: /admin/
|
||||
Disallow: /uploads/
|
||||
|
||||
# Bloquer les bots malveillants connus
|
||||
User-agent: AhrefsBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: SemrushBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: MJ12bot
|
||||
Disallow: /
|
||||
39
public/site.webmanifest
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "PrimaDopu - Visualisation Avant/Après par IA",
|
||||
"short_name": "PrimaDopu",
|
||||
"description": "Créez des visuels avant/après professionnels en moins de 3 minutes. Rénovation, décoration, mobilier, paysagisme.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/favicon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/apple-touch-icon.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"theme_color": "#1e3a5f",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"categories": ["business", "productivity", "design"],
|
||||
"lang": "fr"
|
||||
}
|
||||
56
public/sitemap.xml
Normal file
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<!-- Page d'accueil -->
|
||||
<url>
|
||||
<loc>https://primadopu.fr/</loc>
|
||||
<lastmod>2026-01-11</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
|
||||
<!-- Page tarifs -->
|
||||
<url>
|
||||
<loc>https://primadopu.fr/pricing</loc>
|
||||
<lastmod>2026-01-11</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
|
||||
<!-- Page connexion -->
|
||||
<url>
|
||||
<loc>https://primadopu.fr/sign-in</loc>
|
||||
<lastmod>2026-01-11</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
|
||||
<!-- Page inscription -->
|
||||
<url>
|
||||
<loc>https://primadopu.fr/sign-up</loc>
|
||||
<lastmod>2026-01-11</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
|
||||
<!-- Pages légales -->
|
||||
<url>
|
||||
<loc>https://primadopu.fr/legal/mentions</loc>
|
||||
<lastmod>2026-01-11</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://primadopu.fr/legal/privacy</loc>
|
||||
<lastmod>2026-01-11</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
|
||||
<url>
|
||||
<loc>https://primadopu.fr/legal/terms</loc>
|
||||
<lastmod>2026-01-11</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
BIN
public/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
8
sentry.client.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import * as Sentry from "@sentry/astro";
|
||||
|
||||
Sentry.init({
|
||||
dsn: "https://2a29d2cf34830c052d250fffec3a7559@o4510654490083328.ingest.de.sentry.io/4510654492901456",
|
||||
// Adds request headers and IP for users, for more info visit:
|
||||
// https://docs.sentry.io/platforms/javascript/guides/astro/configuration/options/#sendDefaultPii
|
||||
sendDefaultPii: true,
|
||||
});
|
||||
8
sentry.server.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import * as Sentry from "@sentry/astro";
|
||||
|
||||
Sentry.init({
|
||||
dsn: "https://2a29d2cf34830c052d250fffec3a7559@o4510654490083328.ingest.de.sentry.io/4510654492901456",
|
||||
// Adds request headers and IP for users, for more info visit:
|
||||
// https://docs.sentry.io/platforms/javascript/guides/astro/configuration/options/#sendDefaultPii
|
||||
sendDefaultPii: true,
|
||||
});
|
||||
357
src/components/BeforeAfterDemo.astro
Normal file
@@ -0,0 +1,357 @@
|
||||
---
|
||||
import { Image } from "astro:assets";
|
||||
|
||||
import defaultBefore from "../img/before.png";
|
||||
import defaultAfter from "../img/after.png";
|
||||
|
||||
interface Props {
|
||||
beforeImage?: ImageMetadata;
|
||||
afterImage?: ImageMetadata;
|
||||
beforeLabel?: string;
|
||||
afterLabel?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
beforeImage = defaultBefore,
|
||||
afterImage = defaultAfter,
|
||||
beforeLabel = "Avant",
|
||||
afterLabel = "Après",
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<div class="demo-wrapper" data-demo>
|
||||
<div class="slider" data-slider>
|
||||
<div class="slider-frame">
|
||||
<div class="slider-img slider-after">
|
||||
<Image
|
||||
src={afterImage}
|
||||
alt={afterLabel}
|
||||
class="img"
|
||||
loading="eager"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="slider-img slider-before" data-before>
|
||||
<Image
|
||||
src={beforeImage}
|
||||
alt={beforeLabel}
|
||||
class="img"
|
||||
loading="eager"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slider-handle" data-handle>
|
||||
<div class="handle-line"></div>
|
||||
<div class="handle-btn">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="9,6 4,12 9,18"></polyline>
|
||||
<polyline points="15,6 20,12 15,18"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="badge badge-before">{beforeLabel}</span>
|
||||
<span class="badge badge-after">{afterLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.demo-wrapper {
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: relative;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
cursor: grab;
|
||||
touch-action: pan-y pinch-zoom;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.slider:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.slider-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slider-img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slider-img .img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
-webkit-user-drag: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slider-after {
|
||||
z-index: 1;
|
||||
}
|
||||
.slider-before {
|
||||
z-index: 2;
|
||||
clip-path: inset(0 65% 0 0);
|
||||
}
|
||||
|
||||
.slider-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 35%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.handle-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: white;
|
||||
box-shadow: 0 0 12px rgba(0, 0, 0, 0.4);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.handle-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2);
|
||||
color: #1e3a5f;
|
||||
transition: transform 0.2s var(--ease-out-expo);
|
||||
}
|
||||
|
||||
.slider:hover .handle-btn {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
.handle-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(8px);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.badge-before {
|
||||
left: 14px;
|
||||
background: rgba(239, 68, 68, 0.92);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-after {
|
||||
right: 14px;
|
||||
background: rgba(34, 197, 94, 0.92);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes slideReveal {
|
||||
0% {
|
||||
clip-path: inset(0 50% 0 0);
|
||||
}
|
||||
30% {
|
||||
clip-path: inset(0 70% 0 0);
|
||||
}
|
||||
60% {
|
||||
clip-path: inset(0 30% 0 0);
|
||||
}
|
||||
100% {
|
||||
clip-path: inset(0 65% 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes handleReveal {
|
||||
0% {
|
||||
left: 50%;
|
||||
}
|
||||
30% {
|
||||
left: 30%;
|
||||
}
|
||||
60% {
|
||||
left: 70%;
|
||||
}
|
||||
100% {
|
||||
left: 35%;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-before.is-animating {
|
||||
animation: slideReveal 2.5s var(--ease-out-expo);
|
||||
}
|
||||
|
||||
.slider-handle.is-animating {
|
||||
animation: handleReveal 2.5s var(--ease-out-expo);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.slider {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.handle-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.handle-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 5px 10px;
|
||||
font-size: 11px;
|
||||
top: 10px;
|
||||
}
|
||||
|
||||
.badge-before {
|
||||
left: 10px;
|
||||
}
|
||||
.badge-after {
|
||||
right: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function initDemo() {
|
||||
document.querySelectorAll<HTMLElement>("[data-demo]").forEach((demo) => {
|
||||
const slider = demo.querySelector<HTMLElement>("[data-slider]");
|
||||
const before = demo.querySelector<HTMLElement>("[data-before]");
|
||||
const handle = demo.querySelector<HTMLElement>("[data-handle]");
|
||||
|
||||
if (!slider || !before || !handle) return;
|
||||
|
||||
let isDragging = false;
|
||||
let hasAnimated = false;
|
||||
|
||||
const setPosition = (pct: number) => {
|
||||
const p = Math.max(0, Math.min(100, pct));
|
||||
before.style.clipPath = `inset(0 ${100 - p}% 0 0)`;
|
||||
handle.style.left = `${p}%`;
|
||||
};
|
||||
|
||||
const getPct = (clientX: number) => {
|
||||
const rect = slider.getBoundingClientRect();
|
||||
return ((clientX - rect.left) / rect.width) * 100;
|
||||
};
|
||||
|
||||
const stopAnim = () => {
|
||||
before.classList.remove("is-animating");
|
||||
handle.classList.remove("is-animating");
|
||||
};
|
||||
|
||||
slider.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
stopAnim();
|
||||
setPosition(getPct(e.clientX));
|
||||
});
|
||||
|
||||
window.addEventListener("mousemove", (e) => {
|
||||
if (isDragging)
|
||||
requestAnimationFrame(() => setPosition(getPct(e.clientX)));
|
||||
});
|
||||
|
||||
window.addEventListener("mouseup", () => {
|
||||
isDragging = false;
|
||||
});
|
||||
|
||||
slider.addEventListener(
|
||||
"touchstart",
|
||||
(e) => {
|
||||
isDragging = true;
|
||||
stopAnim();
|
||||
setPosition(getPct(e.touches[0].clientX));
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
slider.addEventListener(
|
||||
"touchmove",
|
||||
(e) => {
|
||||
if (isDragging)
|
||||
requestAnimationFrame(() =>
|
||||
setPosition(getPct(e.touches[0].clientX))
|
||||
);
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
slider.addEventListener("touchend", () => {
|
||||
isDragging = false;
|
||||
});
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting && !hasAnimated) {
|
||||
hasAnimated = true;
|
||||
setTimeout(() => {
|
||||
if (!isDragging) {
|
||||
before.classList.add("is-animating");
|
||||
handle.classList.add("is-animating");
|
||||
before.addEventListener(
|
||||
"animationend",
|
||||
() => {
|
||||
stopAnim();
|
||||
setPosition(35);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
}, 400);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.25 }
|
||||
);
|
||||
|
||||
observer.observe(demo);
|
||||
});
|
||||
}
|
||||
|
||||
document.readyState === "loading"
|
||||
? document.addEventListener("DOMContentLoaded", initDemo)
|
||||
: initDemo();
|
||||
document.addEventListener("astro:after-swap", initDemo);
|
||||
</script>
|
||||
90
src/components/CreditsBadge.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/** @jsxImportSource react */
|
||||
import { useAuth } from "@clerk/astro/react";
|
||||
|
||||
interface CreditsBadgeProps {
|
||||
used: number;
|
||||
limit: number;
|
||||
planName: string;
|
||||
}
|
||||
|
||||
export function CreditsBadge({ used, limit, planName }: CreditsBadgeProps) {
|
||||
const remaining = Math.max(0, limit - used);
|
||||
const percentage = Math.min(100, (used / limit) * 100);
|
||||
|
||||
const getColorClass = () => {
|
||||
if (percentage >= 100) return "bg-red-100 text-red-700 border-red-200";
|
||||
if (percentage >= 80)
|
||||
return "bg-orange-100 text-orange-700 border-orange-200";
|
||||
return "bg-green-100 text-green-700 border-green-200";
|
||||
};
|
||||
|
||||
const getProgressColor = () => {
|
||||
if (percentage >= 100) return "bg-red-500";
|
||||
if (percentage >= 80) return "bg-orange-500";
|
||||
return "bg-green-500";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border p-3 ${getColorClass()}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">Plan {planName}</span>
|
||||
<span className="text-sm font-bold">{remaining} restantes</span>
|
||||
</div>
|
||||
<div className="w-full bg-white/50 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${getProgressColor()}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs mt-1 opacity-75">
|
||||
{used} / {limit} générations
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreditsIndicator({
|
||||
used,
|
||||
limit,
|
||||
}: {
|
||||
used: number;
|
||||
limit: number;
|
||||
}) {
|
||||
const remaining = Math.max(0, limit - used);
|
||||
const isLow = remaining <= 2;
|
||||
const isEmpty = remaining === 0;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<a
|
||||
href="/no-credits"
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-red-100 text-red-700 rounded-full text-sm font-medium hover:bg-red-200 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
0 crédit
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href="/pricing"
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
isLow
|
||||
? "bg-orange-100 text-orange-700 hover:bg-orange-200"
|
||||
: "bg-slate-100 text-slate-700 hover:bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10.394 2.08a1 1 0 00-.788 0l-7 3a1 1 0 000 1.84L5.25 8.051a.999.999 0 01.356-.257l4-1.714a1 1 0 11.788 1.838l-2.328.996.002 1.069c0 .527.213 1.028.589 1.4a5.965 5.965 0 012.135 1.178A5.993 5.993 0 0115 12c1.326 0 2.556-.43 3.553-1.158l.392-.168a1 1 0 000-1.84l-7-3zM6 12.688a6.025 6.025 0 01-1-.588V14a1 1 0 001 1h8a1 1 0 001-1v-1.9a6.025 6.025 0 01-1 .588V14H6v-1.312z" />
|
||||
</svg>
|
||||
{remaining} crédit{remaining > 1 ? "s" : ""}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
157
src/components/InstructionsShowcase.astro
Normal file
@@ -0,0 +1,157 @@
|
||||
---
|
||||
import { Image } from "astro:assets";
|
||||
|
||||
import kitchenImg from "../img/kitchen.png";
|
||||
import parquetImg from "../img/black-parquet.png";
|
||||
import tableImg from "../img/coffe-table.png";
|
||||
|
||||
interface Instruction {
|
||||
image: ImageMetadata;
|
||||
action: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
instructions?: Instruction[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title = "Instructions appliquées",
|
||||
instructions = [
|
||||
{ image: kitchenImg, action: "remplace la cuisine", target: "cuisine" },
|
||||
|
||||
{ image: parquetImg, action: "tout le sol", target: "parquet noir" },
|
||||
{
|
||||
image: tableImg,
|
||||
action: "remplace la table basse",
|
||||
target: "table basse",
|
||||
},
|
||||
],
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<div class="instructions-showcase">
|
||||
<p class="instructions-title">{title}</p>
|
||||
<div class="instructions-grid">
|
||||
{
|
||||
instructions.map((instruction, index) => (
|
||||
<div class="instruction-card" style={`--delay: ${index * 0.1}s`}>
|
||||
<div class="instruction-image">
|
||||
<Image
|
||||
src={instruction.image}
|
||||
alt={instruction.target}
|
||||
class="img"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
<div class="instruction-text">
|
||||
<span class="instruction-action">{instruction.action}</span>
|
||||
<span class="instruction-arrow">→</span>
|
||||
<span class="instruction-target">{instruction.target}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.instructions-showcase {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.instructions-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.instructions-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.instruction-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 0.625rem 1rem 0.625rem 0.625rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.instruction-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.instruction-image {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.instruction-image .img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.instruction-text {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.instruction-action {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.instruction-arrow {
|
||||
color: #94a3b8;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.instruction-target {
|
||||
font-weight: 600;
|
||||
color: #1e3a5f;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.instructions-grid {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.instruction-card {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.instruction-text {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.instruction-image {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
801
src/components/OnboardingTutorial.astro
Normal file
@@ -0,0 +1,801 @@
|
||||
---
|
||||
/**
|
||||
* Tutoriel d'onboarding BLOQUANT
|
||||
*
|
||||
* STRATÉGIE :
|
||||
* - Bandeau fixe en haut avec l'étape actuelle
|
||||
* - Overlay bloquant avec un "trou" autour de l'élément ciblé
|
||||
* - L'utilisateur NE PEUT PAS interagir avec autre chose que l'élément ciblé
|
||||
* - Vérification stricte avant de passer à l'étape suivante
|
||||
*/
|
||||
interface Props {
|
||||
isFirstTime: boolean;
|
||||
}
|
||||
|
||||
const { isFirstTime } = Astro.props;
|
||||
---
|
||||
|
||||
<!-- Container principal du tutoriel -->
|
||||
<div
|
||||
id="tutorial-system"
|
||||
data-first-time={isFirstTime ? "true" : "false"}
|
||||
class="hidden"
|
||||
>
|
||||
<!-- Bandeau fixe en haut - compact sur mobile -->
|
||||
<div
|
||||
id="tutorial-banner"
|
||||
class="fixed top-0 left-0 right-0 bg-gradient-to-r from-indigo-600 to-purple-600 text-white shadow-lg"
|
||||
style="z-index: 100000;"
|
||||
>
|
||||
<div class="max-w-4xl mx-auto px-3 sm:px-4 py-2 sm:py-3 flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2 sm:gap-4 min-w-0">
|
||||
<!-- Numéro d'étape -->
|
||||
<div class="flex items-center gap-1 sm:gap-2 shrink-0">
|
||||
<span
|
||||
id="tutorial-step-number"
|
||||
class="flex items-center justify-center w-6 h-6 sm:w-8 sm:h-8 rounded-full bg-white/20 font-bold text-sm sm:text-lg"
|
||||
>1</span
|
||||
>
|
||||
<span class="text-white/70 text-sm">/</span>
|
||||
<span id="tutorial-total-steps" class="text-white/70 text-sm">4</span>
|
||||
</div>
|
||||
|
||||
<!-- Titre et description -->
|
||||
<div class="min-w-0">
|
||||
<h3 id="tutorial-step-title" class="font-semibold text-sm sm:text-lg truncate">
|
||||
Chargement...
|
||||
</h3>
|
||||
<p id="tutorial-step-description" class="text-white/80 text-xs sm:text-sm hidden sm:block"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton passer -->
|
||||
<button
|
||||
id="tutorial-skip-btn"
|
||||
class="px-2 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm bg-white/10 hover:bg-white/20 rounded-lg transition-colors whitespace-nowrap shrink-0"
|
||||
>
|
||||
<span class="hidden sm:inline">Passer le tutoriel</span>
|
||||
<span class="sm:hidden">Passer</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Barre de progression -->
|
||||
<div class="h-1 bg-white/20">
|
||||
<div
|
||||
id="tutorial-progress"
|
||||
class="h-full bg-white transition-all duration-300"
|
||||
style="width: 0%"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overlay bloquant avec trou découpé -->
|
||||
<div
|
||||
id="tutorial-overlay"
|
||||
class="fixed inset-0 pointer-events-auto"
|
||||
style="z-index: 99998; background: rgba(0,0,0,0.8);"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Bordure autour de l'élément ciblé (juste la bordure, pas d'overlay) -->
|
||||
<div
|
||||
id="tutorial-highlight"
|
||||
class="fixed pointer-events-none rounded-xl"
|
||||
style="z-index: 99999; border: 4px solid #818cf8; background: transparent;"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Zone cliquable au-dessus du highlight -->
|
||||
<div
|
||||
id="tutorial-clickzone"
|
||||
class="fixed cursor-pointer"
|
||||
style="z-index: 100001; background: transparent;"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Tooltip/Flèche pointant vers l'élément -->
|
||||
<div
|
||||
id="tutorial-tooltip"
|
||||
class="fixed bg-white rounded-xl shadow-2xl p-4 max-w-xs"
|
||||
style="z-index: 100002;"
|
||||
>
|
||||
<div
|
||||
id="tutorial-tooltip-arrow"
|
||||
class="absolute w-4 h-4 bg-white transform rotate-45"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
id="tutorial-tooltip-text"
|
||||
class="text-slate-700 font-medium text-sm relative z-10"
|
||||
>
|
||||
</p>
|
||||
<div class="mt-2 flex items-center gap-2 text-xs text-indigo-600">
|
||||
<span class="animate-pulse">👆</span>
|
||||
<span id="tutorial-action-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de bienvenue -->
|
||||
<div
|
||||
id="tutorial-welcome"
|
||||
class="fixed inset-0 bg-black/80 flex items-center justify-center p-4 overflow-y-auto"
|
||||
style="z-index: 100003;"
|
||||
>
|
||||
<div
|
||||
class="bg-white rounded-2xl shadow-2xl max-w-lg w-full p-5 sm:p-8 text-center my-auto"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 sm:w-20 sm:h-20 mx-auto mb-4 sm:mb-6 bg-white border-4 border-[#e85d04] rounded-2xl flex items-center justify-center shadow-lg overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src="/logo-primadopu.png"
|
||||
alt="PrimaDopu"
|
||||
class="w-12 h-12 sm:w-14 sm:h-14 object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl sm:text-2xl font-bold text-slate-900 mb-2 sm:mb-3">
|
||||
Bienvenue sur PrimaDopu !
|
||||
</h2>
|
||||
|
||||
<p class="text-sm sm:text-base text-slate-600 mb-4 sm:mb-6 leading-relaxed">
|
||||
Créez des visuels <strong class="text-indigo-600">avant/après</strong> professionnels
|
||||
en quelques clics grâce à l'IA.
|
||||
<br /><br />
|
||||
<strong>Ce tutoriel va vous guider étape par étape.</strong>
|
||||
</p>
|
||||
|
||||
<div class="space-y-2 sm:space-y-3 text-left bg-slate-50 rounded-xl p-3 sm:p-4 mb-4 sm:mb-6">
|
||||
<div class="flex items-start gap-2 sm:gap-3">
|
||||
<span
|
||||
class="flex items-center justify-center w-6 h-6 sm:w-7 sm:h-7 rounded-full bg-indigo-100 text-indigo-600 text-xs sm:text-sm font-bold shrink-0"
|
||||
>1</span
|
||||
>
|
||||
<p class="text-xs sm:text-sm text-slate-700">
|
||||
Téléchargez une photo de votre espace
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-start gap-2 sm:gap-3">
|
||||
<span
|
||||
class="flex items-center justify-center w-6 h-6 sm:w-7 sm:h-7 rounded-full bg-indigo-100 text-indigo-600 text-xs sm:text-sm font-bold shrink-0"
|
||||
>2</span
|
||||
>
|
||||
<p class="text-xs sm:text-sm text-slate-700">
|
||||
Ajoutez vos instructions de transformation
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-start gap-2 sm:gap-3">
|
||||
<span
|
||||
class="flex items-center justify-center w-6 h-6 sm:w-7 sm:h-7 rounded-full bg-indigo-100 text-indigo-600 text-xs sm:text-sm font-bold shrink-0"
|
||||
>3</span
|
||||
>
|
||||
<p class="text-xs sm:text-sm text-slate-700">
|
||||
L'IA génère le résultat en moins de 3 minutes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
id="tutorial-start-btn"
|
||||
class="w-full py-2.5 sm:py-3 px-6 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-xl font-semibold hover:from-indigo-700 hover:to-purple-700 transition-all shadow-lg hover:shadow-xl text-sm sm:text-base"
|
||||
>
|
||||
C'est parti ! 🚀
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
id="tutorial-skip-welcome-btn"
|
||||
class="mt-3 sm:mt-4 text-xs sm:text-sm text-slate-500 hover:text-slate-700 transition-colors"
|
||||
>
|
||||
Je connais déjà, passer →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de fin -->
|
||||
<div
|
||||
id="tutorial-complete"
|
||||
class="fixed inset-0 bg-black/80 flex items-center justify-center p-4 hidden"
|
||||
style="z-index: 100003;"
|
||||
>
|
||||
<div
|
||||
class="bg-white rounded-2xl shadow-2xl max-w-lg w-full p-8 text-center"
|
||||
>
|
||||
<div
|
||||
class="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-green-400 to-emerald-600 rounded-full flex items-center justify-center shadow-lg"
|
||||
>
|
||||
<span class="text-4xl">🎉</span>
|
||||
</div>
|
||||
|
||||
<h2 class="text-2xl font-bold text-slate-900 mb-3">
|
||||
Bravo, vous êtes prêt !
|
||||
</h2>
|
||||
|
||||
<p class="text-slate-600 mb-6">
|
||||
Vous maîtrisez maintenant les bases de PrimaDopu.<br />
|
||||
Lancez votre première génération !
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
id="tutorial-finish-btn"
|
||||
class="w-full py-3 px-6 bg-gradient-to-r from-green-500 to-emerald-600 text-white rounded-xl font-semibold hover:from-green-600 hover:to-emerald-700 transition-all shadow-lg hover:shadow-xl"
|
||||
>
|
||||
Générer ma première image ! ✨
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes pulse-ring {
|
||||
0% {
|
||||
border-color: #818cf8;
|
||||
}
|
||||
50% {
|
||||
border-color: #c7d2fe;
|
||||
}
|
||||
100% {
|
||||
border-color: #818cf8;
|
||||
}
|
||||
}
|
||||
|
||||
#tutorial-highlight {
|
||||
animation: pulse-ring 1.5s ease-in-out infinite;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Ajouter un padding au body quand le tutoriel est actif */
|
||||
body.tutorial-active {
|
||||
padding-top: 48px !important;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
body.tutorial-active {
|
||||
padding-top: 72px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
interface TutorialStep {
|
||||
id: string;
|
||||
target: string;
|
||||
title: string;
|
||||
description: string;
|
||||
tooltip: string;
|
||||
actionHint: string;
|
||||
validate: () => boolean;
|
||||
onEnter?: () => void;
|
||||
allowModalInteraction?: boolean;
|
||||
}
|
||||
|
||||
const STEPS: TutorialStep[] = [
|
||||
{
|
||||
id: "upload",
|
||||
target: "#uploadZone",
|
||||
title: "Téléchargez votre photo",
|
||||
description: "Commencez par ajouter l'image de l'espace à transformer",
|
||||
tooltip: "Cliquez ici ou glissez-déposez une image",
|
||||
actionHint: "Ajoutez une image pour continuer",
|
||||
validate: () => {
|
||||
const preview = document.getElementById("imagePreview");
|
||||
const placeholder = document.getElementById("uploadPlaceholder");
|
||||
const result = !!(
|
||||
preview &&
|
||||
!preview.classList.contains("hidden") &&
|
||||
placeholder?.classList.contains("hidden")
|
||||
);
|
||||
console.log("[Tutorial] Validate upload:", result);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "continue",
|
||||
target: "#toStep2Btn",
|
||||
title: "Passez aux instructions",
|
||||
description: "Votre image est chargée ! Continuez vers l'étape suivante",
|
||||
tooltip: "Cliquez sur ce bouton pour continuer",
|
||||
actionHint: 'Cliquez sur "Continuer →"',
|
||||
validate: () => {
|
||||
const step2 = document.getElementById("step2");
|
||||
const result = !!(step2 && !step2.classList.contains("hidden"));
|
||||
console.log("[Tutorial] Validate continue:", result);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "add-instruction",
|
||||
target: "#addInstructionBtn",
|
||||
title: "Ajoutez une instruction",
|
||||
description: "Définissez ce que vous voulez transformer",
|
||||
tooltip: "Cliquez pour ajouter votre première instruction",
|
||||
actionHint: 'Cliquez sur "+ Ajouter une instruction"',
|
||||
validate: () => {
|
||||
const modal = document.getElementById("instructionModal");
|
||||
const result = !!(modal && !modal.classList.contains("hidden"));
|
||||
console.log("[Tutorial] Validate add-instruction:", result);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "fill-instruction",
|
||||
target: "#instructionModal > div",
|
||||
title: "Complétez le formulaire",
|
||||
description: "Zone + Référence → Ajouter",
|
||||
tooltip:
|
||||
'Remplissez la zone (ex: "sol") et sélectionnez une image de référence, puis cliquez "Ajouter"',
|
||||
actionHint: 'Complétez le formulaire et cliquez "Ajouter"',
|
||||
allowModalInteraction: true,
|
||||
onEnter: () => {
|
||||
const zoneInput = document.getElementById(
|
||||
"instructionLocation"
|
||||
) as HTMLInputElement;
|
||||
if (zoneInput) {
|
||||
zoneInput.value = "";
|
||||
setTimeout(() => zoneInput.focus(), 100);
|
||||
}
|
||||
|
||||
// Ajouter une bulle d'aide temporaire dans le modal
|
||||
const modalContent = document.querySelector("#instructionModal > div");
|
||||
if (modalContent && !document.getElementById("tutorial-modal-hint")) {
|
||||
const hint = document.createElement("div");
|
||||
hint.id = "tutorial-modal-hint";
|
||||
hint.className = "bg-indigo-50 border border-indigo-200 rounded-lg p-3 mb-4 text-sm text-indigo-700";
|
||||
hint.innerHTML = `
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-lg">💡</span>
|
||||
<div>
|
||||
<strong>Étape du tutoriel :</strong><br/>
|
||||
1. Tapez une zone (ex: "sol")<br/>
|
||||
2. Choisissez une image de référence<br/>
|
||||
3. Cliquez sur "Ajouter"
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
modalContent.insertBefore(hint, modalContent.firstChild);
|
||||
}
|
||||
},
|
||||
validate: () => {
|
||||
const modal = document.getElementById("instructionModal");
|
||||
const list = document.getElementById("instructionsList");
|
||||
const result = !!(
|
||||
modal?.classList.contains("hidden") &&
|
||||
list &&
|
||||
list.children.length > 0
|
||||
);
|
||||
|
||||
// Supprimer la bulle d'aide si l'étape est validée
|
||||
if (result) {
|
||||
const hint = document.getElementById("tutorial-modal-hint");
|
||||
if (hint) hint.remove();
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[Tutorial] Validate fill-instruction:",
|
||||
result,
|
||||
"modal hidden:",
|
||||
modal?.classList.contains("hidden"),
|
||||
"list children:",
|
||||
list?.children.length
|
||||
);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "generate",
|
||||
target: "#toStep3Btn",
|
||||
title: "Lancez la génération !",
|
||||
description: "Tout est prêt, générez votre première image",
|
||||
tooltip: "Cliquez pour lancer l'IA et créer votre image",
|
||||
actionHint: 'Cliquez sur "Générer →"',
|
||||
validate: () => {
|
||||
const step3 = document.getElementById("step3");
|
||||
const result = !!(step3 && !step3.classList.contains("hidden"));
|
||||
console.log("[Tutorial] Validate generate:", result);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
class Tutorial {
|
||||
private container: HTMLElement;
|
||||
private banner: HTMLElement;
|
||||
private overlay: HTMLElement;
|
||||
private highlight: HTMLElement;
|
||||
private clickzone: HTMLElement;
|
||||
private tooltip: HTMLElement;
|
||||
private tooltipArrow: HTMLElement;
|
||||
private welcome: HTMLElement;
|
||||
private complete: HTMLElement;
|
||||
|
||||
private currentStep = -1;
|
||||
private isActive = false;
|
||||
private pollInterval: number | null = null;
|
||||
|
||||
constructor() {
|
||||
this.container = document.getElementById("tutorial-system")!;
|
||||
this.banner = document.getElementById("tutorial-banner")!;
|
||||
this.overlay = document.getElementById("tutorial-overlay")!;
|
||||
this.highlight = document.getElementById("tutorial-highlight")!;
|
||||
this.clickzone = document.getElementById("tutorial-clickzone")!;
|
||||
this.tooltip = document.getElementById("tutorial-tooltip")!;
|
||||
this.tooltipArrow = document.getElementById("tutorial-tooltip-arrow")!;
|
||||
this.welcome = document.getElementById("tutorial-welcome")!;
|
||||
this.complete = document.getElementById("tutorial-complete")!;
|
||||
|
||||
if (!this.container) {
|
||||
console.error("[Tutorial] Container not found");
|
||||
return;
|
||||
}
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init() {
|
||||
const isFirstTime = this.container.dataset.firstTime === "true";
|
||||
const completed =
|
||||
localStorage.getItem("primadopu_tutorial_completed") === "true";
|
||||
const shouldShow =
|
||||
localStorage.getItem("primadopu_show_tutorial") === "true";
|
||||
|
||||
console.log("[Tutorial] Init:", { isFirstTime, completed, shouldShow });
|
||||
|
||||
this.bindEvents();
|
||||
|
||||
const totalEl = document.getElementById("tutorial-total-steps");
|
||||
if (totalEl) totalEl.textContent = STEPS.length.toString();
|
||||
|
||||
if ((isFirstTime && !completed) || shouldShow) {
|
||||
localStorage.removeItem("primadopu_show_tutorial");
|
||||
setTimeout(() => this.start(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
private bindEvents() {
|
||||
document
|
||||
.getElementById("tutorial-start-btn")
|
||||
?.addEventListener("click", () => {
|
||||
this.welcome.classList.add("hidden");
|
||||
this.goToStep(0);
|
||||
});
|
||||
|
||||
document
|
||||
.getElementById("tutorial-skip-btn")
|
||||
?.addEventListener("click", () => this.finish());
|
||||
document
|
||||
.getElementById("tutorial-skip-welcome-btn")
|
||||
?.addEventListener("click", () => this.finish());
|
||||
|
||||
document
|
||||
.getElementById("tutorial-finish-btn")
|
||||
?.addEventListener("click", () => {
|
||||
this.finish();
|
||||
const btn = document.getElementById(
|
||||
"toStep3Btn"
|
||||
) as HTMLButtonElement;
|
||||
if (btn && !btn.disabled) btn.click();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && this.isActive) {
|
||||
this.finish();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
if (this.isActive && this.currentStep >= 0) {
|
||||
this.positionElements();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener(
|
||||
"scroll",
|
||||
() => {
|
||||
if (this.isActive && this.currentStep >= 0) {
|
||||
this.positionElements();
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
private start() {
|
||||
console.log("[Tutorial] Starting...");
|
||||
this.isActive = true;
|
||||
this.container.classList.remove("hidden");
|
||||
document.body.classList.add("tutorial-active");
|
||||
|
||||
this.welcome.classList.remove("hidden");
|
||||
this.overlay.style.display = "none";
|
||||
this.highlight.style.display = "none";
|
||||
this.clickzone.style.display = "none";
|
||||
this.tooltip.style.display = "none";
|
||||
}
|
||||
|
||||
private goToStep(index: number) {
|
||||
console.log("[Tutorial] Going to step", index);
|
||||
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
|
||||
if (index >= STEPS.length) {
|
||||
this.showComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentStep = index;
|
||||
const step = STEPS[index];
|
||||
|
||||
this.updateBanner(step, index);
|
||||
|
||||
this.waitForTarget(step.target, (target) => {
|
||||
console.log("[Tutorial] Target found:", step.target);
|
||||
|
||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
|
||||
setTimeout(() => {
|
||||
// D'abord configurer le mode d'interaction (qui peut cacher les éléments)
|
||||
this.setupClickForwarding(target, step);
|
||||
|
||||
// Ensuite positionner les éléments (seulement si pas en mode modal)
|
||||
this.positionElements();
|
||||
|
||||
if (step.onEnter) step.onEnter();
|
||||
|
||||
this.startValidationPoll(step);
|
||||
}, 400);
|
||||
});
|
||||
}
|
||||
|
||||
private updateBanner(step: TutorialStep, index: number) {
|
||||
const numEl = document.getElementById("tutorial-step-number");
|
||||
const titleEl = document.getElementById("tutorial-step-title");
|
||||
const descEl = document.getElementById("tutorial-step-description");
|
||||
const progressEl = document.getElementById("tutorial-progress");
|
||||
|
||||
if (numEl) numEl.textContent = (index + 1).toString();
|
||||
if (titleEl) titleEl.textContent = step.title;
|
||||
if (descEl) descEl.textContent = step.description;
|
||||
if (progressEl)
|
||||
progressEl.style.width = `${((index + 1) / STEPS.length) * 100}%`;
|
||||
}
|
||||
|
||||
private waitForTarget(
|
||||
selector: string,
|
||||
callback: (el: HTMLElement) => void,
|
||||
maxAttempts = 50
|
||||
) {
|
||||
let attempts = 0;
|
||||
|
||||
const check = () => {
|
||||
const el = document.querySelector(selector) as HTMLElement;
|
||||
if (el && el.offsetParent !== null) {
|
||||
callback(el);
|
||||
} else if (attempts < maxAttempts) {
|
||||
attempts++;
|
||||
setTimeout(check, 100);
|
||||
} else {
|
||||
console.warn("[Tutorial] Target not found:", selector);
|
||||
this.goToStep(this.currentStep + 1);
|
||||
}
|
||||
};
|
||||
|
||||
check();
|
||||
}
|
||||
|
||||
private positionElements() {
|
||||
if (this.currentStep < 0 || this.currentStep >= STEPS.length) return;
|
||||
|
||||
const step = STEPS[this.currentStep];
|
||||
|
||||
// Si on est en mode interaction avec le modal, ne pas repositionner les éléments (ils sont cachés)
|
||||
if (step.allowModalInteraction) return;
|
||||
|
||||
const target = document.querySelector(step.target) as HTMLElement;
|
||||
|
||||
if (!target) return;
|
||||
|
||||
const rect = target.getBoundingClientRect();
|
||||
const padding = 12;
|
||||
|
||||
// Calculer les coordonnées du trou
|
||||
const holeTop = rect.top - padding;
|
||||
const holeLeft = rect.left - padding;
|
||||
const holeWidth = rect.width + padding * 2;
|
||||
const holeHeight = rect.height + padding * 2;
|
||||
const holeRight = holeLeft + holeWidth;
|
||||
const holeBottom = holeTop + holeHeight;
|
||||
|
||||
// Créer un clip-path polygon qui fait un trou dans l'overlay
|
||||
// Le polygon dessine l'extérieur (écran entier) puis l'intérieur (trou) en sens inverse
|
||||
const clipPath = `polygon(
|
||||
0% 0%,
|
||||
0% 100%,
|
||||
${holeLeft}px 100%,
|
||||
${holeLeft}px ${holeTop}px,
|
||||
${holeRight}px ${holeTop}px,
|
||||
${holeRight}px ${holeBottom}px,
|
||||
${holeLeft}px ${holeBottom}px,
|
||||
${holeLeft}px 100%,
|
||||
100% 100%,
|
||||
100% 0%
|
||||
)`;
|
||||
|
||||
this.overlay.style.clipPath = clipPath;
|
||||
|
||||
// Positionner la bordure highlight
|
||||
this.highlight.style.top = `${holeTop}px`;
|
||||
this.highlight.style.left = `${holeLeft}px`;
|
||||
this.highlight.style.width = `${holeWidth}px`;
|
||||
this.highlight.style.height = `${holeHeight}px`;
|
||||
|
||||
// Positionner la zone cliquable
|
||||
this.clickzone.style.top = `${holeTop}px`;
|
||||
this.clickzone.style.left = `${holeLeft}px`;
|
||||
this.clickzone.style.width = `${holeWidth}px`;
|
||||
this.clickzone.style.height = `${holeHeight}px`;
|
||||
|
||||
this.positionTooltip(rect);
|
||||
|
||||
const textEl = document.getElementById("tutorial-tooltip-text");
|
||||
const hintEl = document.getElementById("tutorial-action-hint");
|
||||
if (textEl) textEl.textContent = step.tooltip;
|
||||
if (hintEl) hintEl.textContent = step.actionHint;
|
||||
}
|
||||
|
||||
private positionTooltip(rect: DOMRect) {
|
||||
const tooltipWidth = 280;
|
||||
const tooltipHeight = 100;
|
||||
const gap = 16;
|
||||
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
let arrowPosition = "top";
|
||||
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
const spaceRight = window.innerWidth - rect.right;
|
||||
|
||||
if (spaceBelow > tooltipHeight + gap + 50) {
|
||||
top = rect.bottom + gap;
|
||||
left = rect.left + rect.width / 2 - tooltipWidth / 2;
|
||||
arrowPosition = "top";
|
||||
} else if (spaceAbove > tooltipHeight + gap + 100) {
|
||||
top = rect.top - tooltipHeight - gap;
|
||||
left = rect.left + rect.width / 2 - tooltipWidth / 2;
|
||||
arrowPosition = "bottom";
|
||||
} else if (spaceRight > tooltipWidth + gap) {
|
||||
top = rect.top + rect.height / 2 - tooltipHeight / 2;
|
||||
left = rect.right + gap;
|
||||
arrowPosition = "left";
|
||||
} else {
|
||||
top = rect.top + rect.height / 2 - tooltipHeight / 2;
|
||||
left = rect.left - tooltipWidth - gap;
|
||||
arrowPosition = "right";
|
||||
}
|
||||
|
||||
left = Math.max(
|
||||
16,
|
||||
Math.min(left, window.innerWidth - tooltipWidth - 16)
|
||||
);
|
||||
top = Math.max(
|
||||
100,
|
||||
Math.min(top, window.innerHeight - tooltipHeight - 16)
|
||||
);
|
||||
|
||||
this.tooltip.style.top = `${top}px`;
|
||||
this.tooltip.style.left = `${left}px`;
|
||||
|
||||
this.tooltipArrow.className =
|
||||
"absolute w-4 h-4 bg-white transform rotate-45";
|
||||
switch (arrowPosition) {
|
||||
case "top":
|
||||
this.tooltipArrow.style.cssText =
|
||||
"top: -8px; left: 50%; transform: translateX(-50%) rotate(45deg); border-top: 1px solid #e2e8f0; border-left: 1px solid #e2e8f0;";
|
||||
break;
|
||||
case "bottom":
|
||||
this.tooltipArrow.style.cssText =
|
||||
"bottom: -8px; left: 50%; transform: translateX(-50%) rotate(45deg); border-bottom: 1px solid #e2e8f0; border-right: 1px solid #e2e8f0;";
|
||||
break;
|
||||
case "left":
|
||||
this.tooltipArrow.style.cssText =
|
||||
"left: -8px; top: 50%; transform: translateY(-50%) rotate(45deg); border-left: 1px solid #e2e8f0; border-bottom: 1px solid #e2e8f0;";
|
||||
break;
|
||||
case "right":
|
||||
this.tooltipArrow.style.cssText =
|
||||
"right: -8px; top: 50%; transform: translateY(-50%) rotate(45deg); border-right: 1px solid #e2e8f0; border-top: 1px solid #e2e8f0;";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private setupClickForwarding(target: HTMLElement, step: TutorialStep) {
|
||||
this.clickzone.onclick = null;
|
||||
|
||||
if (step.allowModalInteraction) {
|
||||
// Pour le modal, on cache TOUT sauf le bandeau pour permettre l'interaction complète
|
||||
this.clickzone.style.display = "none";
|
||||
this.overlay.style.display = "none";
|
||||
this.highlight.style.display = "none";
|
||||
this.tooltip.style.display = "none";
|
||||
} else {
|
||||
// Réactiver les éléments si ce n'est pas un modal
|
||||
this.overlay.style.display = "block";
|
||||
this.highlight.style.display = "block";
|
||||
this.tooltip.style.display = "block";
|
||||
this.clickzone.style.display = "block";
|
||||
this.overlay.style.pointerEvents = "auto";
|
||||
|
||||
this.clickzone.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (step.id === "upload") {
|
||||
const fileInput = document.getElementById(
|
||||
"imageInput"
|
||||
) as HTMLInputElement;
|
||||
if (fileInput) fileInput.click();
|
||||
} else {
|
||||
target.click();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private startValidationPoll(step: TutorialStep) {
|
||||
this.pollInterval = window.setInterval(() => {
|
||||
if (step.validate()) {
|
||||
console.log("[Tutorial] Step validated:", step.id);
|
||||
this.goToStep(this.currentStep + 1);
|
||||
} else {
|
||||
this.positionElements();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
private showComplete() {
|
||||
console.log("[Tutorial] Showing complete modal");
|
||||
|
||||
this.overlay.style.display = "none";
|
||||
this.highlight.style.display = "none";
|
||||
this.clickzone.style.display = "none";
|
||||
this.tooltip.style.display = "none";
|
||||
|
||||
this.complete.classList.remove("hidden");
|
||||
}
|
||||
|
||||
private finish() {
|
||||
console.log("[Tutorial] Finishing");
|
||||
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
|
||||
this.container.classList.add("hidden");
|
||||
this.welcome.classList.add("hidden");
|
||||
this.complete.classList.add("hidden");
|
||||
|
||||
document.body.classList.remove("tutorial-active");
|
||||
|
||||
localStorage.setItem("primadopu_tutorial_completed", "true");
|
||||
|
||||
this.isActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => new Tutorial());
|
||||
} else {
|
||||
new Tutorial();
|
||||
}
|
||||
|
||||
(window as any).replayTutorial = () => {
|
||||
localStorage.removeItem("primadopu_tutorial_completed");
|
||||
localStorage.setItem("primadopu_show_tutorial", "true");
|
||||
window.location.href = "/generate";
|
||||
};
|
||||
</script>
|
||||
496
src/components/ShareButtons.astro
Normal file
@@ -0,0 +1,496 @@
|
||||
---
|
||||
interface Props {
|
||||
imageUrl: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
imageUrl,
|
||||
title = "Ma transformation Avant/Après",
|
||||
description = "Découvrez ma transformation réalisée avec l'IA !",
|
||||
class: className = "",
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<div
|
||||
class={`share-container relative ${className}`}
|
||||
data-image-url={imageUrl}
|
||||
data-title={title}
|
||||
data-description={description}
|
||||
>
|
||||
<!-- Bouton principal Partager -->
|
||||
<button
|
||||
type="button"
|
||||
class="share-toggle flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-[#1e3a5f] to-[#3b82f6] hover:from-[#162d4a] hover:to-[#2563eb] text-white rounded-xl text-sm font-semibold transition-all shadow-lg hover:shadow-xl"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Partager</span>
|
||||
<svg
|
||||
class="w-4 h-4 chevron-icon transition-transform duration-200"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Panel des options de partage (caché par défaut) -->
|
||||
<div
|
||||
class="share-panel hidden absolute z-50 mt-2 p-3 bg-white rounded-xl shadow-2xl border border-slate-200 min-w-[280px] sm:min-w-[320px]"
|
||||
>
|
||||
<!-- Ligne 1: Actions principales -->
|
||||
<div class="flex flex-wrap gap-2 mb-3 pb-3 border-b border-slate-100">
|
||||
<!-- Partage natif (mobile) - Envoyer via app -->
|
||||
<button
|
||||
type="button"
|
||||
class="share-btn native-share flex items-center gap-2 px-3 py-2 bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white rounded-lg text-sm font-medium transition-all flex-1 justify-center"
|
||||
title="Envoyer via une app"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Envoyer via...</span>
|
||||
</button>
|
||||
|
||||
<!-- Copier le lien -->
|
||||
<button
|
||||
type="button"
|
||||
class="share-btn copy-link flex items-center gap-2 px-3 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg text-sm font-medium transition-colors"
|
||||
title="Copier le lien"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="copy-text">Copier</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Ligne 2: Réseaux sociaux -->
|
||||
<div class="grid grid-cols-4 sm:grid-cols-5 gap-2 mb-3">
|
||||
<!-- Facebook -->
|
||||
<button
|
||||
type="button"
|
||||
class="share-btn facebook flex flex-col items-center gap-1 p-2 hover:bg-slate-50 rounded-lg transition-colors group"
|
||||
title="Facebook"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 flex items-center justify-center bg-[#1877F2] group-hover:bg-[#166FE5] text-white rounded-full transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-xs text-slate-600">Facebook</span>
|
||||
</button>
|
||||
|
||||
<!-- Twitter/X -->
|
||||
<button
|
||||
type="button"
|
||||
class="share-btn twitter flex flex-col items-center gap-1 p-2 hover:bg-slate-50 rounded-lg transition-colors group"
|
||||
title="X (Twitter)"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 flex items-center justify-center bg-black group-hover:bg-gray-800 text-white rounded-full transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-xs text-slate-600">X</span>
|
||||
</button>
|
||||
|
||||
<!-- Instagram -->
|
||||
<button
|
||||
type="button"
|
||||
class="share-btn instagram flex flex-col items-center gap-1 p-2 hover:bg-slate-50 rounded-lg transition-colors group"
|
||||
title="Instagram"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 flex items-center justify-center bg-gradient-to-br from-[#833AB4] via-[#FD1D1D] to-[#F77737] group-hover:opacity-90 text-white rounded-full transition-all"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-xs text-slate-600">Instagram</span>
|
||||
</button>
|
||||
|
||||
<!-- WhatsApp -->
|
||||
<button
|
||||
type="button"
|
||||
class="share-btn whatsapp flex flex-col items-center gap-1 p-2 hover:bg-slate-50 rounded-lg transition-colors group"
|
||||
title="WhatsApp"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 flex items-center justify-center bg-[#25D366] group-hover:bg-[#20BD5A] text-white rounded-full transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-xs text-slate-600">WhatsApp</span>
|
||||
</button>
|
||||
|
||||
<!-- LinkedIn -->
|
||||
<button
|
||||
type="button"
|
||||
class="share-btn linkedin flex flex-col items-center gap-1 p-2 hover:bg-slate-50 rounded-lg transition-colors group"
|
||||
title="LinkedIn"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 flex items-center justify-center bg-[#0A66C2] group-hover:bg-[#004182] text-white rounded-full transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-xs text-slate-600">LinkedIn</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Ligne 3: Email et Télécharger -->
|
||||
<div class="flex gap-2">
|
||||
<!-- Email -->
|
||||
<button
|
||||
type="button"
|
||||
class="share-btn email flex items-center gap-2 px-3 py-2 bg-[#1e3a5f] hover:bg-[#162d4a] text-white rounded-lg text-sm font-medium transition-colors flex-1 justify-center"
|
||||
title="Envoyer par email"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Email</span>
|
||||
</button>
|
||||
|
||||
<!-- Télécharger -->
|
||||
<button
|
||||
type="button"
|
||||
class="share-btn download flex items-center gap-2 px-3 py-2 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white rounded-lg text-sm font-medium transition-colors flex-1 justify-center"
|
||||
title="Télécharger l'image"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Télécharger</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.querySelectorAll(".share-container").forEach((container) => {
|
||||
const title = container.getAttribute("data-title") || "";
|
||||
const description = container.getAttribute("data-description") || "";
|
||||
|
||||
const toggleBtn = container.querySelector(".share-toggle");
|
||||
const panel = container.querySelector(".share-panel");
|
||||
const chevron = container.querySelector(".chevron-icon");
|
||||
const nativeShareBtn = container.querySelector(".native-share");
|
||||
|
||||
// Helper pour obtenir l'URL de l'image dynamiquement (peut être mise à jour après le chargement)
|
||||
const getImageUrl = () => {
|
||||
const imageUrl = container.getAttribute("data-image-url") || "";
|
||||
return imageUrl.startsWith("http")
|
||||
? imageUrl
|
||||
: `${window.location.origin}${imageUrl}`;
|
||||
};
|
||||
|
||||
// URL de la page actuelle pour le partage
|
||||
const pageUrl = window.location.href;
|
||||
const shareText = `${title} - ${description}`;
|
||||
|
||||
// Détecter si on est sur mobile
|
||||
const isMobile =
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent
|
||||
);
|
||||
|
||||
// Vérifier si Web Share API est disponible
|
||||
const canShare = navigator.share !== undefined;
|
||||
|
||||
// Afficher/masquer le bouton de partage natif selon la plateforme
|
||||
if (nativeShareBtn) {
|
||||
if (canShare) {
|
||||
nativeShareBtn.classList.remove("hidden");
|
||||
} else {
|
||||
nativeShareBtn.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
// Fonction pour ajouter/retirer le padding de la page
|
||||
const updatePagePadding = (isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
document.body.style.paddingBottom = "300px";
|
||||
} else {
|
||||
document.body.style.paddingBottom = "";
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle du panel
|
||||
toggleBtn?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const isOpen = !panel?.classList.contains("hidden");
|
||||
|
||||
if (isOpen) {
|
||||
panel?.classList.add("hidden");
|
||||
chevron?.classList.remove("rotate-180");
|
||||
updatePagePadding(false);
|
||||
} else {
|
||||
panel?.classList.remove("hidden");
|
||||
chevron?.classList.add("rotate-180");
|
||||
updatePagePadding(true);
|
||||
}
|
||||
});
|
||||
|
||||
// Fermer le panel si on clique ailleurs
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!container.contains(e.target as Node)) {
|
||||
panel?.classList.add("hidden");
|
||||
chevron?.classList.remove("rotate-180");
|
||||
updatePagePadding(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Partage natif (Web Share API - mobile)
|
||||
nativeShareBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
const fullImageUrl = getImageUrl();
|
||||
// Essayer de partager avec l'image si possible
|
||||
if (navigator.canShare) {
|
||||
const response = await fetch(fullImageUrl);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], "avant-apres.jpg", {
|
||||
type: blob.type,
|
||||
});
|
||||
|
||||
if (navigator.canShare({ files: [file] })) {
|
||||
await navigator.share({
|
||||
title: title,
|
||||
text: description,
|
||||
url: pageUrl,
|
||||
files: [file],
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: partage sans fichier
|
||||
await navigator.share({
|
||||
title: title,
|
||||
text: `${shareText}\n${pageUrl}`,
|
||||
url: pageUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== "AbortError") {
|
||||
console.error("Erreur partage natif:", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Copier le lien
|
||||
container
|
||||
.querySelector(".copy-link")
|
||||
?.addEventListener("click", async () => {
|
||||
try {
|
||||
const fullImageUrl = getImageUrl();
|
||||
await navigator.clipboard.writeText(fullImageUrl);
|
||||
const copyText = container.querySelector(".copy-text");
|
||||
if (copyText) {
|
||||
copyText.textContent = "Copié !";
|
||||
setTimeout(() => {
|
||||
copyText.textContent = "Copier";
|
||||
}, 2000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Erreur copie:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// Facebook
|
||||
container.querySelector(".facebook")?.addEventListener("click", () => {
|
||||
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(pageUrl)}"e=${encodeURIComponent(shareText)}`;
|
||||
window.open(url, "_blank", "width=600,height=400");
|
||||
});
|
||||
|
||||
// Twitter/X
|
||||
container.querySelector(".twitter")?.addEventListener("click", () => {
|
||||
const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(pageUrl)}`;
|
||||
window.open(url, "_blank", "width=600,height=400");
|
||||
});
|
||||
|
||||
// Instagram - Télécharge l'image (Instagram n'a pas d'API de partage web)
|
||||
container
|
||||
.querySelector(".instagram")
|
||||
?.addEventListener("click", async () => {
|
||||
const fullImageUrl = getImageUrl();
|
||||
// Sur mobile, on ouvre l'app Instagram si possible, sinon on télécharge
|
||||
if (isMobile) {
|
||||
// Essayer d'ouvrir l'app Instagram
|
||||
const instagramUrl =
|
||||
"instagram://library?AssetPath=" +
|
||||
encodeURIComponent(fullImageUrl);
|
||||
|
||||
// Télécharger d'abord l'image
|
||||
try {
|
||||
const response = await fetch(fullImageUrl);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `primadopu-avant-apres-${Date.now()}.jpg`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// Afficher un message
|
||||
alert(
|
||||
"Image téléchargée ! Ouvrez Instagram et partagez-la depuis votre galerie."
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Erreur Instagram:", err);
|
||||
window.open("https://instagram.com", "_blank");
|
||||
}
|
||||
} else {
|
||||
// Sur desktop, télécharger l'image
|
||||
try {
|
||||
const response = await fetch(fullImageUrl);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `primadopu-avant-apres-${Date.now()}.jpg`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
alert("Image téléchargée ! Ouvrez Instagram et partagez-la.");
|
||||
} catch (err) {
|
||||
console.error("Erreur Instagram:", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// WhatsApp
|
||||
container.querySelector(".whatsapp")?.addEventListener("click", () => {
|
||||
const message = `${shareText}\n${pageUrl}`;
|
||||
if (isMobile) {
|
||||
// Sur mobile, utiliser le lien direct WhatsApp
|
||||
window.location.href = `whatsapp://send?text=${encodeURIComponent(message)}`;
|
||||
} else {
|
||||
// Sur desktop, utiliser web.whatsapp.com
|
||||
window.open(
|
||||
`https://web.whatsapp.com/send?text=${encodeURIComponent(message)}`,
|
||||
"_blank",
|
||||
"width=600,height=400"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// LinkedIn
|
||||
container.querySelector(".linkedin")?.addEventListener("click", () => {
|
||||
const url = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(pageUrl)}`;
|
||||
window.open(url, "_blank", "width=600,height=400");
|
||||
});
|
||||
|
||||
// Email
|
||||
container.querySelector(".email")?.addEventListener("click", () => {
|
||||
const fullImageUrl = getImageUrl();
|
||||
const subject = encodeURIComponent(title);
|
||||
const body = encodeURIComponent(
|
||||
`${description}\n\nVoir l'image: ${fullImageUrl}\n\nGénéré avec PrimaDopu - https://primadopu.fr`
|
||||
);
|
||||
window.location.href = `mailto:?subject=${subject}&body=${body}`;
|
||||
});
|
||||
|
||||
// Télécharger
|
||||
container
|
||||
.querySelector(".download")
|
||||
?.addEventListener("click", async () => {
|
||||
const fullImageUrl = getImageUrl();
|
||||
try {
|
||||
const response = await fetch(fullImageUrl);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `primadopu-avant-apres-${Date.now()}.jpg`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error("Erreur téléchargement:", err);
|
||||
// Fallback: ouvrir l'image dans un nouvel onglet
|
||||
window.open(fullImageUrl, "_blank");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
820
src/components/ZoneMaskEditor.tsx
Normal file
@@ -0,0 +1,820 @@
|
||||
// @refresh reset
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import * as fabric from "fabric";
|
||||
import {
|
||||
Paintbrush,
|
||||
Pentagon,
|
||||
Eraser,
|
||||
Hand,
|
||||
Undo2,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Trash2,
|
||||
Check,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ZoneMaskEditorProps {
|
||||
imageUrl: string;
|
||||
onSave: (maskDataUrl: string) => void;
|
||||
onCancel: () => void;
|
||||
referenceImage?: string;
|
||||
}
|
||||
|
||||
type DrawingTool = "brush" | "polygon" | "eraser" | "pan";
|
||||
|
||||
export default function ZoneMaskEditor({
|
||||
imageUrl,
|
||||
onSave,
|
||||
onCancel,
|
||||
referenceImage,
|
||||
}: ZoneMaskEditorProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const fabricCanvasRef = useRef<fabric.Canvas | null>(null);
|
||||
const [selectedTool, setSelectedTool] = useState<DrawingTool>("brush");
|
||||
const [brushSize, setBrushSize] = useState(30);
|
||||
const [isDrawingPolygon, setIsDrawingPolygon] = useState(false);
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const polygonPointsRef = useRef<{ x: number; y: number }[]>([]);
|
||||
const polygonCirclesRef = useRef<fabric.Circle[]>([]);
|
||||
const backgroundImageRef = useRef<fabric.FabricImage | null>(null);
|
||||
const panningRef = useRef<{
|
||||
isDragging: boolean;
|
||||
lastX: number;
|
||||
lastY: number;
|
||||
}>({
|
||||
isDragging: false,
|
||||
lastX: 0,
|
||||
lastY: 0,
|
||||
});
|
||||
const originalImageSizeRef = useRef<{
|
||||
width: number;
|
||||
height: number;
|
||||
scale: number;
|
||||
}>({
|
||||
width: 0,
|
||||
height: 0,
|
||||
scale: 1,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
// Initialize Fabric.js canvas with touch support
|
||||
const canvas = new fabric.Canvas(canvasRef.current, {
|
||||
isDrawingMode: false,
|
||||
selection: false,
|
||||
allowTouchScrolling: false, // Empêcher le scroll par défaut sur touch
|
||||
});
|
||||
|
||||
canvas.backgroundColor = "#000000";
|
||||
fabricCanvasRef.current = canvas;
|
||||
|
||||
// Empêcher le scroll de la page quand on touche le canvas
|
||||
const canvasElement = canvasRef.current;
|
||||
const upperCanvas = canvas.upperCanvasEl;
|
||||
|
||||
const preventScroll = (e: TouchEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
if (upperCanvas) {
|
||||
upperCanvas.addEventListener("touchstart", preventScroll, {
|
||||
passive: false,
|
||||
});
|
||||
upperCanvas.addEventListener("touchmove", preventScroll, {
|
||||
passive: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Load the original image
|
||||
fabric.FabricImage.fromURL(imageUrl, {
|
||||
crossOrigin: "anonymous",
|
||||
}).then((img) => {
|
||||
if (!img.width || !img.height) return;
|
||||
|
||||
// Calculate canvas dimensions to fit in viewport
|
||||
const maxWidth = window.innerWidth - 200;
|
||||
const maxHeight = window.innerHeight - 300;
|
||||
|
||||
let scale = 1;
|
||||
if (img.width > maxWidth || img.height > maxHeight) {
|
||||
const scaleX = maxWidth / img.width;
|
||||
const scaleY = maxHeight / img.height;
|
||||
scale = Math.min(scaleX, scaleY);
|
||||
}
|
||||
|
||||
const canvasWidth = img.width * scale;
|
||||
const canvasHeight = img.height * scale;
|
||||
|
||||
// Stocker les dimensions originales pour l'export
|
||||
originalImageSizeRef.current = {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
scale: scale,
|
||||
};
|
||||
|
||||
canvas.setDimensions({ width: canvasWidth, height: canvasHeight });
|
||||
|
||||
// Configurer l'image de fond avec scale et centrage
|
||||
img.set({
|
||||
scaleX: scale,
|
||||
scaleY: scale,
|
||||
left: 0,
|
||||
top: 0,
|
||||
originX: "left",
|
||||
originY: "top",
|
||||
opacity: 0.6,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
});
|
||||
|
||||
canvas.backgroundImage = img;
|
||||
backgroundImageRef.current = img;
|
||||
canvas.renderAll();
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
// Retirer les event listeners touch
|
||||
if (upperCanvas) {
|
||||
upperCanvas.removeEventListener("touchstart", preventScroll);
|
||||
upperCanvas.removeEventListener("touchmove", preventScroll);
|
||||
}
|
||||
canvas.dispose();
|
||||
};
|
||||
}, [imageUrl]);
|
||||
|
||||
// Nettoyer tous les event handlers
|
||||
const clearAllEventHandlers = () => {
|
||||
const canvas = fabricCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
canvas.off("mouse:down");
|
||||
canvas.off("mouse:move");
|
||||
canvas.off("mouse:up");
|
||||
canvas.isDrawingMode = false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = fabricCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
// Nettoyer tous les handlers avant de configurer le nouvel outil
|
||||
clearAllEventHandlers();
|
||||
|
||||
// Réinitialiser l'état du polygone si on change d'outil
|
||||
if (selectedTool !== "polygon") {
|
||||
polygonCirclesRef.current.forEach((circle) => canvas.remove(circle));
|
||||
polygonCirclesRef.current = [];
|
||||
polygonPointsRef.current = [];
|
||||
setIsDrawingPolygon(false);
|
||||
}
|
||||
|
||||
// Configuration selon l'outil sélectionné
|
||||
if (selectedTool === "brush") {
|
||||
setupBrushTool();
|
||||
} else if (selectedTool === "eraser") {
|
||||
setupEraserTool();
|
||||
} else if (selectedTool === "polygon") {
|
||||
canvas.isDrawingMode = false;
|
||||
setIsDrawingPolygon(true);
|
||||
setupPolygonTool();
|
||||
} else if (selectedTool === "pan") {
|
||||
canvas.isDrawingMode = false;
|
||||
setupPanTool();
|
||||
}
|
||||
}, [selectedTool, brushSize]);
|
||||
|
||||
// Gestion du zoom
|
||||
const handleZoom = (delta: number) => {
|
||||
const canvas = fabricCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const newZoom = Math.min(Math.max(zoomLevel + delta, 0.5), 3);
|
||||
setZoomLevel(newZoom);
|
||||
canvas.setZoom(newZoom);
|
||||
canvas.renderAll();
|
||||
};
|
||||
|
||||
const setupPanTool = () => {
|
||||
const canvas = fabricCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
// Helper pour obtenir les coordonnées (souris ou touch)
|
||||
const getClientCoords = (e: MouseEvent | TouchEvent) => {
|
||||
if ("touches" in e && e.touches.length > 0) {
|
||||
return { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||
}
|
||||
if ("changedTouches" in e && e.changedTouches.length > 0) {
|
||||
return {
|
||||
x: e.changedTouches[0].clientX,
|
||||
y: e.changedTouches[0].clientY,
|
||||
};
|
||||
}
|
||||
return { x: (e as MouseEvent).clientX, y: (e as MouseEvent).clientY };
|
||||
};
|
||||
|
||||
canvas.on("mouse:down", (opt) => {
|
||||
const coords = getClientCoords(opt.e as MouseEvent | TouchEvent);
|
||||
panningRef.current.isDragging = true;
|
||||
panningRef.current.lastX = coords.x;
|
||||
panningRef.current.lastY = coords.y;
|
||||
});
|
||||
|
||||
canvas.on("mouse:move", (opt) => {
|
||||
if (!panningRef.current.isDragging) return;
|
||||
|
||||
const coords = getClientCoords(opt.e as MouseEvent | TouchEvent);
|
||||
const vpt = canvas.viewportTransform;
|
||||
if (!vpt) return;
|
||||
|
||||
const deltaX = coords.x - panningRef.current.lastX;
|
||||
const deltaY = coords.y - panningRef.current.lastY;
|
||||
|
||||
vpt[4] += deltaX;
|
||||
vpt[5] += deltaY;
|
||||
|
||||
panningRef.current.lastX = coords.x;
|
||||
panningRef.current.lastY = coords.y;
|
||||
|
||||
canvas.requestRenderAll();
|
||||
});
|
||||
|
||||
canvas.on("mouse:up", () => {
|
||||
panningRef.current.isDragging = false;
|
||||
});
|
||||
};
|
||||
|
||||
const saveHistory = () => {
|
||||
const canvas = fabricCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const json = JSON.stringify(canvas.toJSON());
|
||||
setHistory((prev) => [...prev, json]);
|
||||
};
|
||||
|
||||
const setupBrushTool = () => {
|
||||
const canvas = fabricCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
canvas.isDrawingMode = true;
|
||||
canvas.freeDrawingBrush = new fabric.PencilBrush(canvas);
|
||||
canvas.freeDrawingBrush.width = brushSize;
|
||||
canvas.freeDrawingBrush.color = "rgba(255, 255, 255, 0.7)";
|
||||
|
||||
// Sauvegarder dans l'historique après chaque dessin
|
||||
canvas.on("path:created", () => {
|
||||
saveHistory();
|
||||
});
|
||||
};
|
||||
|
||||
const setupEraserTool = () => {
|
||||
const canvas = fabricCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
canvas.isDrawingMode = false;
|
||||
canvas.selection = false;
|
||||
|
||||
canvas.on("mouse:down", (opt) => {
|
||||
const pointer = canvas.getScenePoint(opt.e);
|
||||
const objects = canvas.getObjects();
|
||||
|
||||
// Trouver et supprimer les objets blancs sous le curseur
|
||||
for (let i = objects.length - 1; i >= 0; i--) {
|
||||
const obj = objects[i];
|
||||
if (obj.containsPoint(pointer)) {
|
||||
canvas.remove(obj);
|
||||
}
|
||||
}
|
||||
canvas.renderAll();
|
||||
saveHistory();
|
||||
});
|
||||
|
||||
canvas.on("mouse:move", (opt) => {
|
||||
const e = opt.e as MouseEvent;
|
||||
if (e.buttons !== 1) return; // Seulement si bouton souris pressé
|
||||
|
||||
const pointer = canvas.getScenePoint(opt.e);
|
||||
const objects = canvas.getObjects();
|
||||
|
||||
for (let i = objects.length - 1; i >= 0; i--) {
|
||||
const obj = objects[i];
|
||||
if (obj.containsPoint(pointer)) {
|
||||
canvas.remove(obj);
|
||||
}
|
||||
}
|
||||
canvas.renderAll();
|
||||
});
|
||||
|
||||
canvas.on("mouse:up", () => {
|
||||
saveHistory();
|
||||
});
|
||||
};
|
||||
|
||||
const setupPolygonTool = () => {
|
||||
const canvas = fabricCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
let points: { x: number; y: number }[] = [];
|
||||
let circles: fabric.Circle[] = [];
|
||||
|
||||
canvas.on("mouse:down", (opt) => {
|
||||
const pointer = canvas.getScenePoint(opt.e);
|
||||
|
||||
points.push({ x: pointer.x, y: pointer.y });
|
||||
|
||||
// Ajouter un cercle indicateur
|
||||
const circle = new fabric.Circle({
|
||||
radius: 5,
|
||||
fill: "#00FF00",
|
||||
left: pointer.x - 5,
|
||||
top: pointer.y - 5,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
});
|
||||
|
||||
canvas.add(circle);
|
||||
circles.push(circle);
|
||||
|
||||
// Sauvegarder dans les refs pour finishPolygon
|
||||
polygonPointsRef.current = points;
|
||||
polygonCirclesRef.current = circles;
|
||||
|
||||
canvas.renderAll();
|
||||
});
|
||||
};
|
||||
|
||||
const finishPolygon = () => {
|
||||
const canvas = fabricCanvasRef.current;
|
||||
if (!canvas || polygonPointsRef.current.length < 3) {
|
||||
alert("Vous devez placer au moins 3 points pour créer un polygone");
|
||||
return;
|
||||
}
|
||||
|
||||
// Créer le polygone rempli
|
||||
const polygon = new fabric.Polygon(polygonPointsRef.current, {
|
||||
fill: "#FFFFFF",
|
||||
opacity: 0.7,
|
||||
stroke: "#FFFFFF",
|
||||
strokeWidth: 2,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
});
|
||||
|
||||
// Retirer les cercles indicateurs
|
||||
polygonCirclesRef.current.forEach((circle) => canvas.remove(circle));
|
||||
polygonCirclesRef.current = [];
|
||||
|
||||
canvas.add(polygon);
|
||||
canvas.renderAll();
|
||||
|
||||
// Réinitialiser
|
||||
polygonPointsRef.current = [];
|
||||
setIsDrawingPolygon(false);
|
||||
|
||||
// Sauvegarder dans l'historique
|
||||
saveHistory();
|
||||
|
||||
// Retourner au mode pinceau
|
||||
setSelectedTool("brush");
|
||||
};
|
||||
|
||||
const clearCanvas = () => {
|
||||
const canvas = fabricCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
// Retirer tous les objets dessinés (mais garder l'image de fond)
|
||||
const objects = canvas.getObjects().slice(); // Copie pour éviter les problèmes d'itération
|
||||
objects.forEach((obj) => canvas.remove(obj));
|
||||
|
||||
canvas.renderAll();
|
||||
saveHistory();
|
||||
};
|
||||
|
||||
const undo = () => {
|
||||
if (history.length === 0) return;
|
||||
|
||||
const canvas = fabricCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
// Retirer le dernier état
|
||||
const newHistory = [...history];
|
||||
newHistory.pop();
|
||||
setHistory(newHistory);
|
||||
|
||||
// Charger l'état précédent
|
||||
if (newHistory.length > 0) {
|
||||
const previousState = newHistory[newHistory.length - 1];
|
||||
canvas.loadFromJSON(previousState, () => {
|
||||
canvas.renderAll();
|
||||
});
|
||||
} else {
|
||||
// Si plus d'historique, tout effacer
|
||||
const objects = canvas.getObjects().slice();
|
||||
objects.forEach((obj) => canvas.remove(obj));
|
||||
canvas.renderAll();
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour normaliser le masque en noir/blanc pur (pas de gris)
|
||||
const normalizeMask = (dataUrl: string): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = img.width;
|
||||
tempCanvas.height = img.height;
|
||||
const ctx = tempCanvas.getContext("2d")!;
|
||||
|
||||
// Dessiner l'image
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// Récupérer les pixels
|
||||
const imageData = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
tempCanvas.width,
|
||||
tempCanvas.height
|
||||
);
|
||||
const data = imageData.data;
|
||||
|
||||
// Convertir chaque pixel en noir ou blanc pur
|
||||
// Si la luminosité > 10, c'est blanc, sinon noir
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const luminosity = (r + g + b) / 3;
|
||||
|
||||
if (luminosity > 10) {
|
||||
// Blanc pur
|
||||
data[i] = 255;
|
||||
data[i + 1] = 255;
|
||||
data[i + 2] = 255;
|
||||
data[i + 3] = 255;
|
||||
} else {
|
||||
// Noir pur
|
||||
data[i] = 0;
|
||||
data[i + 1] = 0;
|
||||
data[i + 2] = 0;
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
resolve(tempCanvas.toDataURL("image/png"));
|
||||
};
|
||||
img.src = dataUrl;
|
||||
});
|
||||
};
|
||||
|
||||
const downloadDebugMask = async () => {
|
||||
const canvas = fabricCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
// Sauvegarder l'état actuel
|
||||
const bgImage = canvas.backgroundImage;
|
||||
const currentZoom = canvas.getZoom();
|
||||
const currentVpt = canvas.viewportTransform?.slice();
|
||||
|
||||
// Réinitialiser le zoom et la position pour l'export
|
||||
canvas.setZoom(1);
|
||||
canvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
|
||||
|
||||
// Retirer temporairement l'image de fond pour exporter seulement le masque
|
||||
canvas.backgroundImage = undefined;
|
||||
canvas.renderAll();
|
||||
|
||||
// Calculer le multiplier pour exporter à la taille originale de l'image
|
||||
const { scale } = originalImageSizeRef.current;
|
||||
const exportMultiplier = scale > 0 ? 1 / scale : 1;
|
||||
|
||||
// Exporter le masque brut à la taille originale de l'image
|
||||
const rawMaskDataUrl = canvas.toDataURL({
|
||||
format: "png",
|
||||
quality: 1,
|
||||
multiplier: exportMultiplier,
|
||||
});
|
||||
|
||||
// Restaurer l'image de fond, le zoom et la position
|
||||
canvas.backgroundImage = bgImage;
|
||||
canvas.setZoom(currentZoom);
|
||||
if (currentVpt && currentVpt.length === 6) {
|
||||
canvas.setViewportTransform(
|
||||
currentVpt as [number, number, number, number, number, number]
|
||||
);
|
||||
}
|
||||
canvas.renderAll();
|
||||
|
||||
// Normaliser le masque en noir/blanc pur
|
||||
const maskDataUrl = await normalizeMask(rawMaskDataUrl);
|
||||
|
||||
// Télécharger le masque
|
||||
const link = document.createElement("a");
|
||||
link.download = "mask-debug.png";
|
||||
link.href = maskDataUrl;
|
||||
link.click();
|
||||
};
|
||||
|
||||
const downloadMaskedPreview = async () => {
|
||||
const canvas = fabricCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
// Sauvegarder l'état actuel
|
||||
const bgImage = canvas.backgroundImage;
|
||||
const currentZoom = canvas.getZoom();
|
||||
const currentVpt = canvas.viewportTransform?.slice();
|
||||
|
||||
// Réinitialiser le zoom et la position pour l'export
|
||||
canvas.setZoom(1);
|
||||
canvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
|
||||
|
||||
// Retirer temporairement l'image de fond pour exporter seulement le masque
|
||||
canvas.backgroundImage = undefined;
|
||||
canvas.renderAll();
|
||||
|
||||
// Calculer le multiplier pour exporter à la taille originale de l'image
|
||||
const { scale } = originalImageSizeRef.current;
|
||||
const exportMultiplier = scale > 0 ? 1 / scale : 1;
|
||||
|
||||
// Exporter le masque (blanc sur noir) à la taille originale de l'image
|
||||
const rawMaskDataUrl = canvas.toDataURL({
|
||||
format: "png",
|
||||
quality: 1,
|
||||
multiplier: exportMultiplier,
|
||||
});
|
||||
|
||||
// Restaurer l'image de fond, le zoom et la position
|
||||
canvas.backgroundImage = bgImage;
|
||||
canvas.setZoom(currentZoom);
|
||||
if (currentVpt && currentVpt.length === 6) {
|
||||
canvas.setViewportTransform(
|
||||
currentVpt as [number, number, number, number, number, number]
|
||||
);
|
||||
}
|
||||
canvas.renderAll();
|
||||
|
||||
// Normaliser le masque en noir/blanc pur
|
||||
const maskDataUrl = await normalizeMask(rawMaskDataUrl);
|
||||
|
||||
// Appeler l'API pour appliquer le masque
|
||||
try {
|
||||
// Convertir le dataURL du masque en blob
|
||||
const maskBlob = await fetch(maskDataUrl).then((r) => r.blob());
|
||||
|
||||
// Récupérer l'image originale
|
||||
const imageBlob = await fetch(imageUrl).then((r) => r.blob());
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("image", imageBlob, "image.png");
|
||||
formData.append("mask", maskBlob, "mask.png");
|
||||
|
||||
const response = await fetch("/api/apply-mask", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Erreur API");
|
||||
}
|
||||
|
||||
const resultBlob = await response.blob();
|
||||
const resultUrl = URL.createObjectURL(resultBlob);
|
||||
|
||||
// Télécharger le résultat
|
||||
const link = document.createElement("a");
|
||||
link.download = "masked-preview.png";
|
||||
link.href = resultUrl;
|
||||
link.click();
|
||||
|
||||
URL.revokeObjectURL(resultUrl);
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de l'application du masque:", error);
|
||||
alert("Erreur lors de l'application du masque");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const canvas = fabricCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
// Sauvegarder l'état actuel
|
||||
const bgImage = canvas.backgroundImage;
|
||||
const currentZoom = canvas.getZoom();
|
||||
const currentVpt = canvas.viewportTransform?.slice();
|
||||
|
||||
// Réinitialiser le zoom et la position pour l'export
|
||||
canvas.setZoom(1);
|
||||
canvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
|
||||
|
||||
// Retirer temporairement l'image de fond pour exporter seulement le masque
|
||||
canvas.backgroundImage = undefined;
|
||||
canvas.renderAll();
|
||||
|
||||
// Calculer le multiplier pour exporter à la taille originale de l'image
|
||||
const { scale } = originalImageSizeRef.current;
|
||||
const exportMultiplier = scale > 0 ? 1 / scale : 1;
|
||||
|
||||
// Exporter le masque brut à la taille originale de l'image
|
||||
const rawMaskDataUrl = canvas.toDataURL({
|
||||
format: "png",
|
||||
quality: 1,
|
||||
multiplier: exportMultiplier,
|
||||
});
|
||||
|
||||
// Restaurer l'image de fond, le zoom et la position
|
||||
canvas.backgroundImage = bgImage;
|
||||
canvas.setZoom(currentZoom);
|
||||
if (currentVpt && currentVpt.length === 6) {
|
||||
canvas.setViewportTransform(
|
||||
currentVpt as [number, number, number, number, number, number]
|
||||
);
|
||||
}
|
||||
canvas.renderAll();
|
||||
|
||||
// Normaliser le masque en noir/blanc pur avant de sauvegarder
|
||||
const normalizedMaskDataUrl = await normalizeMask(rawMaskDataUrl);
|
||||
onSave(normalizedMaskDataUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-90 flex items-center justify-center p-2 sm:p-4">
|
||||
<div className="bg-white rounded-lg shadow-2xl p-4 sm:p-6 max-w-[98vw] sm:max-w-[95vw] w-full max-h-[98vh] sm:max-h-[95vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg sm:text-2xl font-bold text-gray-900">
|
||||
Délimiter la zone
|
||||
</h2>
|
||||
<p className="text-xs sm:text-sm text-gray-600 mt-1">
|
||||
Dessinez en blanc la zone à modifier
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col gap-3 mb-4 p-3 sm:p-4 bg-gray-100 rounded-lg">
|
||||
{/* Ligne 1: Outils principaux */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedTool("brush")}
|
||||
className={`p-2 sm:px-3 sm:py-2 rounded-lg font-medium transition-colors text-sm flex items-center gap-1.5 ${
|
||||
selectedTool === "brush"
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-white text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
title="Pinceau"
|
||||
>
|
||||
<Paintbrush className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Pinceau</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedTool("polygon")}
|
||||
className={`p-2 sm:px-3 sm:py-2 rounded-lg font-medium transition-colors text-sm flex items-center gap-1.5 ${
|
||||
selectedTool === "polygon"
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-white text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
title="Polygone"
|
||||
>
|
||||
<Pentagon className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Polygone</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedTool("eraser")}
|
||||
className={`p-2 sm:px-3 sm:py-2 rounded-lg font-medium transition-colors text-sm flex items-center gap-1.5 ${
|
||||
selectedTool === "eraser"
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-white text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
title="Gomme - Effacer les zones blanches"
|
||||
>
|
||||
<Eraser className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Gomme</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedTool("pan")}
|
||||
className={`p-2 sm:px-3 sm:py-2 rounded-lg font-medium transition-colors text-sm flex items-center gap-1.5 ${
|
||||
selectedTool === "pan"
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-white text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
title="Déplacer la vue"
|
||||
>
|
||||
<Hand className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Déplacer</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<button
|
||||
onClick={undo}
|
||||
disabled={history.length === 0}
|
||||
className="p-2 sm:px-3 sm:py-2 bg-white text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
title="Annuler"
|
||||
>
|
||||
<Undo2 className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Annuler</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={clearCanvas}
|
||||
className="p-2 sm:px-3 sm:py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors text-sm flex items-center gap-1.5"
|
||||
title="Effacer tout"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Effacer</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ligne 2: Contrôles secondaires */}
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
{/* Zoom */}
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<span className="text-xs sm:text-sm font-medium text-gray-700 hidden sm:inline">
|
||||
Zoom :
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleZoom(-0.25)}
|
||||
className="p-2 bg-white text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors text-sm disabled:opacity-50"
|
||||
disabled={zoomLevel <= 0.5}
|
||||
title="Dézoomer"
|
||||
>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-xs sm:text-sm text-gray-600 min-w-[3rem] text-center">
|
||||
{Math.round(zoomLevel * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleZoom(0.25)}
|
||||
className="p-2 bg-white text-gray-700 rounded-lg font-medium hover:bg-gray-200 transition-colors text-sm disabled:opacity-50"
|
||||
disabled={zoomLevel >= 3}
|
||||
title="Zoomer"
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Taille du pinceau */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs sm:text-sm font-medium text-gray-700">
|
||||
Taille :
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="100"
|
||||
value={brushSize}
|
||||
onChange={(e) => setBrushSize(parseInt(e.target.value))}
|
||||
className="w-20 sm:w-32"
|
||||
disabled={selectedTool === "polygon" || selectedTool === "pan"}
|
||||
/>
|
||||
<span className="text-xs sm:text-sm text-gray-600 min-w-[2.5rem]">
|
||||
{brushSize}px
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Terminer polygone - affiché seulement si en mode polygone */}
|
||||
{isDrawingPolygon && (
|
||||
<button
|
||||
onClick={finishPolygon}
|
||||
className="px-3 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 transition-colors text-sm flex items-center gap-1.5"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
<span>Terminer le polygone</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas Container */}
|
||||
<div className="flex items-center justify-center bg-gray-900 rounded-lg p-2 sm:p-4 mb-4 overflow-auto max-h-[50vh] sm:max-h-[60vh]">
|
||||
<canvas ref={canvasRef} className="border border-gray-700 rounded" />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 sm:flex-initial px-4 sm:px-6 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<span>Annuler</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex-1 sm:flex-initial px-4 sm:px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
<span>Valider</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/components/ZoneMaskEditorWrapper.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import ZoneMaskEditor from './ZoneMaskEditor';
|
||||
|
||||
interface MaskEditorOpenEvent {
|
||||
imageUrl: string;
|
||||
referenceImage?: string;
|
||||
}
|
||||
|
||||
export default function ZoneMaskEditorWrapper() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [imageUrl, setImageUrl] = useState('');
|
||||
const [referenceImage, setReferenceImage] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpen = (event: CustomEvent<MaskEditorOpenEvent>) => {
|
||||
setImageUrl(event.detail.imageUrl);
|
||||
setReferenceImage(event.detail.referenceImage);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
window.addEventListener('openMaskEditor', handleOpen as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('openMaskEditor', handleOpen as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSave = (maskDataUrl: string) => {
|
||||
window.dispatchEvent(new CustomEvent('maskEditorSave', { detail: { maskDataUrl } }));
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
window.dispatchEvent(new CustomEvent('maskEditorCancel'));
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<ZoneMaskEditor
|
||||
imageUrl={imageUrl}
|
||||
referenceImage={referenceImage}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
BIN
src/img/after-1.jpg
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
src/img/after.png
Normal file
|
After Width: | Height: | Size: 5.7 MiB |
BIN
src/img/before-1.jpg
Normal file
|
After Width: | Height: | Size: 671 KiB |
BIN
src/img/before.png
Normal file
|
After Width: | Height: | Size: 4.6 MiB |
BIN
src/img/black-parquet.png
Normal file
|
After Width: | Height: | Size: 481 KiB |
BIN
src/img/carrelage.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
src/img/coffe-table.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
src/img/kitchen.png
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
BIN
src/img/table.png
Normal file
|
After Width: | Height: | Size: 662 KiB |
484
src/layouts/Layout.astro
Normal file
@@ -0,0 +1,484 @@
|
||||
---
|
||||
import { SignedIn, SignedOut } from "@clerk/astro/components";
|
||||
|
||||
import "../styles/global.css";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
keywords?: string;
|
||||
disableStickyHeader?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
title = "PrimaDopu - Visualisation Avant/Après par IA",
|
||||
description = "Créez des visuels avant/après pro en 3 min. Rénovation, home staging, déco. L'outil IA pour architectes et décorateurs.",
|
||||
keywords = "primadopu, prima dopu, visualisation avant après, home staging virtuel, IA décoration intérieur, outil architecte IA, rénovation virtuelle, simulation aménagement, transformation intérieur IA, projet rénovation maison, décoration pièce IA, avant après travaux, visite virtuelle immobilier, staging immobilier, design intérieur IA, aménagement espace, relooking maison virtuel, visualisation projet rénovation, outil décorateur professionnel, simulation travaux maison, home design IA France",
|
||||
disableStickyHeader = false,
|
||||
} = Astro.props;
|
||||
const currentPath = Astro.url.pathname;
|
||||
const siteUrl = Astro.site || "https://primadopu.fr";
|
||||
const canonicalURL = new URL(Astro.url.pathname, siteUrl);
|
||||
const ogImageURL = new URL("/og-image.png", siteUrl).toString();
|
||||
const logoURL = new URL("/logo-primadopu-texte.png", siteUrl).toString();
|
||||
|
||||
// Récupérer l'utilisateur Clerk pour la photo de profil
|
||||
const user = await Astro.locals.currentUser();
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="PrimaDopu" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
<!-- SEO Meta Tags -->
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta name="keywords" content={keywords} />
|
||||
<meta name="author" content="PrimaDopu" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<link rel="canonical" href={canonicalURL} />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={canonicalURL} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={ogImageURL} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta
|
||||
property="og:image:alt"
|
||||
content="PrimaDopu - Visualisation Avant/Après par IA"
|
||||
/>
|
||||
<meta property="og:locale" content="fr_FR" />
|
||||
<meta property="og:site_name" content="PrimaDopu" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content={canonicalURL} />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={ogImageURL} />
|
||||
<meta
|
||||
name="twitter:image:alt"
|
||||
content="PrimaDopu - Visualisation Avant/Après par IA"
|
||||
/>
|
||||
|
||||
<!-- Structured Data - SoftwareApplication -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "PrimaDopu",
|
||||
"description": "Outil de visualisation avant/après par IA pour professionnels de l'aménagement, architectes, décorateurs et agences immobilières",
|
||||
"applicationCategory": "DesignApplication",
|
||||
"operatingSystem": "Web",
|
||||
"url": "https://primadopu.fr",
|
||||
"image": "https://primadopu.fr/og-image.png",
|
||||
"screenshot": "https://primadopu.fr/og-image.png",
|
||||
"softwareVersion": "1.0",
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": "PrimaDopu",
|
||||
"url": "https://primadopu.fr"
|
||||
},
|
||||
"offers": {
|
||||
"@type": "AggregateOffer",
|
||||
"lowPrice": "0",
|
||||
"highPrice": "49.99",
|
||||
"priceCurrency": "EUR",
|
||||
"offerCount": "3"
|
||||
},
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "4.8",
|
||||
"ratingCount": "150",
|
||||
"bestRating": "5",
|
||||
"worstRating": "1"
|
||||
},
|
||||
"featureList": [
|
||||
"Visualisation avant/après par IA",
|
||||
"Home staging virtuel",
|
||||
"Génération en moins de 3 minutes",
|
||||
"Interface intuitive",
|
||||
"Export haute résolution"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Structured Data - Organization -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "PrimaDopu",
|
||||
"url": "https://primadopu.fr",
|
||||
"logo": "https://primadopu.fr/logo-primadopu.png",
|
||||
"description": "Solution française de visualisation avant/après par intelligence artificielle pour les professionnels de l'immobilier et de la décoration",
|
||||
"foundingDate": "2024",
|
||||
"areaServed": {
|
||||
"@type": "Country",
|
||||
"name": "France"
|
||||
},
|
||||
"sameAs": [
|
||||
"https://www.instagram.com/primadopu/",
|
||||
"https://www.facebook.com/profile.php?id=61586567542846"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Structured Data - WebSite with SearchAction -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "PrimaDopu",
|
||||
"url": "https://primadopu.fr",
|
||||
"description": "Créez des visuels avant/après professionnels avec l'IA",
|
||||
"inLanguage": "fr-FR"
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Structured Data - FAQ pour résultats enrichis -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Qu'est-ce que PrimaDopu ?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "PrimaDopu est un outil de visualisation avant/après par intelligence artificielle. Il permet aux professionnels (architectes, décorateurs, agences immobilières) de créer des visuels photoréalistes de projets de rénovation en moins de 3 minutes."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Comment fonctionne PrimaDopu ?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Téléchargez une photo de l'espace à transformer, décrivez les modifications souhaitées (changement de sol, mobilier, couleurs...), et l'IA génère automatiquement un visuel avant/après photoréaliste."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "PrimaDopu est-il gratuit ?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "PrimaDopu propose un essai gratuit pour tester le service. Des abonnements Standard et Pro sont disponibles pour une utilisation professionnelle intensive, sans engagement."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Quels types de transformations puis-je réaliser ?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "PrimaDopu permet de visualiser tous types de transformations : changement de sol (carrelage, parquet), peinture, mobilier, décoration, luminaires, paysagisme, home staging virtuel, et bien plus encore."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Combien de temps faut-il pour générer une image ?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "La génération d'un visuel avant/après prend moins de 3 minutes. L'IA analyse votre photo et vos instructions pour créer un résultat photoréaliste rapidement."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Hreflang pour SEO international -->
|
||||
<link rel="alternate" hreflang="fr" href={canonicalURL} />
|
||||
<link rel="alternate" hreflang="x-default" href={canonicalURL} />
|
||||
|
||||
<!-- Additional SEO Meta Tags -->
|
||||
<meta name="language" content="fr" />
|
||||
<meta name="revisit-after" content="7 days" />
|
||||
<meta name="rating" content="general" />
|
||||
<meta name="distribution" content="global" />
|
||||
<meta name="geo.region" content="FR" />
|
||||
<meta name="geo.placename" content="France" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||
|
||||
<!-- Preconnect for performance -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
<!-- DNS Prefetch -->
|
||||
<link rel="dns-prefetch" href="https://www.google-analytics.com" />
|
||||
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />
|
||||
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<!-- Theme Color -->
|
||||
<meta name="theme-color" content="#1e3a5f" />
|
||||
|
||||
<!-- Google Tag Manager -->
|
||||
<script>
|
||||
(function (w, d, s, l, i) {
|
||||
w[l] = w[l] || [];
|
||||
w[l].push({ "gtm.start": new Date().getTime(), event: "gtm.js" });
|
||||
var f = d.getElementsByTagName(s)[0],
|
||||
j = d.createElement(s),
|
||||
dl = l != "dataLayer" ? "&l=" + l : "";
|
||||
j.async = true;
|
||||
j.src = "https://www.googletagmanager.com/gtm.js?id=" + i + dl;
|
||||
f.parentNode.insertBefore(j, f);
|
||||
})(window, document, "script", "dataLayer", "GTM-TSGKWJRP");
|
||||
</script>
|
||||
<!-- End Google Tag Manager -->
|
||||
|
||||
<!-- Facebook Pixel -->
|
||||
<script>
|
||||
!(function (f, b, e, v, n, t, s) {
|
||||
if (f.fbq) return;
|
||||
n = f.fbq = function () {
|
||||
n.callMethod
|
||||
? n.callMethod.apply(n, arguments)
|
||||
: n.queue.push(arguments);
|
||||
};
|
||||
if (!f._fbq) f._fbq = n;
|
||||
n.push = n;
|
||||
n.loaded = !0;
|
||||
n.version = "2.0";
|
||||
n.queue = [];
|
||||
t = b.createElement(e);
|
||||
t.async = !0;
|
||||
t.src = v;
|
||||
s = b.getElementsByTagName(e)[0];
|
||||
s.parentNode.insertBefore(t, s);
|
||||
})(
|
||||
window,
|
||||
document,
|
||||
"script",
|
||||
"https://connect.facebook.net/en_US/fbevents.js"
|
||||
);
|
||||
fbq("init", "VOTRE_PIXEL_ID");
|
||||
fbq("track", "PageView");
|
||||
</script>
|
||||
<noscript
|
||||
><img
|
||||
height="1"
|
||||
width="1"
|
||||
style="display:none"
|
||||
src="https://www.facebook.com/tr?id=VOTRE_PIXEL_ID&ev=PageView&noscript=1"
|
||||
/></noscript
|
||||
>
|
||||
<!-- End Facebook Pixel -->
|
||||
</head>
|
||||
<body class="min-h-screen bg-white">
|
||||
<!-- Google Tag Manager (noscript) -->
|
||||
<noscript
|
||||
><iframe
|
||||
src="https://www.googletagmanager.com/ns.html?id=GTM-TSGKWJRP"
|
||||
height="0"
|
||||
width="0"
|
||||
style="display:none;visibility:hidden"></iframe></noscript
|
||||
>
|
||||
<!-- End Google Tag Manager (noscript) -->
|
||||
<header
|
||||
class:list={[
|
||||
"bg-white/80 backdrop-blur-md shadow-sm border-b border-slate-100",
|
||||
disableStickyHeader ? "relative" : "sticky top-0 z-40",
|
||||
]}
|
||||
>
|
||||
<div class="max-w-7xl mx-auto p-4 px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex items-center gap-8">
|
||||
<a href="/" class="flex items-center">
|
||||
<img
|
||||
src="/logo-primadopu.png"
|
||||
alt="PrimaDopu"
|
||||
class="h-10 md:h-16"
|
||||
/>
|
||||
<span class="text-xl md:text-4xl font-bold">
|
||||
<span class="text-blue-950">Prima</span><span
|
||||
class="text-orange-600">Dopu</span
|
||||
>
|
||||
</span>
|
||||
</a>
|
||||
<SignedIn>
|
||||
<nav class="hidden md:flex items-center gap-1">
|
||||
<a
|
||||
href="/generate"
|
||||
class:list={[
|
||||
"px-4 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
currentPath === "/generate"
|
||||
? "bg-orange-100 text-orange-700"
|
||||
: "text-slate-600 hover:bg-slate-100",
|
||||
]}
|
||||
>
|
||||
🎨 Générer
|
||||
</a>
|
||||
<a
|
||||
href="/library"
|
||||
class:list={[
|
||||
"px-4 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
currentPath === "/library"
|
||||
? "bg-orange-100 text-orange-700"
|
||||
: "text-slate-600 hover:bg-slate-100",
|
||||
]}
|
||||
>
|
||||
📚 Bibliothèque
|
||||
</a>
|
||||
<a
|
||||
href="/history"
|
||||
class:list={[
|
||||
"px-4 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
currentPath === "/history"
|
||||
? "bg-orange-100 text-orange-700"
|
||||
: "text-slate-600 hover:bg-slate-100",
|
||||
]}
|
||||
>
|
||||
📜 Historique
|
||||
</a>
|
||||
<a
|
||||
href="/pricing"
|
||||
class:list={[
|
||||
"px-4 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
currentPath === "/pricing"
|
||||
? "bg-orange-100 text-orange-700"
|
||||
: "text-slate-600 hover:bg-slate-100",
|
||||
]}
|
||||
>
|
||||
💳 Tarifs
|
||||
</a>
|
||||
<a
|
||||
href="/profile"
|
||||
class:list={[
|
||||
"px-4 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
currentPath === "/profile"
|
||||
? "bg-orange-100 text-orange-700"
|
||||
: "text-slate-600 hover:bg-slate-100",
|
||||
]}
|
||||
>
|
||||
👤 Profil
|
||||
</a>
|
||||
</nav>
|
||||
</SignedIn>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<SignedOut>
|
||||
<a
|
||||
href="/sign-in"
|
||||
class="px-5 py-2.5 bg-[#e85d04] text-white rounded-lg text-sm font-medium hover:bg-[#d35400] transition-colors cursor-pointer shadow-sm"
|
||||
>
|
||||
Se connecter
|
||||
</a>
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<a
|
||||
href="/profile"
|
||||
class="w-10 h-10 rounded-full overflow-hidden border-2 border-indigo-200 hover:border-indigo-400 transition-all shadow-sm"
|
||||
title="Mon profil"
|
||||
>
|
||||
{
|
||||
user?.imageUrl ? (
|
||||
<img
|
||||
src={user.imageUrl}
|
||||
alt="Photo de profil"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div class="w-full h-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-white font-medium">
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</a>
|
||||
</SignedIn>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile nav -->
|
||||
<SignedIn>
|
||||
<nav class="md:hidden flex items-center gap-1 pb-3 overflow-x-auto">
|
||||
<a
|
||||
href="/generate"
|
||||
class:list={[
|
||||
"px-3 py-1.5 rounded-lg text-xs font-medium transition-colors whitespace-nowrap",
|
||||
currentPath === "/generate"
|
||||
? "bg-orange-100 text-orange-700"
|
||||
: "text-slate-600 hover:bg-slate-100",
|
||||
]}
|
||||
>
|
||||
🎨 Générer
|
||||
</a>
|
||||
<a
|
||||
href="/library"
|
||||
class:list={[
|
||||
"px-3 py-1.5 rounded-lg text-xs font-medium transition-colors whitespace-nowrap",
|
||||
currentPath === "/library"
|
||||
? "bg-orange-100 text-orange-700"
|
||||
: "text-slate-600 hover:bg-slate-100",
|
||||
]}
|
||||
>
|
||||
📚 Bibliothèque
|
||||
</a>
|
||||
<a
|
||||
href="/history"
|
||||
class:list={[
|
||||
"px-3 py-1.5 rounded-lg text-xs font-medium transition-colors whitespace-nowrap",
|
||||
currentPath === "/history"
|
||||
? "bg-orange-100 text-orange-700"
|
||||
: "text-slate-600 hover:bg-slate-100",
|
||||
]}
|
||||
>
|
||||
📜 Historique
|
||||
</a>
|
||||
<a
|
||||
href="/pricing"
|
||||
class:list={[
|
||||
"px-3 py-1.5 rounded-lg text-xs font-medium transition-colors whitespace-nowrap",
|
||||
currentPath === "/pricing"
|
||||
? "bg-orange-100 text-orange-700"
|
||||
: "text-slate-600 hover:bg-slate-100",
|
||||
]}
|
||||
>
|
||||
💳 Tarifs
|
||||
</a>
|
||||
<!-- <a
|
||||
href="/profile"
|
||||
class:list={[
|
||||
"px-3 py-1.5 rounded-lg text-xs font-medium transition-colors whitespace-nowrap",
|
||||
currentPath === "/profile"
|
||||
? "bg-orange-100 text-orange-700"
|
||||
: "text-slate-600 hover:bg-slate-100",
|
||||
]}
|
||||
>
|
||||
👤 Profil
|
||||
</a> -->
|
||||
</nav>
|
||||
</SignedIn>
|
||||
</div>
|
||||
</header>
|
||||
<main class="max-w-7xl mx-auto p-4 px-4 sm:px-6 lg:px-8 py-8">
|
||||
<slot />
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
749
src/lib/db.ts
Normal file
@@ -0,0 +1,749 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { join } from "path";
|
||||
import { mkdirSync } from "fs";
|
||||
|
||||
// Utiliser process.cwd() pour pointer vers la racine du projet
|
||||
const dbPath = join(process.cwd(), "data/primadopu.db");
|
||||
|
||||
// Créer le dossier data s'il n'existe pas
|
||||
try {
|
||||
mkdirSync(join(process.cwd(), "data"), { recursive: true });
|
||||
} catch {}
|
||||
|
||||
const db = new Database(dbPath);
|
||||
|
||||
// Activer les foreign keys
|
||||
db.pragma("foreign_keys = ON");
|
||||
|
||||
// Créer les tables
|
||||
db.exec(`
|
||||
-- Table des dossiers pour organiser les références
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT DEFAULT '#6366f1',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Table des références (images de matériaux/textures)
|
||||
CREATE TABLE IF NOT EXISTS material_refs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
image_path TEXT NOT NULL,
|
||||
folder_id TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Table des générations (avant/après)
|
||||
CREATE TABLE IF NOT EXISTS generations (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
original_image_path TEXT NOT NULL,
|
||||
generated_image_path TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Table des instructions (pour chaque génération)
|
||||
CREATE TABLE IF NOT EXISTS instructions (
|
||||
id TEXT PRIMARY KEY,
|
||||
generation_id TEXT NOT NULL,
|
||||
location TEXT NOT NULL,
|
||||
reference_id TEXT NOT NULL,
|
||||
FOREIGN KEY (generation_id) REFERENCES generations(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (reference_id) REFERENCES material_refs(id)
|
||||
);
|
||||
|
||||
-- Index pour les recherches par user_id
|
||||
CREATE INDEX IF NOT EXISTS idx_material_refs_user ON material_refs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_generations_user ON generations(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_folders_user ON folders(user_id);
|
||||
`);
|
||||
|
||||
// Migration: ajouter folder_id si la colonne n'existe pas
|
||||
try {
|
||||
db.exec(
|
||||
`ALTER TABLE material_refs ADD COLUMN folder_id TEXT REFERENCES folders(id) ON DELETE SET NULL`
|
||||
);
|
||||
} catch {
|
||||
// La colonne existe déjà
|
||||
}
|
||||
|
||||
// Migration: ajouter mask_image_path si la colonne n'existe pas
|
||||
try {
|
||||
db.exec(`ALTER TABLE instructions ADD COLUMN mask_image_path TEXT`);
|
||||
} catch {
|
||||
// La colonne existe déjà
|
||||
}
|
||||
|
||||
// Migration: créer l'index pour mask_image_path
|
||||
try {
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_instructions_mask ON instructions(mask_image_path)`
|
||||
);
|
||||
} catch {
|
||||
// L'index existe déjà
|
||||
}
|
||||
|
||||
export default db;
|
||||
|
||||
// Types
|
||||
export interface Folder {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Reference {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string | null;
|
||||
image_path: string;
|
||||
folder_id: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Generation {
|
||||
id: string;
|
||||
user_id: string;
|
||||
original_image_path: string;
|
||||
generated_image_path: string | null;
|
||||
status: "pending" | "processing" | "completed" | "failed";
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Instruction {
|
||||
id: string;
|
||||
generation_id: string;
|
||||
location: string;
|
||||
reference_id: string;
|
||||
mask_image_path?: string;
|
||||
}
|
||||
|
||||
// Fonctions pour les dossiers
|
||||
export function createFolder(
|
||||
userId: string,
|
||||
name: string,
|
||||
color?: string
|
||||
): Folder {
|
||||
const id = crypto.randomUUID();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO folders (id, user_id, name, color) VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(id, userId, name, color || "#6366f1");
|
||||
return getFolder(id)!;
|
||||
}
|
||||
|
||||
export function getFolder(id: string): Folder | undefined {
|
||||
const stmt = db.prepare("SELECT * FROM folders WHERE id = ?");
|
||||
return stmt.get(id) as Folder | undefined;
|
||||
}
|
||||
|
||||
export function getUserFolders(userId: string): Folder[] {
|
||||
const stmt = db.prepare(
|
||||
"SELECT * FROM folders WHERE user_id = ? ORDER BY name ASC"
|
||||
);
|
||||
return stmt.all(userId) as Folder[];
|
||||
}
|
||||
|
||||
export function updateFolder(
|
||||
id: string,
|
||||
userId: string,
|
||||
name: string,
|
||||
color?: string
|
||||
): boolean {
|
||||
const stmt = db.prepare(
|
||||
"UPDATE folders SET name = ?, color = COALESCE(?, color) WHERE id = ? AND user_id = ?"
|
||||
);
|
||||
const result = stmt.run(name, color || null, id, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function deleteFolder(id: string, userId: string): boolean {
|
||||
// Les références seront automatiquement mises à NULL grâce à ON DELETE SET NULL
|
||||
const stmt = db.prepare("DELETE FROM folders WHERE id = ? AND user_id = ?");
|
||||
const result = stmt.run(id, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
// Fonctions pour les références
|
||||
export function createReference(
|
||||
userId: string,
|
||||
imagePath: string,
|
||||
name?: string,
|
||||
folderId?: string
|
||||
): Reference {
|
||||
const id = crypto.randomUUID();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO material_refs (id, user_id, name, image_path, folder_id) VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(id, userId, name || null, imagePath, folderId || null);
|
||||
return getReference(id)!;
|
||||
}
|
||||
|
||||
export function getReference(id: string): Reference | undefined {
|
||||
const stmt = db.prepare("SELECT * FROM material_refs WHERE id = ?");
|
||||
return stmt.get(id) as Reference | undefined;
|
||||
}
|
||||
|
||||
export function getUserReferences(
|
||||
userId: string,
|
||||
folderId?: string | null
|
||||
): Reference[] {
|
||||
if (folderId === undefined) {
|
||||
// Toutes les références
|
||||
const stmt = db.prepare(
|
||||
"SELECT * FROM material_refs WHERE user_id = ? ORDER BY created_at DESC"
|
||||
);
|
||||
return stmt.all(userId) as Reference[];
|
||||
} else if (folderId === null) {
|
||||
// Références sans dossier
|
||||
const stmt = db.prepare(
|
||||
"SELECT * FROM material_refs WHERE user_id = ? AND folder_id IS NULL ORDER BY created_at DESC"
|
||||
);
|
||||
return stmt.all(userId) as Reference[];
|
||||
} else {
|
||||
// Références d'un dossier spécifique
|
||||
const stmt = db.prepare(
|
||||
"SELECT * FROM material_refs WHERE user_id = ? AND folder_id = ? ORDER BY created_at DESC"
|
||||
);
|
||||
return stmt.all(userId, folderId) as Reference[];
|
||||
}
|
||||
}
|
||||
|
||||
export function moveReferenceToFolder(
|
||||
id: string,
|
||||
userId: string,
|
||||
folderId: string | null
|
||||
): boolean {
|
||||
const stmt = db.prepare(
|
||||
"UPDATE material_refs SET folder_id = ? WHERE id = ? AND user_id = ?"
|
||||
);
|
||||
const result = stmt.run(folderId, id, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function deleteReference(id: string, userId: string): boolean {
|
||||
// D'abord supprimer les instructions qui utilisent cette référence
|
||||
const deleteInstructions = db.prepare(
|
||||
"DELETE FROM instructions WHERE reference_id = ?"
|
||||
);
|
||||
deleteInstructions.run(id);
|
||||
|
||||
// Ensuite supprimer la référence
|
||||
const stmt = db.prepare(
|
||||
"DELETE FROM material_refs WHERE id = ? AND user_id = ?"
|
||||
);
|
||||
const result = stmt.run(id, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
// Fonctions pour les générations
|
||||
export function createGeneration(
|
||||
userId: string,
|
||||
originalImagePath: string
|
||||
): Generation {
|
||||
const id = crypto.randomUUID();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO generations (id, user_id, original_image_path) VALUES (?, ?, ?)
|
||||
`);
|
||||
stmt.run(id, userId, originalImagePath);
|
||||
return getGeneration(id)!;
|
||||
}
|
||||
|
||||
export function getGeneration(id: string): Generation | undefined {
|
||||
const stmt = db.prepare("SELECT * FROM generations WHERE id = ?");
|
||||
return stmt.get(id) as Generation | undefined;
|
||||
}
|
||||
|
||||
export function getUserGenerations(userId: string): Generation[] {
|
||||
const stmt = db.prepare(
|
||||
"SELECT * FROM generations WHERE user_id = ? ORDER BY created_at DESC"
|
||||
);
|
||||
return stmt.all(userId) as Generation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les générations d'un utilisateur avec pagination
|
||||
*/
|
||||
export function getUserGenerationsPaginated(
|
||||
userId: string,
|
||||
page: number = 1,
|
||||
limit: number = 10
|
||||
): { generations: Generation[]; totalItems: number; totalPages: number } {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Compter le nombre total
|
||||
const countResult = db
|
||||
.prepare("SELECT COUNT(*) as count FROM generations WHERE user_id = ?")
|
||||
.get(userId) as { count: number };
|
||||
|
||||
const totalItems = countResult.count;
|
||||
const totalPages = Math.ceil(totalItems / limit);
|
||||
|
||||
// Récupérer les générations paginées
|
||||
const stmt = db.prepare(
|
||||
"SELECT * FROM generations WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||
);
|
||||
const generations = stmt.all(userId, limit, offset) as Generation[];
|
||||
|
||||
return { generations, totalItems, totalPages };
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur n'a jamais fait de génération (pour le tutoriel)
|
||||
*/
|
||||
export function isFirstTimeUser(userId: string): boolean {
|
||||
const result = db
|
||||
.prepare("SELECT COUNT(*) as count FROM generations WHERE user_id = ?")
|
||||
.get(userId) as { count: number };
|
||||
return result.count === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compte le nombre de générations complètes d'un utilisateur
|
||||
*/
|
||||
export function getUserGenerationCount(userId: string): number {
|
||||
const result = db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) as count FROM generations WHERE user_id = ? AND status = 'completed'"
|
||||
)
|
||||
.get(userId) as { count: number };
|
||||
return result.count;
|
||||
}
|
||||
|
||||
export function updateGeneration(
|
||||
id: string,
|
||||
updates: Partial<Pick<Generation, "generated_image_path" | "status">>
|
||||
): void {
|
||||
const sets: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (updates.generated_image_path !== undefined) {
|
||||
sets.push("generated_image_path = ?");
|
||||
values.push(updates.generated_image_path);
|
||||
}
|
||||
if (updates.status !== undefined) {
|
||||
sets.push("status = ?");
|
||||
values.push(updates.status);
|
||||
}
|
||||
|
||||
if (sets.length > 0) {
|
||||
values.push(id);
|
||||
const stmt = db.prepare(
|
||||
`UPDATE generations SET ${sets.join(", ")} WHERE id = ?`
|
||||
);
|
||||
stmt.run(...values);
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteGeneration(id: string, userId: string): boolean {
|
||||
const stmt = db.prepare(
|
||||
"DELETE FROM generations WHERE id = ? AND user_id = ?"
|
||||
);
|
||||
const result = stmt.run(id, userId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
// Fonctions pour les instructions
|
||||
export function createInstruction(
|
||||
generationId: string,
|
||||
location: string,
|
||||
referenceId: string,
|
||||
maskImagePath?: string
|
||||
): Instruction {
|
||||
const id = crypto.randomUUID();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO instructions (id, generation_id, location, reference_id, mask_image_path) VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(id, generationId, location, referenceId, maskImagePath || null);
|
||||
return {
|
||||
id,
|
||||
generation_id: generationId,
|
||||
location,
|
||||
reference_id: referenceId,
|
||||
mask_image_path: maskImagePath,
|
||||
};
|
||||
}
|
||||
|
||||
export function getGenerationInstructions(
|
||||
generationId: string
|
||||
): (Instruction & { reference: Reference })[] {
|
||||
const stmt = db.prepare(`
|
||||
SELECT i.*, r.id as ref_id, r.user_id as ref_user_id, r.name as ref_name,
|
||||
r.image_path as ref_image_path, r.folder_id as ref_folder_id, r.created_at as ref_created_at
|
||||
FROM instructions i
|
||||
JOIN material_refs r ON i.reference_id = r.id
|
||||
WHERE i.generation_id = ?
|
||||
`);
|
||||
const rows = stmt.all(generationId) as any[];
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
generation_id: row.generation_id,
|
||||
location: row.location,
|
||||
reference_id: row.reference_id,
|
||||
mask_image_path: row.mask_image_path,
|
||||
reference: {
|
||||
id: row.ref_id,
|
||||
user_id: row.ref_user_id,
|
||||
name: row.ref_name,
|
||||
image_path: row.ref_image_path,
|
||||
folder_id: row.ref_folder_id,
|
||||
created_at: row.ref_created_at,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export function deleteInstruction(id: string): boolean {
|
||||
const stmt = db.prepare("DELETE FROM instructions WHERE id = ?");
|
||||
const result = stmt.run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SYSTÈME DE CRÉDITS / ABONNEMENTS
|
||||
// ==========================================
|
||||
|
||||
// Créer la table des crédits utilisateur
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_credits (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
total_generations INTEGER DEFAULT 0,
|
||||
monthly_generations INTEGER DEFAULT 0,
|
||||
last_monthly_reset TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
export interface UserCredits {
|
||||
user_id: string;
|
||||
total_generations: number;
|
||||
monthly_generations: number;
|
||||
last_monthly_reset: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Limites par plan
|
||||
export const PLAN_LIMITS = {
|
||||
free: { monthly: null, total: 3 }, // 3 générations au total, pas de reset mensuel
|
||||
standard: { monthly: 25, total: null }, // 25/mois, illimité au total
|
||||
pro: { monthly: 50, total: null }, // 50/mois, illimité au total
|
||||
} as const;
|
||||
|
||||
export type PlanType = keyof typeof PLAN_LIMITS;
|
||||
|
||||
// Obtenir ou créer les crédits d'un utilisateur
|
||||
export function getUserCredits(userId: string): UserCredits {
|
||||
let credits = db
|
||||
.prepare("SELECT * FROM user_credits WHERE user_id = ?")
|
||||
.get(userId) as UserCredits | undefined;
|
||||
|
||||
if (!credits) {
|
||||
// Créer l'entrée pour un nouvel utilisateur
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO user_credits (user_id, total_generations, monthly_generations, last_monthly_reset)
|
||||
VALUES (?, 0, 0, ?)
|
||||
`
|
||||
).run(userId, new Date().toISOString().slice(0, 7)); // Format: "2026-01"
|
||||
|
||||
credits = db
|
||||
.prepare("SELECT * FROM user_credits WHERE user_id = ?")
|
||||
.get(userId) as UserCredits;
|
||||
}
|
||||
|
||||
return credits;
|
||||
}
|
||||
|
||||
// Vérifier et réinitialiser les crédits mensuels si nécessaire
|
||||
export function checkAndResetMonthlyCredits(userId: string): UserCredits {
|
||||
const credits = getUserCredits(userId);
|
||||
const currentMonth = new Date().toISOString().slice(0, 7); // Format: "2026-01"
|
||||
|
||||
if (credits.last_monthly_reset !== currentMonth) {
|
||||
// Nouveau mois, réinitialiser les crédits mensuels
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE user_credits
|
||||
SET monthly_generations = 0, last_monthly_reset = ?
|
||||
WHERE user_id = ?
|
||||
`
|
||||
).run(currentMonth, userId);
|
||||
|
||||
return getUserCredits(userId);
|
||||
}
|
||||
|
||||
return credits;
|
||||
}
|
||||
|
||||
// Vérifier si un utilisateur peut générer (selon son plan)
|
||||
// INCLUT les crédits bonus (supplémentaires achetés)
|
||||
export function canUserGenerate(
|
||||
userId: string,
|
||||
planType: PlanType,
|
||||
isAdmin: boolean = false,
|
||||
bonusCredits: number = 0
|
||||
): {
|
||||
canGenerate: boolean;
|
||||
reason?: string;
|
||||
credits: UserCredits;
|
||||
limit: number;
|
||||
used: number;
|
||||
isUnlimited: boolean;
|
||||
bonusCredits: number;
|
||||
totalAvailable: number;
|
||||
} {
|
||||
const credits = checkAndResetMonthlyCredits(userId);
|
||||
const limits = PLAN_LIMITS[planType];
|
||||
|
||||
// Les admins ont des générations illimitées
|
||||
if (isAdmin) {
|
||||
return {
|
||||
canGenerate: true,
|
||||
credits,
|
||||
limit: Infinity,
|
||||
used: credits.total_generations,
|
||||
isUnlimited: true,
|
||||
bonusCredits,
|
||||
totalAvailable: Infinity,
|
||||
};
|
||||
}
|
||||
|
||||
if (planType === "free") {
|
||||
// Plan gratuit: vérifie le total (3 max au total) + bonus
|
||||
const baseLimit = limits.total || 0;
|
||||
const totalAvailable = baseLimit + bonusCredits - credits.total_generations;
|
||||
const canGenerate = totalAvailable > 0;
|
||||
return {
|
||||
canGenerate,
|
||||
reason: canGenerate
|
||||
? undefined
|
||||
: "Vous avez utilisé tous vos crédits. Passez à un abonnement ou achetez des crédits pour continuer.",
|
||||
credits,
|
||||
limit: baseLimit,
|
||||
used: credits.total_generations,
|
||||
isUnlimited: false,
|
||||
bonusCredits,
|
||||
totalAvailable: Math.max(0, totalAvailable),
|
||||
};
|
||||
} else {
|
||||
// Plans payants: vérifie le mensuel + bonus
|
||||
const monthlyLimit = limits.monthly || 0;
|
||||
const monthlyRemaining = Math.max(
|
||||
0,
|
||||
monthlyLimit - credits.monthly_generations
|
||||
);
|
||||
const totalAvailable = monthlyRemaining + bonusCredits;
|
||||
const canGenerate = totalAvailable > 0;
|
||||
return {
|
||||
canGenerate,
|
||||
reason: canGenerate
|
||||
? undefined
|
||||
: `Vous avez utilisé tous vos crédits ce mois-ci. Achetez des crédits supplémentaires pour continuer.`,
|
||||
credits,
|
||||
limit: monthlyLimit,
|
||||
used: credits.monthly_generations,
|
||||
isUnlimited: false,
|
||||
bonusCredits,
|
||||
totalAvailable,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Incrémenter les crédits après une génération réussie
|
||||
// Cette fonction incrémente TOUJOURS les compteurs (pour les stats)
|
||||
export function incrementUserCredits(userId: string): void {
|
||||
const credits = checkAndResetMonthlyCredits(userId);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE user_credits
|
||||
SET total_generations = total_generations + 1,
|
||||
monthly_generations = monthly_generations + 1
|
||||
WHERE user_id = ?
|
||||
`
|
||||
).run(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour les crédits d'un utilisateur (admin seulement)
|
||||
* Permet de modifier les générations mensuelles et totales
|
||||
*/
|
||||
export function updateUserCredits(
|
||||
userId: string,
|
||||
data: { monthly_generations?: number; total_generations?: number }
|
||||
): UserCredits {
|
||||
// S'assurer que l'utilisateur existe
|
||||
getUserCredits(userId);
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (typeof data.monthly_generations === "number") {
|
||||
updates.push("monthly_generations = ?");
|
||||
values.push(Math.max(0, data.monthly_generations));
|
||||
}
|
||||
|
||||
if (typeof data.total_generations === "number") {
|
||||
updates.push("total_generations = ?");
|
||||
values.push(Math.max(0, data.total_generations));
|
||||
}
|
||||
|
||||
if (updates.length > 0) {
|
||||
values.push(userId);
|
||||
db.prepare(
|
||||
`UPDATE user_credits SET ${updates.join(", ")} WHERE user_id = ?`
|
||||
).run(...values);
|
||||
}
|
||||
|
||||
return getUserCredits(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consomme un crédit pour une génération
|
||||
* Logique: utilise d'abord les crédits mensuels de l'abonnement,
|
||||
* puis les crédits bonus si le quota mensuel est épuisé
|
||||
*
|
||||
* @param userId - ID de l'utilisateur
|
||||
* @param planType - Type de plan (free, standard, pro)
|
||||
* @param bonusCredits - Nombre de crédits bonus disponibles
|
||||
* @param useBonusCredit - Fonction pour décrémenter les crédits bonus
|
||||
* @returns { success: boolean, usedBonus: boolean, reason?: string }
|
||||
*/
|
||||
export function consumeCredit(
|
||||
userId: string,
|
||||
planType: PlanType,
|
||||
bonusCredits: number,
|
||||
useBonusCredit: () => boolean
|
||||
): { success: boolean; usedBonus: boolean; reason?: string } {
|
||||
const credits = checkAndResetMonthlyCredits(userId);
|
||||
const limits = PLAN_LIMITS[planType];
|
||||
|
||||
if (planType === "free") {
|
||||
// Plan gratuit: vérifier si on a des crédits de base ou bonus
|
||||
const baseLimit = limits.total || 3;
|
||||
const baseRemaining = baseLimit - credits.total_generations;
|
||||
|
||||
if (baseRemaining > 0) {
|
||||
// Utiliser un crédit de base (gratuit)
|
||||
incrementUserCredits(userId);
|
||||
return { success: true, usedBonus: false };
|
||||
} else if (bonusCredits > 0) {
|
||||
// Utiliser un crédit bonus
|
||||
const used = useBonusCredit();
|
||||
if (used) {
|
||||
incrementUserCredits(userId); // Incrémenter les compteurs pour les stats
|
||||
return { success: true, usedBonus: true };
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
usedBonus: false,
|
||||
reason: "Plus de crédits disponibles",
|
||||
};
|
||||
} else {
|
||||
// Plans payants: vérifier le quota mensuel d'abord
|
||||
const monthlyLimit = limits.monthly || 0;
|
||||
const monthlyRemaining = monthlyLimit - credits.monthly_generations;
|
||||
|
||||
if (monthlyRemaining > 0) {
|
||||
// Utiliser un crédit mensuel de l'abonnement
|
||||
incrementUserCredits(userId);
|
||||
return { success: true, usedBonus: false };
|
||||
} else if (bonusCredits > 0) {
|
||||
// Le quota mensuel est épuisé, utiliser un crédit bonus
|
||||
const used = useBonusCredit();
|
||||
if (used) {
|
||||
incrementUserCredits(userId); // Incrémenter les compteurs pour les stats
|
||||
return { success: true, usedBonus: true };
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
usedBonus: false,
|
||||
reason: "Quota mensuel épuisé et pas de crédits bonus",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Obtenir les statistiques de crédit pour l'affichage
|
||||
// INCLUT les crédits bonus dans le calcul
|
||||
export function getCreditStats(
|
||||
userId: string,
|
||||
planType: PlanType,
|
||||
isAdmin: boolean = false,
|
||||
bonusCredits: number = 0
|
||||
): {
|
||||
used: number;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
percentage: number;
|
||||
isUnlimited: boolean;
|
||||
planName: string;
|
||||
bonusCredits: number;
|
||||
monthlyRemaining: number;
|
||||
totalRemaining: number;
|
||||
} {
|
||||
const credits = checkAndResetMonthlyCredits(userId);
|
||||
const limits = PLAN_LIMITS[planType];
|
||||
|
||||
const planNames: Record<PlanType, string> = {
|
||||
free: "Gratuit",
|
||||
standard: "Standard",
|
||||
pro: "Pro",
|
||||
};
|
||||
|
||||
// Les admins ont des générations illimitées
|
||||
if (isAdmin) {
|
||||
return {
|
||||
used: credits.total_generations,
|
||||
limit: Infinity,
|
||||
remaining: Infinity,
|
||||
percentage: 0,
|
||||
isUnlimited: true,
|
||||
planName: "Admin",
|
||||
bonusCredits,
|
||||
monthlyRemaining: Infinity,
|
||||
totalRemaining: Infinity,
|
||||
};
|
||||
}
|
||||
|
||||
if (planType === "free") {
|
||||
const limit = limits.total || 3;
|
||||
const used = credits.total_generations;
|
||||
const baseRemaining = Math.max(0, limit - used);
|
||||
const totalRemaining = baseRemaining + bonusCredits;
|
||||
return {
|
||||
used,
|
||||
limit,
|
||||
remaining: totalRemaining,
|
||||
percentage: Math.min(100, (used / limit) * 100),
|
||||
isUnlimited: false,
|
||||
planName: planNames[planType],
|
||||
bonusCredits,
|
||||
monthlyRemaining: baseRemaining,
|
||||
totalRemaining,
|
||||
};
|
||||
} else {
|
||||
const limit = limits.monthly || 0;
|
||||
const used = credits.monthly_generations;
|
||||
const monthlyRemaining = Math.max(0, limit - used);
|
||||
const totalRemaining = monthlyRemaining + bonusCredits;
|
||||
return {
|
||||
used,
|
||||
limit,
|
||||
remaining: totalRemaining,
|
||||
percentage: Math.min(100, (used / limit) * 100),
|
||||
isUnlimited: false,
|
||||
planName: planNames[planType],
|
||||
bonusCredits,
|
||||
monthlyRemaining,
|
||||
totalRemaining,
|
||||
};
|
||||
}
|
||||
}
|
||||
330
src/lib/gemini/agents/analyzer.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
// ============================================================================
|
||||
// AGENT ANALYSTE D'IMAGE - TOUS MÉTIERS DU BÂTIMENT & AMÉNAGEMENT
|
||||
// ============================================================================
|
||||
// Analyse exhaustive couvrant: BTP, décoration, architecture, énergie,
|
||||
// rénovation, immobilier, menuiserie, façadier, constructions bois,
|
||||
// fermetures, plomberie, paysagiste, électricité, toiture, piscine, etc.
|
||||
// ============================================================================
|
||||
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
import { MODELS } from "../config";
|
||||
import type { ImageAnalysis, PreparedImage } from "../types";
|
||||
|
||||
// Client AI (initialisé une seule fois)
|
||||
const ai = new GoogleGenAI({
|
||||
apiKey:
|
||||
import.meta.env.AI_GOOGLE_API_KEY || process.env.AI_GOOGLE_API_KEY || "",
|
||||
});
|
||||
|
||||
/**
|
||||
* Prompt d'analyse exhaustif pour tous les métiers du bâtiment
|
||||
*/
|
||||
const ANALYSIS_PROMPT = `Tu es un EXPERT POLYVALENT en analyse visuelle pour TOUS les métiers du bâtiment, de l'aménagement et de l'habitat.
|
||||
|
||||
TON EXPERTISE COUVRE:
|
||||
• BTP & Gros œuvre: maçonnerie, charpente, fondations, structure
|
||||
• Menuiserie intérieure: portes, placards, escaliers, parquets
|
||||
• Menuiserie extérieure & Fermetures: fenêtres, volets, portails, stores, pergolas
|
||||
• Façade & Ravalement: enduit, bardage, isolation extérieure
|
||||
• Toiture & Couverture: tuiles, ardoises, gouttières, cheminées, velux
|
||||
• Plomberie & Sanitaires: tuyauterie, robinetterie, WC, douches, baignoires
|
||||
• Électricité & Domotique: tableau, prises, éclairage, alarmes, interphones
|
||||
• Chauffage & Climatisation: radiateurs, chaudières, PAC, VMC, climatiseurs
|
||||
• Énergies Renouvelables: panneaux solaires, batteries, bornes de recharge
|
||||
• Paysagisme & Extérieur: terrasses, allées, clôtures, végétation, arrosage
|
||||
• Piscine & Spa: bassins, margelles, abris, équipements
|
||||
• Décoration & Agencement: mobilier, luminaires, textiles, art
|
||||
• Cuisine & Salle de bain: meubles, plans de travail, électroménager
|
||||
|
||||
ANALYSE cette image de manière EXHAUSTIVE et identifie:
|
||||
|
||||
1. **TYPE D'ESPACE**: Intérieur, extérieur, ou mixte
|
||||
2. **TOUTES LES SURFACES** modifiables (murs, sols, plafonds, façades, toiture)
|
||||
3. **TOUS LES ÉLÉMENTS ARCHITECTURAUX** (portes, fenêtres, escaliers, garde-corps)
|
||||
4. **TOUS LES ÉQUIPEMENTS TECHNIQUES** (plomberie, électricité, chauffage, énergie)
|
||||
5. **TOUT LE MOBILIER** (meubles, luminaires, décorations)
|
||||
6. **TOUTE LA VÉGÉTATION** (plantes, arbres, haies, massifs)
|
||||
7. **L'ÉCLAIRAGE** précis de la scène
|
||||
8. **LA PERSPECTIVE** exacte pour le respect du cadrage
|
||||
|
||||
Réponds UNIQUEMENT avec ce JSON valide (sans markdown, sans backticks):
|
||||
{
|
||||
"spaceType": "type précis (salon, chambre, cuisine, jardin, façade, toiture...)",
|
||||
"environment": "interior | exterior | mixed",
|
||||
"surfaces": [
|
||||
{
|
||||
"id": "wall_north",
|
||||
"name": "Mur nord",
|
||||
"description": "Mur vertical principal face à l'entrée",
|
||||
"boundaries": "De l'angle gauche jusqu'à la fenêtre",
|
||||
"currentMaterial": "Peinture blanche mate légèrement usée",
|
||||
"category": "wall",
|
||||
"condition": "good",
|
||||
"visiblePercentage": 80
|
||||
}
|
||||
],
|
||||
"objects": [
|
||||
{
|
||||
"id": "window_main",
|
||||
"name": "Fenêtre principale",
|
||||
"category": "window",
|
||||
"subcategory": "double_vitrage_pvc",
|
||||
"description": "Fenêtre 2 vantaux avec volets roulants",
|
||||
"position": "Mur nord, centre",
|
||||
"style": "moderne",
|
||||
"material": "PVC blanc",
|
||||
"color": "blanc",
|
||||
"condition": "good"
|
||||
},
|
||||
{
|
||||
"id": "radiator_main",
|
||||
"name": "Radiateur",
|
||||
"category": "radiator",
|
||||
"subcategory": "acier_horizontal",
|
||||
"description": "Radiateur à eau chaude sous la fenêtre",
|
||||
"position": "Sous la fenêtre principale",
|
||||
"style": "classique",
|
||||
"material": "acier peint",
|
||||
"color": "blanc",
|
||||
"condition": "good"
|
||||
}
|
||||
],
|
||||
"lighting": {
|
||||
"type": "natural | artificial | mixed",
|
||||
"direction": "Lumière venant de la droite par la fenêtre",
|
||||
"intensity": "medium",
|
||||
"temperature": "neutral",
|
||||
"shadows": "soft",
|
||||
"estimatedTimeOfDay": "après-midi"
|
||||
},
|
||||
"perspective": {
|
||||
"viewType": "angular",
|
||||
"description": "Vue depuis l'entrée vers le fond de la pièce",
|
||||
"cameraHeight": "eye_level",
|
||||
"fieldOfView": "normal",
|
||||
"vanishingPoint": "centre-droit de l'image"
|
||||
},
|
||||
"existingMaterials": [
|
||||
{"zone": "Sol", "type": "parquet", "color": "chêne moyen", "texture": "veiné", "finish": "satin"}
|
||||
],
|
||||
"technicalEquipment": [
|
||||
{
|
||||
"id": "outlet_1",
|
||||
"type": "prise électrique",
|
||||
"category": "outlet",
|
||||
"position": "Mur gauche, bas"
|
||||
}
|
||||
],
|
||||
"vegetation": [
|
||||
{
|
||||
"id": "plant_1",
|
||||
"type": "ficus",
|
||||
"category": "indoor_plant",
|
||||
"position": "Coin gauche",
|
||||
"size": "medium",
|
||||
"health": "healthy"
|
||||
}
|
||||
],
|
||||
"relevantTrades": ["peinture", "menuiserie", "électricité", "décoration"]
|
||||
}
|
||||
|
||||
CATÉGORIES D'ÉLÉMENTS À IDENTIFIER (sois EXHAUSTIF):
|
||||
|
||||
🏗️ STRUCTURE & GROS ŒUVRE:
|
||||
- Murs porteurs, cloisons, poutres, colonnes
|
||||
- Escaliers, marches, rampes, garde-corps
|
||||
- Dalles, fondations visibles, linteaux
|
||||
|
||||
🚪 MENUISERIE & FERMETURES:
|
||||
- Portes (intérieures, extérieures, garage, coulissantes)
|
||||
- Fenêtres (fixes, ouvrantes, velux, baies vitrées)
|
||||
- Volets (battants, roulants), stores, persiennes
|
||||
- Portails, portillons, clôtures
|
||||
- Placards, dressings, bibliothèques intégrées
|
||||
- Plinthes, moulures, lambris
|
||||
|
||||
🏠 TOITURE & FAÇADE:
|
||||
- Tuiles, ardoises, couverture métallique
|
||||
- Gouttières, descentes, chéneaux
|
||||
- Cheminées, conduits, antennes
|
||||
- Enduit, bardage (bois, métallique, composite)
|
||||
- Isolation extérieure visible
|
||||
|
||||
🔧 PLOMBERIE & SANITAIRES:
|
||||
- Lavabos, éviers, robinetterie
|
||||
- WC, bidets, urinoirs
|
||||
- Douches, baignoires, parois
|
||||
- Chauffe-eau, ballon, cumulus
|
||||
- Tuyauterie visible, radiateurs
|
||||
|
||||
⚡ ÉLECTRICITÉ & DOMOTIQUE:
|
||||
- Prises, interrupteurs, variateurs
|
||||
- Tableau électrique, compteur
|
||||
- Éclairages (plafonniers, appliques, spots)
|
||||
- Thermostats, interphones, vidéophones
|
||||
- Caméras, détecteurs, alarmes
|
||||
|
||||
🌡️ CHAUFFAGE & CLIMATISATION:
|
||||
- Radiateurs (eau, électrique)
|
||||
- Cheminées, inserts, poêles
|
||||
- Climatiseurs, splits, cassettes
|
||||
- VMC, bouches d'aération
|
||||
|
||||
☀️ ÉNERGIES RENOUVELABLES:
|
||||
- Panneaux solaires (photovoltaïques, thermiques)
|
||||
- Pompes à chaleur
|
||||
- Batteries de stockage
|
||||
- Bornes de recharge VE
|
||||
|
||||
🪴 EXTÉRIEUR & PAYSAGISME:
|
||||
- Terrasses (bois, pierre, composite)
|
||||
- Allées, dallage, pavés
|
||||
- Pelouse, massifs, bordures
|
||||
- Arbres, arbustes, haies
|
||||
- Piscines, spas, fontaines
|
||||
- Mobilier de jardin, pergolas
|
||||
|
||||
🪑 MOBILIER & DÉCORATION:
|
||||
- Canapés, fauteuils, chaises
|
||||
- Tables, bureaux, commodes
|
||||
- Lits, armoires, étagères
|
||||
- Luminaires (lustres, lampes)
|
||||
- Rideaux, tapis, coussins
|
||||
- Tableaux, miroirs, objets déco
|
||||
- Plantes d'intérieur
|
||||
|
||||
🍳 CUISINE:
|
||||
- Meubles hauts et bas
|
||||
- Plan de travail, crédence, îlot
|
||||
- Électroménager (four, plaque, hotte, frigo)
|
||||
- Évier, robinetterie
|
||||
|
||||
🛁 SALLE DE BAIN:
|
||||
- Vasques, meubles de salle de bain
|
||||
- Douche, baignoire
|
||||
- Miroirs, rangements
|
||||
- Sèche-serviettes
|
||||
|
||||
RÈGLES CRITIQUES:
|
||||
1. Identifie CHAQUE élément visible séparément avec un ID unique
|
||||
2. Sois PRÉCIS sur les matériaux, couleurs, styles, états
|
||||
3. Note la POSITION exacte de chaque élément
|
||||
4. Identifie les ÉQUIPEMENTS TECHNIQUES même petits (prises, interrupteurs)
|
||||
5. La perspective est CRUCIALE - analyse-la précisément
|
||||
6. Les MÉTIERS concernés aident à contextualiser les modifications possibles`;
|
||||
|
||||
/**
|
||||
* Analyse fallback générique
|
||||
*/
|
||||
function createFallbackAnalysis(): ImageAnalysis {
|
||||
return {
|
||||
spaceType: "espace non identifié",
|
||||
environment: "interior",
|
||||
surfaces: [
|
||||
{
|
||||
id: "wall_main",
|
||||
name: "Mur principal",
|
||||
description: "Surface murale principale",
|
||||
boundaries: "Zone principale visible",
|
||||
currentMaterial: "inconnu",
|
||||
category: "wall",
|
||||
},
|
||||
{
|
||||
id: "floor_main",
|
||||
name: "Sol",
|
||||
description: "Surface au sol",
|
||||
boundaries: "Partie basse de l'image",
|
||||
currentMaterial: "inconnu",
|
||||
category: "floor",
|
||||
},
|
||||
],
|
||||
objects: [],
|
||||
lighting: {
|
||||
type: "mixed",
|
||||
direction: "non déterminée",
|
||||
intensity: "medium",
|
||||
temperature: "neutral",
|
||||
shadows: "soft",
|
||||
},
|
||||
perspective: {
|
||||
viewType: "frontal",
|
||||
description: "Vue frontale",
|
||||
cameraHeight: "eye_level",
|
||||
fieldOfView: "normal",
|
||||
},
|
||||
existingMaterials: [],
|
||||
technicalEquipment: [],
|
||||
vegetation: [],
|
||||
relevantTrades: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent Analyste - Analyse exhaustive d'une image
|
||||
* Identifie toutes les surfaces, objets, équipements pour tous les métiers
|
||||
*/
|
||||
export async function analyzeImageWithAgent(
|
||||
imageData: PreparedImage
|
||||
): Promise<ImageAnalysis> {
|
||||
console.log(" 🔍 Agent Analyste: Analyse exhaustive multi-métiers...");
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: MODELS.analyzer,
|
||||
contents: [
|
||||
{ text: ANALYSIS_PROMPT },
|
||||
{
|
||||
inlineData: { mimeType: imageData.mimeType, data: imageData.base64 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const text = response.candidates?.[0]?.content?.parts?.[0]?.text || "";
|
||||
|
||||
// Extraire le JSON
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const analysis = JSON.parse(jsonMatch[0]) as ImageAnalysis;
|
||||
|
||||
// Logs détaillés
|
||||
console.log(` ✓ Type d'espace: ${analysis.spaceType} (${analysis.environment})`);
|
||||
console.log(` ✓ ${analysis.surfaces?.length || 0} surfaces identifiées`);
|
||||
console.log(` ✓ ${analysis.objects?.length || 0} objets identifiés`);
|
||||
console.log(` ✓ ${analysis.technicalEquipment?.length || 0} équipements techniques`);
|
||||
console.log(` ✓ ${analysis.vegetation?.length || 0} éléments végétaux`);
|
||||
|
||||
if (analysis.relevantTrades && analysis.relevantTrades.length > 0) {
|
||||
console.log(` ✓ Métiers concernés: ${analysis.relevantTrades.join(", ")}`);
|
||||
}
|
||||
|
||||
// Log des surfaces
|
||||
if (analysis.surfaces && analysis.surfaces.length > 0) {
|
||||
console.log("\n 📐 Surfaces:");
|
||||
for (const surface of analysis.surfaces.slice(0, 5)) {
|
||||
console.log(` - ${surface.name} (${surface.category}): ${surface.currentMaterial}`);
|
||||
}
|
||||
if (analysis.surfaces.length > 5) {
|
||||
console.log(` ... et ${analysis.surfaces.length - 5} autres`);
|
||||
}
|
||||
}
|
||||
|
||||
// Log des objets
|
||||
if (analysis.objects && analysis.objects.length > 0) {
|
||||
console.log("\n 🪑 Objets:");
|
||||
for (const obj of analysis.objects.slice(0, 5)) {
|
||||
console.log(` - ${obj.name} (${obj.category}): ${obj.style} ${obj.material}`);
|
||||
}
|
||||
if (analysis.objects.length > 5) {
|
||||
console.log(` ... et ${analysis.objects.length - 5} autres`);
|
||||
}
|
||||
}
|
||||
|
||||
return analysis;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(" ⚠️ Parsing de l'analyse échoué, utilisation du fallback");
|
||||
console.error(" Erreur:", error);
|
||||
}
|
||||
|
||||
return createFallbackAnalysis();
|
||||
}
|
||||
281
src/lib/gemini/agents/generator.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
// ============================================================================
|
||||
// AGENT GÉNÉRATEUR - GÉNÉRATION D'IMAGE AVEC NANO BANANA PRO
|
||||
// ============================================================================
|
||||
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
import { saveBuffer } from "../../storage";
|
||||
import { MODELS, IMAGE_CONFIG } from "../config";
|
||||
import type { PreparedImage } from "../types";
|
||||
|
||||
// Client AI
|
||||
const ai = new GoogleGenAI({
|
||||
apiKey:
|
||||
import.meta.env.AI_GOOGLE_API_KEY || process.env.AI_GOOGLE_API_KEY || "",
|
||||
});
|
||||
|
||||
/**
|
||||
* Résultat de la génération
|
||||
*/
|
||||
export interface GeneratorResult {
|
||||
imagePath: string;
|
||||
description: string;
|
||||
thoughtCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent Générateur - Génère l'image avec Nano Banana Pro
|
||||
* @param originalImage - Image originale
|
||||
* @param referenceImages - Images de référence (matériaux/objets)
|
||||
* @param prompt - Prompt de génération
|
||||
* @param outputDir - Répertoire de sortie
|
||||
* @param generationId - ID de la génération
|
||||
* @param combinedMaskImages - Masques combinés optionnels (référence dans zone sélectionnée)
|
||||
*/
|
||||
export async function generateWithNanoBanana(
|
||||
originalImage: PreparedImage,
|
||||
referenceImages: PreparedImage[],
|
||||
prompt: string,
|
||||
outputDir: string,
|
||||
generationId: string,
|
||||
combinedMaskImages?: (PreparedImage | null)[]
|
||||
): Promise<GeneratorResult> {
|
||||
console.log(
|
||||
" 🎨 Agent Générateur: Appel à Nano Banana Pro (gemini-3-pro-image-preview)..."
|
||||
);
|
||||
console.log(` 📝 Prompt: ${prompt.length} caractères`);
|
||||
console.log(
|
||||
` 🖼️ Config: ${IMAGE_CONFIG.imageSize} @ ${IMAGE_CONFIG.aspectRatio}`
|
||||
);
|
||||
|
||||
// Afficher le prompt complet pour debug
|
||||
console.log("\n" + "─".repeat(70));
|
||||
console.log("📝 PROMPT COMPLET ENVOYÉ À GEMINI:");
|
||||
console.log("─".repeat(70));
|
||||
console.log(prompt);
|
||||
console.log("─".repeat(70) + "\n");
|
||||
|
||||
// Construire le contenu avec des labels EXPLICITES pour chaque image
|
||||
const contents: any[] = [
|
||||
{ text: prompt },
|
||||
{ text: `
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
🏠 IMAGE 1 - IMAGE ORIGINALE À MODIFIER (image suivante)
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
C'est l'image de base que tu dois transformer.
|
||||
CONSERVE son cadrage, sa perspective et tous les éléments non concernés par les modifications.
|
||||
` },
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: originalImage.mimeType,
|
||||
data: originalImage.base64,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Ajouter les images de référence avec labels explicites
|
||||
if (referenceImages.length > 0) {
|
||||
contents.push({
|
||||
text: `
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
🎨 IMAGES DE RÉFÉRENCE (${referenceImages.length} image(s) suivante(s))
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
Ces images montrent les matériaux, textures ou objets à utiliser pour les modifications.
|
||||
Chaque référence est numérotée et correspond à une zone du masque.
|
||||
`
|
||||
});
|
||||
|
||||
for (let i = 0; i < referenceImages.length; i++) {
|
||||
const refImage = referenceImages[i];
|
||||
contents.push({
|
||||
text: `
|
||||
📌 RÉFÉRENCE ${i + 1}/${referenceImages.length} (image suivante):
|
||||
Cette texture/matériau doit être appliqué dans la ZONE ${i + 1} du masque annoté.
|
||||
`
|
||||
});
|
||||
contents.push({
|
||||
inlineData: { mimeType: refImage.mimeType, data: refImage.base64 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Ajouter le masque fusionné annoté s'il existe
|
||||
let hasMask = false;
|
||||
if (combinedMaskImages && combinedMaskImages.length > 0 && combinedMaskImages[0]) {
|
||||
const annotatedMask = combinedMaskImages[0];
|
||||
|
||||
contents.push({
|
||||
text: `
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
🎭 MASQUE DE GUIDAGE ANNOTÉ (image suivante) - TRÈS IMPORTANT !
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
L'image suivante est un GUIDE VISUEL qui te montre EXACTEMENT :
|
||||
• OÙ appliquer chaque modification (zones délimitées par des contours colorés)
|
||||
• QUOI appliquer (la texture/matériau est déjà visible à l'intérieur de chaque zone)
|
||||
• QUELLE RÉFÉRENCE utiliser (numéros 1, 2, 3... correspondent aux références ci-dessus)
|
||||
|
||||
⚠️ RÈGLES STRICTES À RESPECTER:
|
||||
1. Modifie UNIQUEMENT les zones colorées/numérotées du masque
|
||||
2. Applique la texture/référence correspondant au numéro de la zone
|
||||
3. Garde TOUT LE RESTE de l'image IDENTIQUE à l'originale
|
||||
4. Respecte les contours EXACTS des zones délimitées
|
||||
`
|
||||
});
|
||||
|
||||
contents.push({
|
||||
inlineData: { mimeType: annotatedMask.mimeType, data: annotatedMask.base64 },
|
||||
});
|
||||
|
||||
contents.push({
|
||||
text: `
|
||||
☝️ L'image CI-DESSUS est le MASQUE ANNOTÉ. Voici la correspondance:
|
||||
${referenceImages.map((_, i) => ` • Zone ${i + 1} (numéro ${i + 1} dans un cercle) → Applique la RÉFÉRENCE ${i + 1}`).join('\n')}
|
||||
|
||||
Les parties NON masquées (sans numéro ni contour coloré) doivent rester STRICTEMENT IDENTIQUES à l'image originale.
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
`
|
||||
});
|
||||
|
||||
hasMask = true;
|
||||
console.log(` 🎭 Masque fusionné annoté ajouté avec labels explicites`);
|
||||
}
|
||||
|
||||
// Instruction finale de rappel
|
||||
contents.push({
|
||||
text: `
|
||||
|
||||
🎯 RÉCAPITULATIF DE TA MISSION:
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
1. Prends l'IMAGE ORIGINALE (image 1) comme base
|
||||
2. Consulte les IMAGES DE RÉFÉRENCE pour voir les matériaux/textures à utiliser
|
||||
3. Regarde le MASQUE ANNOTÉ pour savoir EXACTEMENT où appliquer chaque référence:
|
||||
${referenceImages.map((_, i) => ` - Zone ${i + 1} → Référence ${i + 1}`).join('\n')}
|
||||
4. Génère UNE SEULE image finale avec toutes les modifications appliquées
|
||||
5. CONSERVE le cadrage, la perspective et la luminosité de l'original
|
||||
|
||||
🚫 INTERDIT:
|
||||
- Zoomer ou dézoomer
|
||||
- Recadrer l'image
|
||||
- Changer l'angle de vue
|
||||
- Modifier les zones NON masquées
|
||||
- Inventer des modifications non demandées
|
||||
═══════════════════════════════════════════════════════════════════════
|
||||
`
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// LOG FINAL DE TOUT CE QUI EST ENVOYÉ À L'IA
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
console.log("\n" + "═".repeat(70));
|
||||
console.log("📤 RÉCAPITULATIF FINAL - ENVOI À GEMINI");
|
||||
console.log("═".repeat(70));
|
||||
console.log(`🤖 Modèle: ${MODELS.generator}`);
|
||||
console.log(`📐 Config: ${IMAGE_CONFIG.imageSize} @ ${IMAGE_CONFIG.aspectRatio}`);
|
||||
console.log("");
|
||||
|
||||
// Compter et lister les éléments
|
||||
let contentIndex = 0;
|
||||
for (const content of contents) {
|
||||
contentIndex++;
|
||||
if (content.text) {
|
||||
const textPreview = content.text.length > 100
|
||||
? content.text.substring(0, 100) + "..."
|
||||
: content.text;
|
||||
console.log(`📝 [${contentIndex}] TEXTE (${content.text.length} chars): "${textPreview.replace(/\n/g, ' ')}"`);
|
||||
} else if (content.inlineData) {
|
||||
const sizeKB = (content.inlineData.data.length * 0.75 / 1024).toFixed(0);
|
||||
const type = content.inlineData.mimeType;
|
||||
|
||||
// Identifier le type d'image
|
||||
let imageType = "Image";
|
||||
if (contentIndex === 2) imageType = "🏠 IMAGE ORIGINALE";
|
||||
else if (contentIndex <= 2 + referenceImages.length) imageType = `🎨 RÉFÉRENCE ${contentIndex - 2}`;
|
||||
else if (hasMask && contentIndex === 2 + referenceImages.length + 1) imageType = "🎭 MASQUE FUSIONNÉ ANNOTÉ";
|
||||
|
||||
console.log(`🖼️ [${contentIndex}] ${imageType} (${type}, ~${sizeKB} KB)`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(`📊 TOTAL: ${contents.length} éléments`);
|
||||
console.log(` • 1 image originale`);
|
||||
console.log(` • ${referenceImages.length} image(s) de référence`);
|
||||
console.log(` • ${hasMask ? "1 masque fusionné annoté" : "0 masque"}`);
|
||||
console.log(` • ${contents.filter(c => c.text).length} bloc(s) de texte`);
|
||||
console.log("═".repeat(70) + "\n");
|
||||
|
||||
// Configuration de l'API
|
||||
const apiConfig: any = {
|
||||
responseModalities: ["TEXT", "IMAGE"],
|
||||
imageConfig: {
|
||||
aspectRatio: IMAGE_CONFIG.aspectRatio,
|
||||
imageSize: IMAGE_CONFIG.imageSize,
|
||||
},
|
||||
};
|
||||
|
||||
// Appel avec configuration avancée Nano Banana Pro
|
||||
const response = await ai.models.generateContent({
|
||||
model: MODELS.generator,
|
||||
contents: contents,
|
||||
config: apiConfig,
|
||||
});
|
||||
|
||||
if (!response.candidates || response.candidates.length === 0) {
|
||||
throw new Error("Réponse vide de l'API Gemini");
|
||||
}
|
||||
|
||||
const parts = response.candidates[0].content?.parts || [];
|
||||
let generatedImagePath = "";
|
||||
let description = "";
|
||||
let thoughtCount = 0;
|
||||
|
||||
// Traitement des parties (inclut les "thought" images de Nano Banana Pro)
|
||||
for (const part of parts) {
|
||||
// Ignorer les images de "thinking" (intermédiaires)
|
||||
if ((part as any).thought) {
|
||||
thoughtCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.inlineData?.data) {
|
||||
const imageData = part.inlineData.data;
|
||||
const mimeType = part.inlineData.mimeType || "image/png";
|
||||
const extension = mimeType.split("/")[1]?.replace("jpeg", "jpg") || "png";
|
||||
|
||||
const fileName = `generated_${generationId}.${extension}`;
|
||||
const imageBuffer = Buffer.from(imageData as string, "base64");
|
||||
|
||||
// Sauvegarder sur S3
|
||||
generatedImagePath = await saveBuffer(imageBuffer, fileName, "generated");
|
||||
|
||||
console.log(
|
||||
` 💾 Sauvegardé sur S3: ${fileName} (${(
|
||||
imageBuffer.length / 1024
|
||||
).toFixed(0)} KB)`
|
||||
);
|
||||
if (thoughtCount > 0) {
|
||||
console.log(
|
||||
` 🧠 Mode Thinking: ${thoughtCount} image(s) intermédiaire(s) générée(s)`
|
||||
);
|
||||
}
|
||||
} else if (part.text) {
|
||||
description = part.text;
|
||||
}
|
||||
}
|
||||
|
||||
if (!generatedImagePath) {
|
||||
throw new Error(
|
||||
`Pas d'image générée. Réponse: ${
|
||||
description?.substring(0, 300) || "vide"
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
imagePath: generatedImagePath,
|
||||
description: description || "Image générée avec succès",
|
||||
thoughtCount,
|
||||
};
|
||||
}
|
||||
228
src/lib/gemini/agents/mask-analyzer.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
// ============================================================================
|
||||
// AGENT ANALYSEUR DE MASQUE
|
||||
// ============================================================================
|
||||
// Analyse un masque dessiné par l'utilisateur pour :
|
||||
// 1. Identifier précisément quelle zone/élément il délimite
|
||||
// 2. Améliorer l'instruction utilisateur en fonction du masque réel
|
||||
// 3. Corriger les incohérences (ex: "tout le sol" mais masque partiel)
|
||||
// ============================================================================
|
||||
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
import { MODELS } from "../config";
|
||||
import type { PreparedImage, ImageAnalysis } from "../types";
|
||||
|
||||
// Client AI
|
||||
const ai = new GoogleGenAI({
|
||||
apiKey:
|
||||
import.meta.env.AI_GOOGLE_API_KEY || process.env.AI_GOOGLE_API_KEY || "",
|
||||
});
|
||||
|
||||
/**
|
||||
* Résultat de l'analyse du masque
|
||||
*/
|
||||
export interface MaskAnalysisResult {
|
||||
/** Description de la zone délimitée par le masque */
|
||||
zoneDescription: string;
|
||||
/** Type d'élément couvert (surface, objet, etc.) */
|
||||
elementType: "surface" | "object" | "area" | "multiple";
|
||||
/** Éléments spécifiques identifiés dans le masque */
|
||||
elementsInMask: string[];
|
||||
/** Position relative dans l'image */
|
||||
position: {
|
||||
horizontal: "left" | "center" | "right" | "full-width";
|
||||
vertical: "top" | "middle" | "bottom" | "full-height";
|
||||
};
|
||||
/** Pourcentage approximatif de l'image couvert */
|
||||
coveragePercent: number;
|
||||
/** Est-ce une zone partielle ou totale? */
|
||||
isPartial: boolean;
|
||||
/** Suggestions pour améliorer l'instruction */
|
||||
instructionCorrections: string[];
|
||||
/** Instruction améliorée basée sur le masque */
|
||||
improvedInstruction: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt pour analyser le masque avec l'image originale
|
||||
*/
|
||||
const MASK_ANALYSIS_PROMPT = `Tu es un EXPERT en analyse d'image et de zones délimitées.
|
||||
|
||||
Je te fournis DEUX images:
|
||||
1. L'IMAGE ORIGINALE de l'espace (intérieur/extérieur)
|
||||
2. Un MASQUE où la zone BLANCHE représente la zone sélectionnée par l'utilisateur
|
||||
|
||||
L'INSTRUCTION DE L'UTILISATEUR pour cette zone est: "{instruction}"
|
||||
|
||||
ANALYSE LE MASQUE et réponds à ces questions:
|
||||
|
||||
1. **ZONE DÉLIMITÉE** - Quelle partie de l'image le masque sélectionne-t-il?
|
||||
- Décris précisément la zone (ex: "partie droite du sol", "mur du fond", "coin gauche du toit")
|
||||
|
||||
2. **TYPE D'ÉLÉMENT** - Qu'est-ce qui est sélectionné?
|
||||
- "surface" = mur, sol, plafond, toit, façade
|
||||
- "object" = meuble, luminaire, plante, équipement
|
||||
- "area" = zone générale sans élément précis
|
||||
- "multiple" = plusieurs éléments différents
|
||||
|
||||
3. **ÉLÉMENTS IDENTIFIÉS** - Liste les éléments/surfaces visibles dans la zone blanche
|
||||
|
||||
4. **POSITION**
|
||||
- Horizontal: left (gauche), center (centre), right (droite), full-width (toute la largeur)
|
||||
- Vertical: top (haut), middle (milieu), bottom (bas), full-height (toute la hauteur)
|
||||
|
||||
5. **COUVERTURE** - Quel pourcentage approximatif de l'image le masque couvre-t-il? (0-100)
|
||||
|
||||
6. **PARTIEL OU TOTAL** - Le masque couvre-t-il:
|
||||
- Une PARTIE d'un élément (ex: partie droite du sol) = partial
|
||||
- La TOTALITÉ d'un élément (ex: tout le mur du fond) = total
|
||||
|
||||
7. **CORRECTIONS À L'INSTRUCTION**
|
||||
- L'instruction "{instruction}" correspond-elle au masque?
|
||||
- Si l'instruction dit "tout le sol" mais le masque ne couvre qu'une partie → corriger
|
||||
- Si l'instruction est vague mais le masque est précis → préciser
|
||||
|
||||
8. **INSTRUCTION AMÉLIORÉE**
|
||||
- Reformule l'instruction pour qu'elle corresponde EXACTEMENT au masque
|
||||
- Sois PRÉCIS sur la zone: "partie droite du sol" au lieu de "le sol"
|
||||
|
||||
Réponds UNIQUEMENT avec ce JSON (sans markdown, sans backticks):
|
||||
{
|
||||
"zoneDescription": "Description précise de la zone délimitée par le masque",
|
||||
"elementType": "surface | object | area | multiple",
|
||||
"elementsInMask": ["sol", "partie du tapis", ...],
|
||||
"position": {
|
||||
"horizontal": "left | center | right | full-width",
|
||||
"vertical": "top | middle | bottom | full-height"
|
||||
},
|
||||
"coveragePercent": 25,
|
||||
"isPartial": true,
|
||||
"instructionCorrections": [
|
||||
"L'instruction mentionne 'tout le sol' mais le masque ne couvre que la partie droite",
|
||||
"Préciser: 'partie droite du sol' au lieu de 'sol'"
|
||||
],
|
||||
"improvedInstruction": "Instruction reformulée précise correspondant exactement au masque"
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Analyse un masque pour comprendre quelle zone il délimite
|
||||
*/
|
||||
export async function analyzeMaskZone(
|
||||
originalImage: PreparedImage,
|
||||
maskImage: PreparedImage,
|
||||
userInstruction: string,
|
||||
imageAnalysis?: ImageAnalysis
|
||||
): Promise<MaskAnalysisResult> {
|
||||
console.log(" 🎭 Analyse du masque en cours...");
|
||||
console.log(` Instruction utilisateur: "${userInstruction}"`);
|
||||
|
||||
const prompt = MASK_ANALYSIS_PROMPT.replace(/{instruction}/g, userInstruction);
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: MODELS.analyzer,
|
||||
contents: [
|
||||
{
|
||||
parts: [
|
||||
{ text: prompt },
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: originalImage.mimeType,
|
||||
data: originalImage.base64,
|
||||
},
|
||||
},
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: maskImage.mimeType,
|
||||
data: maskImage.base64,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const text = response.candidates?.[0]?.content?.parts?.[0]?.text || "";
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
|
||||
console.log(` ✓ Zone identifiée: ${parsed.zoneDescription}`);
|
||||
console.log(` ✓ Type: ${parsed.elementType}`);
|
||||
console.log(` ✓ Couverture: ${parsed.coveragePercent}%`);
|
||||
console.log(` ✓ Partiel: ${parsed.isPartial}`);
|
||||
if (parsed.instructionCorrections?.length > 0) {
|
||||
console.log(` ⚠️ Corrections: ${parsed.instructionCorrections.length}`);
|
||||
}
|
||||
console.log(` → Instruction améliorée: "${parsed.improvedInstruction}"`);
|
||||
|
||||
return {
|
||||
zoneDescription: parsed.zoneDescription || "Zone non identifiée",
|
||||
elementType: parsed.elementType || "area",
|
||||
elementsInMask: parsed.elementsInMask || [],
|
||||
position: parsed.position || { horizontal: "center", vertical: "middle" },
|
||||
coveragePercent: parsed.coveragePercent || 50,
|
||||
isPartial: parsed.isPartial !== false,
|
||||
instructionCorrections: parsed.instructionCorrections || [],
|
||||
improvedInstruction: parsed.improvedInstruction || userInstruction,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(" ❌ Erreur lors de l'analyse du masque:", error);
|
||||
}
|
||||
|
||||
// Fallback si l'analyse échoue
|
||||
return {
|
||||
zoneDescription: "Zone sélectionnée par l'utilisateur",
|
||||
elementType: "area",
|
||||
elementsInMask: [],
|
||||
position: { horizontal: "center", vertical: "middle" },
|
||||
coveragePercent: 50,
|
||||
isPartial: true,
|
||||
instructionCorrections: [],
|
||||
improvedInstruction: userInstruction,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine l'instruction originale avec l'analyse du masque
|
||||
* pour créer une instruction optimale
|
||||
*/
|
||||
export function buildMaskAwareInstruction(
|
||||
originalInstruction: string,
|
||||
maskAnalysis: MaskAnalysisResult,
|
||||
referenceName?: string
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Utiliser l'instruction améliorée comme base
|
||||
parts.push(maskAnalysis.improvedInstruction);
|
||||
|
||||
// Ajouter des précisions si le masque est partiel
|
||||
if (maskAnalysis.isPartial) {
|
||||
parts.push(`[ZONE PRÉCISE: ${maskAnalysis.zoneDescription}]`);
|
||||
}
|
||||
|
||||
// Ajouter la position si elle est spécifique
|
||||
if (maskAnalysis.position.horizontal !== "full-width" || maskAnalysis.position.vertical !== "full-height") {
|
||||
const posDesc = [];
|
||||
if (maskAnalysis.position.horizontal !== "full-width") {
|
||||
posDesc.push(maskAnalysis.position.horizontal === "left" ? "côté gauche" :
|
||||
maskAnalysis.position.horizontal === "right" ? "côté droit" : "au centre");
|
||||
}
|
||||
if (maskAnalysis.position.vertical !== "full-height") {
|
||||
posDesc.push(maskAnalysis.position.vertical === "top" ? "partie haute" :
|
||||
maskAnalysis.position.vertical === "bottom" ? "partie basse" : "au milieu");
|
||||
}
|
||||
if (posDesc.length > 0) {
|
||||
parts.push(`[POSITION: ${posDesc.join(", ")}]`);
|
||||
}
|
||||
}
|
||||
|
||||
// Ajouter la couverture si elle est partielle
|
||||
if (maskAnalysis.coveragePercent < 80) {
|
||||
parts.push(`[COUVERTURE: ~${maskAnalysis.coveragePercent}% de la zone visible]`);
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
594
src/lib/gemini/agents/planner.ts
Normal file
@@ -0,0 +1,594 @@
|
||||
// ============================================================================
|
||||
// AGENT PLANIFICATEUR - COMPRÉHENSION INTELLIGENTE DES INSTRUCTIONS
|
||||
// ============================================================================
|
||||
// Analyse VRAIMENT les instructions utilisateur pour comprendre:
|
||||
// - L'ACTION demandée (ajouter, remplacer, appliquer, modifier)
|
||||
// - LA QUANTITÉ (1, 3, plusieurs, etc.)
|
||||
// - LA ZONE PRÉCISE (partie droite, côté gauche, au centre, etc.)
|
||||
// - L'ÉLÉMENT concerné (panneaux solaires, meubles, etc.)
|
||||
// ============================================================================
|
||||
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
import { MODELS } from "../config";
|
||||
import { analyzeReferenceImage } from "./reference-analyzer";
|
||||
import type {
|
||||
ImageAnalysis,
|
||||
ModificationPlan,
|
||||
ModificationTask,
|
||||
GenerationInstruction,
|
||||
PreparedImage,
|
||||
ReferenceAnalysis,
|
||||
SurfaceInfo,
|
||||
ObjectInfo,
|
||||
} from "../types";
|
||||
|
||||
// Client AI
|
||||
const ai = new GoogleGenAI({
|
||||
apiKey:
|
||||
import.meta.env.AI_GOOGLE_API_KEY || process.env.AI_GOOGLE_API_KEY || "",
|
||||
});
|
||||
|
||||
/**
|
||||
* Interface pour l'interprétation enrichie d'une instruction
|
||||
*/
|
||||
interface EnrichedInstruction {
|
||||
originalText: string;
|
||||
action: "add" | "replace" | "apply_texture" | "remove" | "modify";
|
||||
quantity: number | "all" | "some" | "partial";
|
||||
quantityText: string; // "3 panneaux", "tous les murs", etc.
|
||||
targetElement: string; // "panneau solaire", "table", "parquet", etc.
|
||||
targetZone: string; // "partie droite du toit", "mur nord", etc.
|
||||
zoneConstraints: {
|
||||
side?: "left" | "right" | "center" | "top" | "bottom";
|
||||
area?: "partial" | "full" | "specific";
|
||||
description: string;
|
||||
};
|
||||
style?: string;
|
||||
color?: string;
|
||||
additionalNotes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt pour parser intelligemment l'instruction utilisateur
|
||||
*/
|
||||
const INSTRUCTION_PARSER_PROMPT = `Tu es un EXPERT en compréhension du langage naturel pour des projets d'aménagement.
|
||||
|
||||
MISSION: Analyser cette instruction utilisateur et extraire PRÉCISÉMENT toutes les informations demandées.
|
||||
|
||||
INSTRUCTION À ANALYSER: "{instruction}"
|
||||
TYPE DE RÉFÉRENCE DÉTECTÉ: {referenceType} ({referenceCategory})
|
||||
|
||||
EXTRACTION REQUISE:
|
||||
|
||||
1. **ACTION** - Que veut faire l'utilisateur?
|
||||
- "add" = AJOUTER un nouvel élément (qui n'existe pas encore)
|
||||
- "replace" = REMPLACER un élément existant par un autre
|
||||
- "apply_texture" = APPLIQUER un matériau/texture sur une SURFACE (mur, sol, toit)
|
||||
- "remove" = SUPPRIMER un élément
|
||||
- "modify" = MODIFIER un élément existant (couleur, taille, etc.)
|
||||
|
||||
2. **QUANTITÉ** - Combien d'éléments?
|
||||
- Nombre précis (1, 2, 3, 10...)
|
||||
- "all" = tous, l'ensemble
|
||||
- "some" = quelques-uns, plusieurs
|
||||
- "partial" = une partie seulement
|
||||
|
||||
3. **ÉLÉMENT CIBLE** - Sur quoi agir?
|
||||
- Identifier l'élément mentionné (panneau solaire, table, mur, toit, etc.)
|
||||
|
||||
4. **ZONE PRÉCISE** - OÙ exactement?
|
||||
- Extraire la position/zone mentionnée
|
||||
- Identifier le côté (gauche, droite, centre, haut, bas)
|
||||
- Identifier si c'est partiel ou total
|
||||
|
||||
INDICES LEXICAUX:
|
||||
- "ajoute", "met", "installe", "pose" → action = "add"
|
||||
- "remplace", "change", "substitue" → action = "replace"
|
||||
- "applique", "peins", "recouvre", "tapisse" → action = "apply_texture"
|
||||
- "enlève", "supprime", "retire" → action = "remove"
|
||||
- "3 panneaux", "deux chaises", "une table" → quantité précise
|
||||
- "tous les murs", "tout le sol" → quantité = "all"
|
||||
- "une partie", "une section", "un coin" → quantité = "partial"
|
||||
- "à droite", "côté droit", "partie droite" → side = "right"
|
||||
- "à gauche", "côté gauche", "partie gauche" → side = "left"
|
||||
- "au centre", "au milieu" → side = "center"
|
||||
- "en haut", "partie supérieure" → side = "top"
|
||||
- "en bas", "partie inférieure" → side = "bottom"
|
||||
|
||||
Réponds UNIQUEMENT avec ce JSON (sans markdown, sans backticks):
|
||||
{
|
||||
"action": "add | replace | apply_texture | remove | modify",
|
||||
"quantity": 3,
|
||||
"quantityText": "3 panneaux solaires",
|
||||
"targetElement": "panneau solaire",
|
||||
"targetZone": "partie droite du toit",
|
||||
"zoneConstraints": {
|
||||
"side": "right",
|
||||
"area": "partial",
|
||||
"description": "Uniquement sur la section droite de la toiture, pas sur tout le toit"
|
||||
},
|
||||
"style": "style si mentionné",
|
||||
"color": "couleur si mentionnée",
|
||||
"additionalNotes": [
|
||||
"Note importante extraite de l'instruction"
|
||||
]
|
||||
}`;
|
||||
|
||||
/**
|
||||
* Agent Planificateur - Crée le plan de modification avec compréhension intelligente
|
||||
*/
|
||||
export async function planModificationsWithAgent(
|
||||
analysis: ImageAnalysis,
|
||||
instructions: GenerationInstruction[],
|
||||
referenceImages: PreparedImage[],
|
||||
originalImage?: PreparedImage,
|
||||
maskImages?: (PreparedImage | null)[]
|
||||
): Promise<ModificationPlan> {
|
||||
console.log(" 📋 Agent Planificateur: Analyse intelligente des modifications...");
|
||||
|
||||
// 1. Analyser chaque image de référence
|
||||
console.log("\n 🔍 Étape 1: Analyse des images de référence...");
|
||||
const referenceAnalyses: ReferenceAnalysis[] = [];
|
||||
for (let i = 0; i < referenceImages.length; i++) {
|
||||
console.log(` 📷 Analyse référence ${i + 1}/${referenceImages.length}...`);
|
||||
const refAnalysis = await analyzeReferenceImage(referenceImages[i]);
|
||||
referenceAnalyses.push(refAnalysis);
|
||||
}
|
||||
|
||||
// 1b. Analyser les masques si présents
|
||||
console.log("\n 🎭 Étape 1b: Analyse des masques...");
|
||||
const maskAnalyses: (import("./mask-analyzer").MaskAnalysisResult | null)[] = [];
|
||||
|
||||
if (maskImages && originalImage) {
|
||||
const { analyzeMaskZone } = await import("./mask-analyzer");
|
||||
|
||||
for (let i = 0; i < instructions.length; i++) {
|
||||
const mask = maskImages[i];
|
||||
if (mask) {
|
||||
console.log(` 🎭 Analyse masque ${i + 1}: "${instructions[i].location}"`);
|
||||
const maskAnalysis = await analyzeMaskZone(
|
||||
originalImage,
|
||||
mask,
|
||||
instructions[i].location,
|
||||
analysis
|
||||
);
|
||||
maskAnalyses.push(maskAnalysis);
|
||||
|
||||
// Mettre à jour l'instruction avec l'analyse du masque
|
||||
instructions[i].maskAnalysis = {
|
||||
zoneDescription: maskAnalysis.zoneDescription,
|
||||
elementType: maskAnalysis.elementType,
|
||||
elementsInMask: maskAnalysis.elementsInMask,
|
||||
position: maskAnalysis.position,
|
||||
coveragePercent: maskAnalysis.coveragePercent,
|
||||
isPartial: maskAnalysis.isPartial,
|
||||
instructionCorrections: maskAnalysis.instructionCorrections,
|
||||
};
|
||||
instructions[i].improvedLocation = maskAnalysis.improvedInstruction;
|
||||
|
||||
console.log(` ✓ Zone: ${maskAnalysis.zoneDescription}`);
|
||||
console.log(` ✓ Instruction améliorée: "${maskAnalysis.improvedInstruction}"`);
|
||||
} else {
|
||||
maskAnalyses.push(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Parser chaque instruction de manière intelligente
|
||||
console.log("\n 🧠 Étape 2: Compréhension intelligente des instructions...");
|
||||
const enrichedInstructions: EnrichedInstruction[] = [];
|
||||
|
||||
for (let i = 0; i < instructions.length; i++) {
|
||||
const instr = instructions[i];
|
||||
const refAnalysis = referenceAnalyses[i];
|
||||
const maskAnalysis = maskAnalyses[i];
|
||||
|
||||
// Utiliser l'instruction améliorée par le masque si disponible
|
||||
const instructionToUse = instr.improvedLocation || instr.location;
|
||||
console.log(` 💬 Parsing instruction ${i + 1}: "${instructionToUse}"`);
|
||||
|
||||
const enriched = await parseInstructionIntelligently(
|
||||
instructionToUse,
|
||||
refAnalysis,
|
||||
maskAnalysis || undefined
|
||||
);
|
||||
|
||||
// Enrichir avec les infos du masque si disponibles
|
||||
if (maskAnalysis) {
|
||||
enriched.zoneConstraints = {
|
||||
...enriched.zoneConstraints,
|
||||
side: maskAnalysis.position.horizontal === "full-width" ? undefined :
|
||||
maskAnalysis.position.horizontal as "left" | "right" | "center",
|
||||
area: maskAnalysis.isPartial ? "partial" : "full",
|
||||
description: maskAnalysis.zoneDescription,
|
||||
};
|
||||
enriched.targetZone = maskAnalysis.zoneDescription;
|
||||
}
|
||||
|
||||
enrichedInstructions.push(enriched);
|
||||
|
||||
console.log(` → Action: ${enriched.action.toUpperCase()}`);
|
||||
console.log(` → Quantité: ${enriched.quantityText}`);
|
||||
console.log(` → Élément: ${enriched.targetElement}`);
|
||||
console.log(` → Zone: ${enriched.targetZone}`);
|
||||
if (enriched.zoneConstraints.side) {
|
||||
console.log(` → Côté: ${enriched.zoneConstraints.side}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Mapper intelligemment sur les éléments analysés
|
||||
console.log("\n 🗺️ Étape 3: Mapping sur les éléments identifiés...");
|
||||
const tasks = buildTasksFromEnrichedInstructions(
|
||||
analysis,
|
||||
instructions,
|
||||
referenceAnalyses,
|
||||
enrichedInstructions
|
||||
);
|
||||
|
||||
console.log(`\n ✓ ${tasks.length} tâches de modification planifiées:`);
|
||||
for (const task of tasks) {
|
||||
const targetName = task.targetSurface?.name || task.targetObject?.name || task.targetZone || "Zone spécifiée";
|
||||
const emoji =
|
||||
task.actionType === "replace_object"
|
||||
? "🔄"
|
||||
: task.actionType === "add_element"
|
||||
? "➕"
|
||||
: "🎨";
|
||||
const qtyInfo = task.quantity ? ` (x${task.quantity})` : "";
|
||||
const maskInfo = task.hasMask ? " 🎭" : "";
|
||||
console.log(` ${emoji} ${task.actionType}: ${targetName}${qtyInfo}${maskInfo} → ${task.targetMaterial}`);
|
||||
}
|
||||
|
||||
// 4. Construire le prompt optimisé
|
||||
const { buildOptimizedPrompt } = await import("../prompts/builder");
|
||||
const globalPrompt = buildOptimizedPrompt(
|
||||
analysis,
|
||||
tasks,
|
||||
instructions,
|
||||
referenceAnalyses
|
||||
);
|
||||
|
||||
return {
|
||||
originalAnalysis: analysis,
|
||||
tasks,
|
||||
globalPrompt,
|
||||
enrichedInstructions,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parse une instruction de manière intelligente avec l'IA
|
||||
*/
|
||||
async function parseInstructionIntelligently(
|
||||
instructionText: string,
|
||||
refAnalysis: ReferenceAnalysis,
|
||||
maskAnalysis?: import("./mask-analyzer").MaskAnalysisResult
|
||||
): Promise<EnrichedInstruction> {
|
||||
// Si on a une analyse de masque, enrichir le prompt avec ces infos
|
||||
let additionalContext = "";
|
||||
if (maskAnalysis) {
|
||||
additionalContext = `
|
||||
CONTEXTE MASQUE (zone délimitée par l'utilisateur):
|
||||
- Zone identifiée: ${maskAnalysis.zoneDescription}
|
||||
- Position: ${maskAnalysis.position.horizontal} / ${maskAnalysis.position.vertical}
|
||||
- Couverture: ${maskAnalysis.coveragePercent}%
|
||||
- Partiel: ${maskAnalysis.isPartial ? "OUI" : "NON"}
|
||||
- Éléments dans le masque: ${maskAnalysis.elementsInMask.join(", ")}
|
||||
|
||||
⚠️ UTILISE CES INFORMATIONS pour préciser la zone et corriger l'instruction si nécessaire!`;
|
||||
}
|
||||
|
||||
const prompt = INSTRUCTION_PARSER_PROMPT
|
||||
.replace("{instruction}", instructionText)
|
||||
.replace("{referenceType}", refAnalysis?.type || "unknown")
|
||||
.replace("{referenceCategory}", refAnalysis?.category || "unknown")
|
||||
+ additionalContext;
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: MODELS.analyzer,
|
||||
contents: [{ text: prompt }],
|
||||
});
|
||||
|
||||
const text = response.candidates?.[0]?.content?.parts?.[0]?.text || "";
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
return {
|
||||
originalText: instructionText,
|
||||
action: parsed.action || "add",
|
||||
quantity: parsed.quantity || 1,
|
||||
quantityText: parsed.quantityText || "1 élément",
|
||||
targetElement: parsed.targetElement || "élément",
|
||||
targetZone: parsed.targetZone || "zone non spécifiée",
|
||||
zoneConstraints: parsed.zoneConstraints || { area: "full", description: "" },
|
||||
style: parsed.style,
|
||||
color: parsed.color,
|
||||
additionalNotes: parsed.additionalNotes || [],
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(" ⚠️ Parsing intelligent échoué, analyse basique...");
|
||||
}
|
||||
|
||||
// Fallback: parsing basique par mots-clés
|
||||
return parseInstructionBasic(instructionText, refAnalysis);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsing basique par mots-clés (fallback)
|
||||
*/
|
||||
function parseInstructionBasic(
|
||||
instructionText: string,
|
||||
refAnalysis: ReferenceAnalysis
|
||||
): EnrichedInstruction {
|
||||
const text = instructionText.toLowerCase();
|
||||
|
||||
// Détecter l'action
|
||||
let action: EnrichedInstruction["action"] = "add";
|
||||
if (text.includes("remplace") || text.includes("change")) {
|
||||
action = "replace";
|
||||
} else if (text.includes("applique") || text.includes("peins") || text.includes("recouvre")) {
|
||||
action = "apply_texture";
|
||||
} else if (text.includes("ajoute") || text.includes("met") || text.includes("installe") || text.includes("pose")) {
|
||||
action = "add";
|
||||
} else if (text.includes("enlève") || text.includes("supprime")) {
|
||||
action = "remove";
|
||||
}
|
||||
|
||||
// Forcer add pour les objets si refAnalysis indique un objet
|
||||
if (refAnalysis?.type === "product_photo" || refAnalysis?.type === "3d_render" ||
|
||||
refAnalysis?.type === "furniture_photo" || refAnalysis?.type === "technical_equipment") {
|
||||
if (action === "apply_texture") {
|
||||
action = "add";
|
||||
}
|
||||
}
|
||||
|
||||
// Détecter la quantité
|
||||
let quantity: number | "all" | "some" | "partial" = 1;
|
||||
let quantityText = "1 élément";
|
||||
|
||||
const numberMatch = text.match(/(\d+)\s*(panneau|table|chaise|meuble|lampe|plante|arbre|volet|fenêtre|porte)/i);
|
||||
if (numberMatch) {
|
||||
quantity = parseInt(numberMatch[1]);
|
||||
quantityText = numberMatch[0];
|
||||
} else if (text.includes("tous les") || text.includes("tout le") || text.includes("toute la")) {
|
||||
quantity = "all";
|
||||
quantityText = "tous les éléments";
|
||||
} else if (text.includes("une partie") || text.includes("section") || text.includes("partie")) {
|
||||
quantity = "partial";
|
||||
quantityText = "une partie";
|
||||
}
|
||||
|
||||
// Détecter le côté
|
||||
let side: "left" | "right" | "center" | "top" | "bottom" | undefined;
|
||||
if (text.includes("droite") || text.includes("droit")) {
|
||||
side = "right";
|
||||
} else if (text.includes("gauche")) {
|
||||
side = "left";
|
||||
} else if (text.includes("centre") || text.includes("milieu")) {
|
||||
side = "center";
|
||||
} else if (text.includes("haut") || text.includes("supérieur")) {
|
||||
side = "top";
|
||||
} else if (text.includes("bas") || text.includes("inférieur")) {
|
||||
side = "bottom";
|
||||
}
|
||||
|
||||
return {
|
||||
originalText: instructionText,
|
||||
action,
|
||||
quantity,
|
||||
quantityText,
|
||||
targetElement: refAnalysis?.category || "élément",
|
||||
targetZone: instructionText,
|
||||
zoneConstraints: {
|
||||
side,
|
||||
area: quantity === "partial" ? "partial" : quantity === "all" ? "full" : "specific",
|
||||
description: instructionText,
|
||||
},
|
||||
additionalNotes: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit les tâches à partir des instructions enrichies
|
||||
*/
|
||||
function buildTasksFromEnrichedInstructions(
|
||||
analysis: ImageAnalysis,
|
||||
instructions: GenerationInstruction[],
|
||||
referenceAnalyses: ReferenceAnalysis[],
|
||||
enrichedInstructions: EnrichedInstruction[]
|
||||
): ModificationTask[] {
|
||||
const tasks: ModificationTask[] = [];
|
||||
|
||||
for (let i = 0; i < enrichedInstructions.length; i++) {
|
||||
const enriched = enrichedInstructions[i];
|
||||
const instruction = instructions[i];
|
||||
const refAnalysis = referenceAnalyses[i];
|
||||
|
||||
// Déterminer le type d'action pour le système
|
||||
let actionType: "apply_texture" | "replace_object" | "add_element" = "add_element";
|
||||
|
||||
if (enriched.action === "apply_texture") {
|
||||
actionType = "apply_texture";
|
||||
} else if (enriched.action === "replace") {
|
||||
actionType = "replace_object";
|
||||
} else if (enriched.action === "add") {
|
||||
actionType = "add_element";
|
||||
}
|
||||
|
||||
// Pour les OBJETS (panneaux solaires, meubles, etc.) → TOUJOURS add_element ou replace_object
|
||||
const isObjectReference =
|
||||
refAnalysis?.type === "product_photo" ||
|
||||
refAnalysis?.type === "3d_render" ||
|
||||
refAnalysis?.type === "furniture_photo" ||
|
||||
refAnalysis?.type === "technical_equipment" ||
|
||||
refAnalysis?.type === "lighting_fixture" ||
|
||||
refAnalysis?.type === "plant_photo" ||
|
||||
refAnalysis?.type === "architectural_element";
|
||||
|
||||
if (isObjectReference && actionType === "apply_texture") {
|
||||
actionType = "add_element";
|
||||
}
|
||||
|
||||
// Chercher une zone/surface correspondante dans l'analyse
|
||||
const targetZone = findBestMatchingZone(analysis, enriched);
|
||||
|
||||
// Construire la description de positionnement détaillée
|
||||
const positionDescription = buildPositionDescription(enriched);
|
||||
|
||||
// Vérifier si un masque est présent pour cette instruction
|
||||
const hasMask = !!instruction.maskImagePath;
|
||||
const maskAnalysis = instruction.maskAnalysis;
|
||||
|
||||
tasks.push({
|
||||
priority: i,
|
||||
targetSurface: targetZone.surface,
|
||||
targetObject: targetZone.object,
|
||||
targetZone: enriched.targetZone,
|
||||
targetMaterial: instruction.referenceName || refAnalysis?.category || "référence",
|
||||
referenceIndex: i,
|
||||
specificInstructions: instruction.improvedLocation || enriched.originalText,
|
||||
actionType,
|
||||
referenceAnalysis: refAnalysis,
|
||||
elementCategory: targetZone.surface?.category || targetZone.object?.category,
|
||||
quantity: typeof enriched.quantity === "number" ? enriched.quantity : undefined,
|
||||
quantityText: enriched.quantityText,
|
||||
positionConstraints: {
|
||||
side: enriched.zoneConstraints.side,
|
||||
area: enriched.zoneConstraints.area,
|
||||
description: positionDescription,
|
||||
},
|
||||
enrichedInstruction: enriched,
|
||||
hasMask,
|
||||
maskAnalysis,
|
||||
combinedMaskBase64: instruction.combinedMaskBase64,
|
||||
});
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve la meilleure zone correspondante dans l'analyse
|
||||
*/
|
||||
function findBestMatchingZone(
|
||||
analysis: ImageAnalysis,
|
||||
enriched: EnrichedInstruction
|
||||
): { surface?: SurfaceInfo; object?: ObjectInfo } {
|
||||
const text = enriched.originalText.toLowerCase();
|
||||
const element = enriched.targetElement.toLowerCase();
|
||||
|
||||
// Chercher dans les surfaces
|
||||
for (const surface of analysis.surfaces || []) {
|
||||
const nameLower = surface.name.toLowerCase();
|
||||
const idLower = surface.id.toLowerCase();
|
||||
const catLower = (surface.category || "").toLowerCase();
|
||||
|
||||
// Match par mot-clé dans l'instruction
|
||||
if (
|
||||
(text.includes("toit") || text.includes("toiture") || element.includes("toit")) &&
|
||||
(catLower.includes("roof") || idLower.includes("roof") || nameLower.includes("toit"))
|
||||
) {
|
||||
// Affiner par côté si spécifié
|
||||
if (enriched.zoneConstraints.side === "right" &&
|
||||
(nameLower.includes("avant") || nameLower.includes("front"))) {
|
||||
return { surface };
|
||||
}
|
||||
if (enriched.zoneConstraints.side === "left" &&
|
||||
(nameLower.includes("arrière") || nameLower.includes("rear"))) {
|
||||
return { surface };
|
||||
}
|
||||
// Si pas de côté spécifié ou pas de match exact, retourner quand même
|
||||
return { surface };
|
||||
}
|
||||
|
||||
if (
|
||||
(text.includes("mur") || text.includes("wall")) &&
|
||||
(catLower === "wall" || idLower.includes("wall"))
|
||||
) {
|
||||
return { surface };
|
||||
}
|
||||
|
||||
if (
|
||||
(text.includes("sol") || text.includes("floor") || text.includes("parquet")) &&
|
||||
(catLower === "floor" || idLower.includes("floor"))
|
||||
) {
|
||||
return { surface };
|
||||
}
|
||||
|
||||
if (
|
||||
(text.includes("façade") || text.includes("facade")) &&
|
||||
(catLower.includes("facade") || idLower.includes("facade"))
|
||||
) {
|
||||
return { surface };
|
||||
}
|
||||
}
|
||||
|
||||
// Chercher dans les objets
|
||||
for (const obj of analysis.objects || []) {
|
||||
const nameLower = obj.name.toLowerCase();
|
||||
const catLower = (obj.category || "").toLowerCase();
|
||||
|
||||
if (
|
||||
(text.includes("table") || element.includes("table")) &&
|
||||
(catLower.includes("table") || nameLower.includes("table"))
|
||||
) {
|
||||
return { object: obj };
|
||||
}
|
||||
|
||||
if (
|
||||
(text.includes("chaise") || element.includes("chaise")) &&
|
||||
(catLower.includes("chair") || nameLower.includes("chaise"))
|
||||
) {
|
||||
return { object: obj };
|
||||
}
|
||||
|
||||
// ... autres matchings
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit une description de positionnement détaillée pour le prompt
|
||||
*/
|
||||
function buildPositionDescription(enriched: EnrichedInstruction): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Quantité
|
||||
if (typeof enriched.quantity === "number") {
|
||||
parts.push(`EXACTEMENT ${enriched.quantity} élément(s)`);
|
||||
} else if (enriched.quantity === "all") {
|
||||
parts.push("TOUS les éléments / zone complète");
|
||||
} else if (enriched.quantity === "partial") {
|
||||
parts.push("UNE PARTIE seulement");
|
||||
}
|
||||
|
||||
// Côté/Position
|
||||
const sideLabels: Record<string, string> = {
|
||||
left: "côté GAUCHE",
|
||||
right: "côté DROIT",
|
||||
center: "au CENTRE",
|
||||
top: "partie HAUTE / SUPÉRIEURE",
|
||||
bottom: "partie BASSE / INFÉRIEURE",
|
||||
};
|
||||
|
||||
if (enriched.zoneConstraints.side) {
|
||||
parts.push(`Position: ${sideLabels[enriched.zoneConstraints.side]}`);
|
||||
}
|
||||
|
||||
// Zone
|
||||
if (enriched.targetZone) {
|
||||
parts.push(`Zone: ${enriched.targetZone}`);
|
||||
}
|
||||
|
||||
// Contraintes supplémentaires
|
||||
if (enriched.zoneConstraints.description) {
|
||||
parts.push(`Détails: ${enriched.zoneConstraints.description}`);
|
||||
}
|
||||
|
||||
return parts.join(" | ");
|
||||
}
|
||||
252
src/lib/gemini/agents/reference-analyzer.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
// ============================================================================
|
||||
// AGENT ANALYSE DE RÉFÉRENCE - CLASSIFICATION ÉTENDUE
|
||||
// ============================================================================
|
||||
// Analyse les images de référence pour déterminer:
|
||||
// - Type (texture, objet, produit, plante, équipement technique, etc.)
|
||||
// - Action à effectuer (appliquer, remplacer, ajouter)
|
||||
// - Conseils d'intégration photoréaliste
|
||||
// ============================================================================
|
||||
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
import { MODELS } from "../config";
|
||||
import type { ReferenceAnalysis, PreparedImage, ReferenceAction } from "../types";
|
||||
|
||||
// Client AI
|
||||
const ai = new GoogleGenAI({
|
||||
apiKey:
|
||||
import.meta.env.AI_GOOGLE_API_KEY || process.env.AI_GOOGLE_API_KEY || "",
|
||||
});
|
||||
|
||||
/**
|
||||
* Prompt d'analyse de référence - Classification étendue
|
||||
*/
|
||||
const REFERENCE_ANALYSIS_PROMPT = `Tu es un EXPERT en identification d'images de référence pour la visualisation de projets d'aménagement.
|
||||
|
||||
TON RÔLE: Analyser cette image de RÉFÉRENCE fournie par l'utilisateur et déterminer PRÉCISÉMENT:
|
||||
1. Ce que l'image représente
|
||||
2. Comment l'utiliser dans une transformation d'espace
|
||||
3. Comment l'intégrer de manière PHOTORÉALISTE
|
||||
|
||||
TYPES DE RÉFÉRENCES POSSIBLES:
|
||||
|
||||
🎨 TEXTURES & MATÉRIAUX (à appliquer sur des surfaces):
|
||||
- Peintures, enduits, crépis
|
||||
- Carrelages, faïences, mosaïques, vinyl
|
||||
- Parquets, stratifiés, vinyles
|
||||
- Pierre, marbre, granit
|
||||
- Béton, béton ciré
|
||||
- Papiers peints
|
||||
- Tissus, textiles
|
||||
- Bardages (bois, métal, composite)
|
||||
- Revêtements de toiture (tuiles, ardoises)
|
||||
|
||||
📦 PRODUITS & OBJETS (à placer/remplacer):
|
||||
- Photos de catalogue produit
|
||||
- Rendus 3D de meubles
|
||||
- Packshots sur fond blanc
|
||||
- Luminaires, lampes
|
||||
- Sanitaires (lavabos, WC, douches)
|
||||
- Électroménager
|
||||
- Menuiseries (portes, fenêtres)
|
||||
- Radiateurs, chauffages
|
||||
|
||||
🌿 VÉGÉTATION (plantes, arbres):
|
||||
- Plantes
|
||||
- Arbres, arbustes
|
||||
- Haies, massifs floraux
|
||||
- Gazon, couvre-sols
|
||||
|
||||
⚡ ÉQUIPEMENTS TECHNIQUES:
|
||||
- Panneaux solaires
|
||||
- Pompes à chaleur
|
||||
- Climatiseurs
|
||||
- Équipements de piscine
|
||||
- Portails, clôtures
|
||||
- Systèmes d'alarme
|
||||
|
||||
🏗️ ÉLÉMENTS ARCHITECTURAUX:
|
||||
- Fenêtres, baies vitrées
|
||||
- Portes (entrée, garage, intérieures)
|
||||
- Volets, stores
|
||||
- Pergolas, vérandas
|
||||
- Escaliers, garde-corps
|
||||
|
||||
Réponds UNIQUEMENT avec ce JSON (sans markdown, sans backticks):
|
||||
{
|
||||
"type": "texture | material_sample | product_photo | 3d_render | furniture_photo | lighting_fixture | plant_photo | technical_equipment | architectural_element | ambiance_photo | custom",
|
||||
"category": "catégorie précise (ex: parquet chevron, table à manger scandinave, panneau solaire monocristallin)",
|
||||
"subcategory": "sous-catégorie si applicable",
|
||||
"description": "description détaillée et complète de ce que montre l'image",
|
||||
"mainColor": "couleur dominante",
|
||||
"secondaryColors": ["couleur2", "couleur3"],
|
||||
"style": "style (moderne, classique, industriel, scandinave, bohème, minimaliste, rustique...)",
|
||||
"material": "matériau principal",
|
||||
"secondaryMaterials": ["autres matériaux visibles"],
|
||||
"action": "apply_texture | replace_object | add_element | use_as_style | extract_color",
|
||||
"finish": "finition (mat, satiné, brillant, texturé...)",
|
||||
"pattern": "motif si applicable (chevron, damier, uni, géométrique...)",
|
||||
"dimensions": "dimensions estimées si identifiables",
|
||||
"brand": "marque si identifiable",
|
||||
"imageQuality": "excellent | good | acceptable | poor",
|
||||
"integrationTips": [
|
||||
"Conseil 1 pour une intégration réaliste",
|
||||
"Conseil 2...",
|
||||
"Conseil 3..."
|
||||
]
|
||||
}
|
||||
|
||||
RÈGLES DE CLASSIFICATION:
|
||||
|
||||
📐 C'est une TEXTURE/MATÉRIAU si:
|
||||
- Image en gros plan de surface
|
||||
- Pattern répétitif ou uniforme
|
||||
- Pas de forme d'objet reconnaissable
|
||||
- Échantillon sans contexte 3D
|
||||
→ action = "apply_texture"
|
||||
|
||||
🛋️ C'est un OBJET/PRODUIT si:
|
||||
- Forme complète identifiable
|
||||
- Meuble, luminaire, équipement
|
||||
- Photo de catalogue ou rendu 3D
|
||||
- Produit avec structure 3D
|
||||
→ action = "replace_object" (si remplacement) ou "add_element" (si ajout)
|
||||
|
||||
🌱 C'est de la VÉGÉTATION si:
|
||||
- Plante, arbre, fleurs
|
||||
- Élément végétal naturel
|
||||
→ action = "add_element" ou "replace_object"
|
||||
|
||||
⚙️ C'est un ÉQUIPEMENT TECHNIQUE si:
|
||||
- Panneau solaire, pompe à chaleur
|
||||
- Climatiseur, chauffage
|
||||
- Installation électrique, plomberie
|
||||
→ action = "replace_object" ou "add_element"
|
||||
|
||||
🏠 C'est un ÉLÉMENT ARCHITECTURAL si:
|
||||
- Porte, fenêtre, volet
|
||||
- Escalier, garde-corps
|
||||
- Élément de structure
|
||||
→ action = "replace_object"
|
||||
|
||||
CONSEILS D'INTÉGRATION À FOURNIR:
|
||||
Pour chaque référence, donne des conseils SPÉCIFIQUES pour:
|
||||
- L'adaptation de l'éclairage
|
||||
- Le respect de la perspective
|
||||
- La gestion des ombres
|
||||
- L'intégration chromatique
|
||||
- Le rendu photoréaliste
|
||||
|
||||
Sois PRÉCIS et EXHAUSTIF dans ton analyse.`;
|
||||
|
||||
/**
|
||||
* Fallback pour l'analyse de référence
|
||||
*/
|
||||
function createFallbackReferenceAnalysis(): ReferenceAnalysis {
|
||||
return {
|
||||
type: "custom",
|
||||
category: "élément de référence",
|
||||
description: "Image de référence non analysée",
|
||||
mainColor: "non déterminé",
|
||||
style: "non déterminé",
|
||||
material: "non déterminé",
|
||||
action: "apply_texture",
|
||||
imageQuality: "acceptable",
|
||||
integrationTips: [
|
||||
"Adapter l'éclairage à la scène",
|
||||
"Respecter la perspective existante",
|
||||
"Ajouter des ombres cohérentes",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent Analyse de Référence
|
||||
* Analyse une image de référence pour déterminer comment l'intégrer
|
||||
*/
|
||||
export async function analyzeReferenceImage(
|
||||
imageData: PreparedImage
|
||||
): Promise<ReferenceAnalysis> {
|
||||
console.log(" 🎨 Agent Référence: Classification de l'image...");
|
||||
|
||||
try {
|
||||
const response = await ai.models.generateContent({
|
||||
model: MODELS.analyzer,
|
||||
contents: [
|
||||
{ text: REFERENCE_ANALYSIS_PROMPT },
|
||||
{
|
||||
inlineData: { mimeType: imageData.mimeType, data: imageData.base64 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const text = response.candidates?.[0]?.content?.parts?.[0]?.text || "";
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
|
||||
if (jsonMatch) {
|
||||
const analysis = JSON.parse(jsonMatch[0]) as ReferenceAnalysis;
|
||||
|
||||
console.log(` ✓ Type: ${analysis.type.toUpperCase()}`);
|
||||
console.log(` ✓ Catégorie: ${analysis.category}`);
|
||||
console.log(` ✓ Action: ${analysis.action}`);
|
||||
console.log(` ✓ Style: ${analysis.style} | Matériau: ${analysis.material}`);
|
||||
console.log(` ✓ Qualité image: ${analysis.imageQuality}`);
|
||||
|
||||
if (analysis.integrationTips && analysis.integrationTips.length > 0) {
|
||||
console.log(` ✓ Conseils d'intégration:`);
|
||||
for (const tip of analysis.integrationTips.slice(0, 3)) {
|
||||
console.log(` → ${tip}`);
|
||||
}
|
||||
}
|
||||
|
||||
return analysis;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(" ⚠️ Analyse de référence échouée, utilisation du fallback");
|
||||
console.error(" Erreur:", error);
|
||||
}
|
||||
|
||||
return createFallbackReferenceAnalysis();
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine l'action appropriée basée sur le type de référence
|
||||
*/
|
||||
export function determineAction(
|
||||
referenceType: string,
|
||||
hasTargetObject: boolean
|
||||
): ReferenceAction {
|
||||
// Textures et matériaux → toujours appliquer comme texture
|
||||
if (
|
||||
referenceType === "texture" ||
|
||||
referenceType === "material_sample" ||
|
||||
referenceType === "paint_color" ||
|
||||
referenceType === "wallpaper" ||
|
||||
referenceType === "tile_pattern" ||
|
||||
referenceType === "wood_sample" ||
|
||||
referenceType === "stone_sample" ||
|
||||
referenceType === "fabric_sample"
|
||||
) {
|
||||
return "apply_texture";
|
||||
}
|
||||
|
||||
// Références de style/ambiance
|
||||
if (
|
||||
referenceType === "ambiance_photo" ||
|
||||
referenceType === "style_reference" ||
|
||||
referenceType === "mood_board"
|
||||
) {
|
||||
return "use_as_style";
|
||||
}
|
||||
|
||||
// Palette de couleurs
|
||||
if (referenceType === "color_palette") {
|
||||
return "extract_color";
|
||||
}
|
||||
|
||||
// Objets et produits
|
||||
if (hasTargetObject) {
|
||||
return "replace_object";
|
||||
}
|
||||
|
||||
return "add_element";
|
||||
}
|
||||
48
src/lib/gemini/config.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// ============================================================================
|
||||
// CONFIGURATION DU SYSTÈME DE GÉNÉRATION
|
||||
// ============================================================================
|
||||
|
||||
import type { GenerationConfig, ModelConfig, ImageConfig } from "./types";
|
||||
|
||||
/**
|
||||
* Modèles AI utilisés
|
||||
*/
|
||||
export const MODELS: ModelConfig = {
|
||||
analyzer: "gemini-2.5-flash",
|
||||
generator: "gemini-3-pro-image-preview",
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration d'image par défaut
|
||||
*/
|
||||
export const IMAGE_CONFIG: ImageConfig = {
|
||||
aspectRatio: "4:3",
|
||||
imageSize: "2K",
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration des retries et limites
|
||||
*/
|
||||
export const GENERATION_CONFIG: GenerationConfig = {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 2000,
|
||||
maxDelayMs: 15000,
|
||||
backoffMultiplier: 2,
|
||||
maxImageSizeBytes: 4 * 1024 * 1024,
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats Apple nécessitant une conversion
|
||||
*/
|
||||
export const APPLE_FORMATS = ["heic", "heif", "hif"];
|
||||
|
||||
/**
|
||||
* Types MIME supportés
|
||||
*/
|
||||
export const MIME_TYPES: Record<string, string> = {
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
};
|
||||
549
src/lib/gemini/index.ts
Normal file
@@ -0,0 +1,549 @@
|
||||
// ============================================================================
|
||||
// MODULE GEMINI - POINT D'ENTRÉE PRINCIPAL
|
||||
// ============================================================================
|
||||
// Système agentique polyvalent pour la visualisation avant/après
|
||||
// Couvre TOUS les métiers du bâtiment et de l'aménagement
|
||||
// ============================================================================
|
||||
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
import { GENERATION_CONFIG, MODELS } from "./config";
|
||||
import { prepareImageForAPI, sleep } from "./utils/image";
|
||||
import { analyzeImageWithAgent } from "./agents/analyzer";
|
||||
import { planModificationsWithAgent } from "./agents/planner";
|
||||
import { generateWithNanoBanana } from "./agents/generator";
|
||||
import { buildSimplifiedRetryPrompt } from "./prompts/builder";
|
||||
|
||||
// Re-export des types
|
||||
export * from "./types";
|
||||
export { MODELS, IMAGE_CONFIG, GENERATION_CONFIG } from "./config";
|
||||
|
||||
// Import des types nécessaires
|
||||
import type {
|
||||
GenerationInstruction,
|
||||
GenerationResult,
|
||||
GenerationOptions,
|
||||
ProgressCallback,
|
||||
ProgressEvent,
|
||||
PreparedImage,
|
||||
} from "./types";
|
||||
|
||||
// Client AI partagé
|
||||
const ai = new GoogleGenAI({
|
||||
apiKey:
|
||||
import.meta.env.AI_GOOGLE_API_KEY || process.env.AI_GOOGLE_API_KEY || "",
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// FONCTION PRINCIPALE: ORCHESTRATION AGENTIQUE
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Génère une image avant/après avec le système agentique
|
||||
*/
|
||||
export async function generateBeforeAfter(
|
||||
originalImagePath: string,
|
||||
instructions: GenerationInstruction[],
|
||||
outputDir: string,
|
||||
generationId: string,
|
||||
options: GenerationOptions = {}
|
||||
): Promise<GenerationResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
console.log("\n" + "═".repeat(70));
|
||||
console.log("🤖 SYSTÈME AGENTIQUE DE GÉNÉRATION AVANT/APRÈS");
|
||||
console.log(" Couvre tous les métiers du bâtiment et de l'aménagement");
|
||||
console.log("═".repeat(70));
|
||||
console.log(`📋 ${instructions.length} instruction(s) de l'utilisateur:`);
|
||||
console.log(`🆔 ID: ${generationId}`);
|
||||
console.log("");
|
||||
|
||||
for (let i = 0; i < instructions.length; i++) {
|
||||
const instr = instructions[i];
|
||||
console.log(` 📌 Instruction ${i + 1}:`);
|
||||
console.log(` └─ Emplacement: "${instr.location}"`);
|
||||
console.log(` └─ Nom: ${instr.referenceName || "(sans nom)"}`);
|
||||
console.log(` └─ Image: ${instr.referenceImagePath}`);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// CHARGEMENT DES IMAGES
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n📸 Chargement des images...");
|
||||
const originalImage = await prepareImageForAPI(originalImagePath);
|
||||
console.log(
|
||||
` ✓ Original: ${(originalImage.base64.length / 1024).toFixed(0)} KB`
|
||||
);
|
||||
|
||||
const referenceImages: PreparedImage[] = [];
|
||||
for (let i = 0; i < instructions.length; i++) {
|
||||
const refImage = await prepareImageForAPI(
|
||||
instructions[i].referenceImagePath
|
||||
);
|
||||
referenceImages.push(refImage);
|
||||
console.log(
|
||||
` ✓ Référence ${i + 1}: ${(refImage.base64.length / 1024).toFixed(
|
||||
0
|
||||
)} KB`
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// PHASE 1: ANALYSE AGENTIQUE
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n🔬 PHASE 1: Analyse intelligente multi-métiers");
|
||||
console.log("─".repeat(50));
|
||||
|
||||
const analysis = await analyzeImageWithAgent(originalImage);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// PHASE 2: PLANIFICATION DES MODIFICATIONS
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n📊 PHASE 2: Planification et mapping des éléments");
|
||||
console.log("─".repeat(50));
|
||||
|
||||
const plan = await planModificationsWithAgent(
|
||||
analysis,
|
||||
instructions,
|
||||
referenceImages
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// PHASE 3: GÉNÉRATION AVEC RETRY
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n🎨 PHASE 3: Génération photoréaliste");
|
||||
console.log("─".repeat(50));
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= GENERATION_CONFIG.maxRetries; attempt++) {
|
||||
console.log(`\n🔄 Tentative ${attempt}/${GENERATION_CONFIG.maxRetries}`);
|
||||
|
||||
try {
|
||||
// Premier essai: prompt complet. Retries: prompt simplifié
|
||||
const prompt =
|
||||
attempt === 1
|
||||
? plan.globalPrompt
|
||||
: buildSimplifiedRetryPrompt(instructions, plan.tasks, attempt);
|
||||
|
||||
const result = await generateWithNanoBanana(
|
||||
originalImage,
|
||||
referenceImages,
|
||||
prompt,
|
||||
outputDir,
|
||||
generationId
|
||||
);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
console.log("\n" + "═".repeat(70));
|
||||
console.log("✅ GÉNÉRATION RÉUSSIE!");
|
||||
console.log(` 📁 ${result.imagePath}`);
|
||||
console.log(` ⏱️ Durée: ${(duration / 1000).toFixed(1)}s`);
|
||||
console.log(` 🔢 Tentatives: ${attempt}`);
|
||||
console.log("═".repeat(70) + "\n");
|
||||
|
||||
return {
|
||||
imagePath: result.imagePath,
|
||||
description: result.description,
|
||||
attempts: attempt,
|
||||
analysisDetails: analysis,
|
||||
duration,
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
console.error(` ❌ Échec: ${lastError.message.substring(0, 200)}`);
|
||||
|
||||
if (attempt < GENERATION_CONFIG.maxRetries) {
|
||||
const delay = Math.min(
|
||||
GENERATION_CONFIG.initialDelayMs *
|
||||
Math.pow(GENERATION_CONFIG.backoffMultiplier, attempt - 1),
|
||||
GENERATION_CONFIG.maxDelayMs
|
||||
);
|
||||
console.log(` ⏳ Nouveau essai dans ${delay}ms...`);
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Échec après ${GENERATION_CONFIG.maxRetries} tentatives. ${lastError?.message}`
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FONCTION AVEC PROGRESS CALLBACK POUR STREAMING SSE
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Génère une image avec callbacks de progression pour le streaming SSE
|
||||
*/
|
||||
export async function generateBeforeAfterWithProgress(
|
||||
originalImagePath: string,
|
||||
instructions: GenerationInstruction[],
|
||||
outputDir: string,
|
||||
generationId: string,
|
||||
onProgress: ProgressCallback,
|
||||
options: GenerationOptions = {}
|
||||
): Promise<GenerationResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
const log = (icon: string, message: string) => {
|
||||
console.log(`${icon} ${message}`);
|
||||
onProgress({ type: "log", icon, message });
|
||||
};
|
||||
|
||||
const setStep = (
|
||||
step: string,
|
||||
status: "pending" | "loading" | "done" | "error"
|
||||
) => {
|
||||
onProgress({ type: "step", step, status });
|
||||
};
|
||||
|
||||
log("🤖", "SYSTÈME AGENTIQUE DE GÉNÉRATION AVANT/APRÈS");
|
||||
log("🏗️", "Couvre tous les métiers du bâtiment et de l'aménagement");
|
||||
log("📋", `${instructions.length} instruction(s) de l'utilisateur`);
|
||||
log("🆔", `ID: ${generationId}`);
|
||||
|
||||
for (let i = 0; i < instructions.length; i++) {
|
||||
const instr = instructions[i];
|
||||
log(
|
||||
"📌",
|
||||
`Instruction ${i + 1}: "${instr.location}" - ${
|
||||
instr.referenceName || "(sans nom)"
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// CHARGEMENT DES IMAGES
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
setStep("upload", "loading");
|
||||
log("📸", "Chargement des images depuis S3...");
|
||||
|
||||
const originalImage = await prepareImageForAPI(originalImagePath);
|
||||
log("✓", `Original: ${(originalImage.base64.length / 1024).toFixed(0)} KB`);
|
||||
|
||||
const referenceImages: PreparedImage[] = [];
|
||||
for (let i = 0; i < instructions.length; i++) {
|
||||
const refImage = await prepareImageForAPI(
|
||||
instructions[i].referenceImagePath
|
||||
);
|
||||
referenceImages.push(refImage);
|
||||
log(
|
||||
"✓",
|
||||
`Référence ${i + 1}: ${(refImage.base64.length / 1024).toFixed(0)} KB`
|
||||
);
|
||||
}
|
||||
|
||||
// Charger les masques s'ils existent
|
||||
const maskImages: (PreparedImage | null)[] = [];
|
||||
let hasMasks = false;
|
||||
let mergedAnnotatedMask: PreparedImage | null = null;
|
||||
|
||||
// D'abord, collecter tous les masques
|
||||
for (let i = 0; i < instructions.length; i++) {
|
||||
const instr = instructions[i];
|
||||
if (instr.maskImagePath) {
|
||||
try {
|
||||
log("🎭", `Chargement masque ${i + 1}...`);
|
||||
const maskImage = await prepareImageForAPI(instr.maskImagePath);
|
||||
maskImages.push(maskImage);
|
||||
hasMasks = true;
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Impossible de charger le masque ${i + 1}:`, error);
|
||||
maskImages.push(null);
|
||||
}
|
||||
} else {
|
||||
maskImages.push(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Créer UN SEUL masque fusionné et annoté avec toutes les zones
|
||||
if (hasMasks) {
|
||||
const { createMergedAnnotatedMask } = await import("./utils/image");
|
||||
|
||||
// Collecter les zones valides
|
||||
const zones: Array<{
|
||||
maskImage: PreparedImage;
|
||||
referenceImage: PreparedImage;
|
||||
referenceName: string;
|
||||
instruction: string;
|
||||
referenceIndex: number;
|
||||
}> = [];
|
||||
for (let i = 0; i < instructions.length; i++) {
|
||||
const mask = maskImages[i];
|
||||
if (mask) {
|
||||
zones.push({
|
||||
maskImage: mask,
|
||||
referenceImage: referenceImages[i],
|
||||
referenceName: instructions[i].referenceName || `Référence ${i + 1}`,
|
||||
instruction: instructions[i].location,
|
||||
referenceIndex: i,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (zones.length > 0) {
|
||||
log("🔀", `Fusion de ${zones.length} masque(s) en un seul avec annotations...`);
|
||||
mergedAnnotatedMask = await createMergedAnnotatedMask(originalImage, zones);
|
||||
log("✅", `Masque unique annoté créé: ${(mergedAnnotatedMask.base64.length / 1024).toFixed(0)} KB`);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasMasks) {
|
||||
log("🎯", `${maskImages.filter(m => m !== null).length} masque(s) fusionné(s) en 1 image annotée`);
|
||||
}
|
||||
|
||||
setStep("upload", "done");
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// PHASE 1: ANALYSE AGENTIQUE
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
setStep("analyze", "loading");
|
||||
log("🔬", "PHASE 1: Analyse intelligente multi-métiers");
|
||||
log("🧠", "Identification des surfaces, objets, équipements, végétation...");
|
||||
|
||||
const analysis = await analyzeImageWithAgent(originalImage);
|
||||
|
||||
log(
|
||||
"✓",
|
||||
`Analyse: ${analysis.spaceType} (${analysis.environment}) - ${
|
||||
analysis.surfaces?.length || 0
|
||||
} surfaces, ${(analysis.objects || []).length} objets`
|
||||
);
|
||||
|
||||
if (analysis.technicalEquipment && analysis.technicalEquipment.length > 0) {
|
||||
log("⚙️", `${analysis.technicalEquipment.length} équipement(s) technique(s) identifié(s)`);
|
||||
}
|
||||
|
||||
if (analysis.vegetation && analysis.vegetation.length > 0) {
|
||||
log("🌿", `${analysis.vegetation.length} élément(s) végétal(aux) identifié(s)`);
|
||||
}
|
||||
|
||||
setStep("analyze", "done");
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// PHASE 2: PLANIFICATION DES MODIFICATIONS
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
setStep("plan", "loading");
|
||||
log("📊", "PHASE 2: Planification et mapping des éléments");
|
||||
log("🗺️", "Analyse des références et création du plan...");
|
||||
|
||||
// Passer les masques au planner si disponibles
|
||||
const plan = await planModificationsWithAgent(
|
||||
analysis,
|
||||
instructions,
|
||||
referenceImages,
|
||||
hasMasks ? originalImage : undefined,
|
||||
hasMasks ? maskImages : undefined
|
||||
);
|
||||
|
||||
log("✓", `Plan créé: ${plan.tasks?.length || 0} tâche(s) de modification`);
|
||||
if (plan.tasks && plan.tasks.length > 0) {
|
||||
for (const task of plan.tasks) {
|
||||
const targetName = task.targetSurface?.name || task.targetObject?.name || "Cible";
|
||||
const actionEmoji =
|
||||
task.actionType === "replace_object"
|
||||
? "🔄"
|
||||
: task.actionType === "add_element"
|
||||
? "➕"
|
||||
: "🎨";
|
||||
log(
|
||||
"📍",
|
||||
`${actionEmoji} ${task.actionType}: ${targetName} → ${task.targetMaterial.substring(0, 40)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (plan.warnings && plan.warnings.length > 0) {
|
||||
for (const warning of plan.warnings) {
|
||||
onProgress({ type: "warning", message: warning });
|
||||
}
|
||||
}
|
||||
|
||||
setStep("plan", "done");
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// PHASE 3: GÉNÉRATION AVEC RETRY
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
setStep("generate", "loading");
|
||||
log("🎨", "PHASE 3: Génération photoréaliste avec Gemini");
|
||||
log("🖼️", "Contraintes: cadrage identique, insertion réaliste, ombres cohérentes");
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= GENERATION_CONFIG.maxRetries; attempt++) {
|
||||
log("🔄", `Tentative ${attempt}/${GENERATION_CONFIG.maxRetries}`);
|
||||
|
||||
try {
|
||||
const prompt =
|
||||
attempt === 1
|
||||
? plan.globalPrompt
|
||||
: buildSimplifiedRetryPrompt(instructions, plan.tasks, attempt);
|
||||
|
||||
log("📝", `Envoi du prompt (${prompt.length} caractères)...`);
|
||||
|
||||
// Passer le masque fusionné annoté unique si disponible
|
||||
const result = await generateWithNanoBanana(
|
||||
originalImage,
|
||||
referenceImages,
|
||||
prompt,
|
||||
outputDir,
|
||||
generationId,
|
||||
mergedAnnotatedMask ? [mergedAnnotatedMask] : undefined
|
||||
);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
setStep("generate", "done");
|
||||
log("✅", `GÉNÉRATION RÉUSSIE en ${(duration / 1000).toFixed(1)}s!`);
|
||||
log("📁", `Image sauvegardée: ${result.imagePath}`);
|
||||
|
||||
if (result.thoughtCount > 0) {
|
||||
log("🧠", `Mode Thinking: ${result.thoughtCount} image(s) intermédiaire(s)`);
|
||||
}
|
||||
|
||||
// Sauvegarder le masque fusionné pour debug si présent
|
||||
const combinedMaskPaths: string[] = [];
|
||||
if (mergedAnnotatedMask) {
|
||||
const { saveBuffer } = await import("../storage");
|
||||
try {
|
||||
const maskFileName = `merged_annotated_mask_${generationId}.png`;
|
||||
const maskBuffer = Buffer.from(mergedAnnotatedMask.base64, "base64");
|
||||
const maskPath = await saveBuffer(maskBuffer, maskFileName, "generated");
|
||||
combinedMaskPaths.push(maskPath);
|
||||
log("🎭", `Masque fusionné annoté sauvegardé pour debug`);
|
||||
} catch (e) {
|
||||
console.warn(`Impossible de sauvegarder le masque fusionné:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
imagePath: result.imagePath,
|
||||
description: result.description,
|
||||
attempts: attempt,
|
||||
analysisDetails: analysis,
|
||||
duration,
|
||||
combinedMaskPaths: combinedMaskPaths.length > 0 ? combinedMaskPaths : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
log(
|
||||
"❌",
|
||||
`Échec: ${(lastError?.message || "Erreur inconnue").substring(0, 150)}`
|
||||
);
|
||||
|
||||
if (attempt < GENERATION_CONFIG.maxRetries) {
|
||||
const delay = Math.min(
|
||||
GENERATION_CONFIG.initialDelayMs *
|
||||
Math.pow(GENERATION_CONFIG.backoffMultiplier, attempt - 1),
|
||||
GENERATION_CONFIG.maxDelayMs
|
||||
);
|
||||
log("⏳", `Nouveau essai dans ${delay}ms...`);
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setStep("generate", "error");
|
||||
onProgress({
|
||||
type: "error",
|
||||
message: `Échec après ${GENERATION_CONFIG.maxRetries} tentatives: ${lastError?.message}`,
|
||||
});
|
||||
|
||||
throw new Error(
|
||||
`Échec après ${GENERATION_CONFIG.maxRetries} tentatives. ${lastError?.message}`
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FONCTIONS UTILITAIRES EXPORTÉES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Analyse une image pour identifier les zones modifiables
|
||||
*/
|
||||
export async function analyzeImage(imagePath: string): Promise<string> {
|
||||
const imageData = await prepareImageForAPI(imagePath);
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: MODELS.analyzer,
|
||||
contents: [
|
||||
{
|
||||
text: `Analyse cette image d'un espace et identifie TOUS les éléments modifiables.
|
||||
|
||||
Couvre TOUS les métiers: rénovation, décoration, menuiserie, plomberie, électricité,
|
||||
chauffage, énergie (solaire, PAC), paysagisme, toiture, façade, piscine, etc.
|
||||
|
||||
Pour chaque élément:
|
||||
1. **Nom**: Description précise (ex: "Mur nord", "Fenêtre principale", "Radiateur")
|
||||
2. **Catégorie**: Type d'élément (surface, meuble, équipement technique, végétation...)
|
||||
3. **État actuel**: Type de revêtement ou équipement actuel
|
||||
4. **Modifications possibles**: Alternatives envisageables
|
||||
|
||||
Sois EXHAUSTIF et couvre tous les éléments visibles.`,
|
||||
},
|
||||
{ inlineData: { mimeType: imageData.mimeType, data: imageData.base64 } },
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
response.candidates?.[0]?.content?.parts?.[0]?.text ||
|
||||
"Analyse non disponible"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide une image de référence
|
||||
*/
|
||||
export async function validateReference(imagePath: string): Promise<{
|
||||
valid: boolean;
|
||||
type?: string;
|
||||
category?: string;
|
||||
quality?: string;
|
||||
suggestions?: string;
|
||||
}> {
|
||||
const imageData = await prepareImageForAPI(imagePath);
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: MODELS.analyzer,
|
||||
contents: [
|
||||
{
|
||||
text: `Cette image doit servir de référence pour une modification d'espace.
|
||||
|
||||
Analyse et détermine:
|
||||
1. Type: texture/matériau OU objet/produit OU végétation OU équipement technique
|
||||
2. Catégorie précise
|
||||
3. Qualité de l'image pour une intégration photoréaliste
|
||||
|
||||
Réponds avec ce JSON (sans markdown):
|
||||
{
|
||||
"valid": true ou false,
|
||||
"type": "texture | product | plant | equipment | architectural | other",
|
||||
"category": "catégorie précise",
|
||||
"quality": "excellente" | "bonne" | "moyenne" | "insuffisante",
|
||||
"suggestions": "conseils pour améliorer l'intégration si nécessaire"
|
||||
}`,
|
||||
},
|
||||
{ inlineData: { mimeType: imageData.mimeType, data: imageData.base64 } },
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const text = response.candidates?.[0]?.content?.parts?.[0]?.text || "";
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) return JSON.parse(jsonMatch[0]);
|
||||
} catch (e) {
|
||||
console.warn("Parsing validation échoué:", e);
|
||||
}
|
||||
|
||||
return { valid: true, type: "unknown", category: "Élément", quality: "inconnue" };
|
||||
}
|
||||
561
src/lib/gemini/prompts/builder.ts
Normal file
@@ -0,0 +1,561 @@
|
||||
// ============================================================================
|
||||
// CONSTRUCTION DU PROMPT DE GÉNÉRATION
|
||||
// ============================================================================
|
||||
// Prompts optimisés avec:
|
||||
// - Contraintes STRICTES anti-zoom/recadrage
|
||||
// - Règles d'insertion photoréaliste
|
||||
// - Couverture de tous les métiers du bâtiment
|
||||
// ============================================================================
|
||||
|
||||
import type {
|
||||
ImageAnalysis,
|
||||
ModificationTask,
|
||||
GenerationInstruction,
|
||||
ReferenceAnalysis,
|
||||
} from "../types";
|
||||
|
||||
/**
|
||||
* Contraintes ABSOLUES de cadrage - Anti-zoom RENFORCÉES
|
||||
*/
|
||||
const STRICT_FRAMING_RULES = `
|
||||
## 🚨🚨🚨 CONTRAINTES ABSOLUES DE CADRAGE - RESPECT IMPÉRATIF 🚨🚨🚨
|
||||
|
||||
### ❌ INTERDICTIONS STRICTES - ÉCHEC SI NON RESPECTÉES:
|
||||
- ❌ NE JAMAIS zoomer avant (rapprocher)
|
||||
- ❌ NE JAMAIS zoomer arrière (éloigner)
|
||||
- ❌ NE JAMAIS recadrer l'image
|
||||
- ❌ NE JAMAIS modifier l'angle de vue
|
||||
- ❌ NE JAMAIS changer les proportions (aspect ratio)
|
||||
- ❌ NE JAMAIS rogner les bords de l'image
|
||||
- ❌ NE JAMAIS ajouter de marges ou de bordures
|
||||
- ❌ NE JAMAIS modifier la perspective de la caméra
|
||||
- ❌ NE JAMAIS déplacer le point de vue
|
||||
- ❌ NE JAMAIS montrer une version agrandie ou réduite de la scène
|
||||
- ❌ NE JAMAIS "améliorer" le cadrage
|
||||
- ❌ NE JAMAIS centrer différemment la composition
|
||||
- ❌ NE JAMAIS inclure plus ou moins de la scène que l'original
|
||||
|
||||
### ✅ OBLIGATIONS IMPÉRATIVES - À RESPECTER PIXEL PAR PIXEL:
|
||||
- ✅ Conserver EXACTEMENT le même cadrage que l'IMAGE 1
|
||||
- ✅ Conserver EXACTEMENT la même perspective
|
||||
- ✅ Conserver EXACTEMENT les mêmes proportions (aspect ratio)
|
||||
- ✅ Conserver EXACTEMENT la même position de caméra
|
||||
- ✅ Conserver EXACTEMENT la même distance focale apparente
|
||||
- ✅ Les bords de l'image générée DOIVENT correspondre EXACTEMENT
|
||||
- ✅ La composition spatiale doit être STRICTEMENT IDENTIQUE
|
||||
- ✅ Les objets fixes (murs, fenêtres, portes) doivent être aux MÊMES positions
|
||||
- ✅ L'horizon doit être à la MÊME hauteur
|
||||
- ✅ Les lignes de fuite doivent converger au MÊME point
|
||||
|
||||
### 🔍 TEST DE VÉRIFICATION ABSOLU:
|
||||
Imagine que l'on superpose l'image originale et l'image générée en transparence:
|
||||
- Les contours architecturaux DOIVENT se superposer PARFAITEMENT
|
||||
- Les angles des murs, plafonds, sols DOIVENT être IDENTIQUES
|
||||
- Les positions des fenêtres, portes DOIVENT être les MÊMES
|
||||
- Le plafond doit toucher le bord supérieur au MÊME endroit
|
||||
- Le sol doit toucher le bord inférieur au MÊME endroit
|
||||
- Les éléments NON modifiés doivent être IDENTIQUES pixel par pixel
|
||||
- AUCUN décalage, AUCUN glissement ne doit être visible
|
||||
|
||||
### ⚠️ ERREURS FRÉQUENTES À ÉVITER:
|
||||
- Zoomer légèrement pour "améliorer" la composition → INTERDIT
|
||||
- Montrer un peu plus de la pièce → INTERDIT
|
||||
- Recadrer pour mieux centrer l'objet modifié → INTERDIT
|
||||
- Changer subtilement l'angle pour montrer plus → INTERDIT`;
|
||||
|
||||
/**
|
||||
* Règles d'insertion photoréaliste ULTRA-DÉTAILLÉES
|
||||
*/
|
||||
const PHOTOREALISTIC_INSERTION_RULES = `
|
||||
## 🎨 RÈGLES D'INSERTION PHOTORÉALISTE - QUALITÉ PROFESSIONNELLE
|
||||
|
||||
### 📸 TYPES DE RÉFÉRENCES À INTÉGRER:
|
||||
L'élément de référence peut provenir de:
|
||||
- Photo de produit sur fond blanc/neutre (catalogue)
|
||||
- Rendu 3D de meuble/équipement
|
||||
- Capture de modèle 3D (SketchUp, Blender, 3ds Max)
|
||||
- Image de catalogue de marque
|
||||
- Photo détourée d'objet
|
||||
- Visualisation architecturale
|
||||
- Photo d'ambiance/inspiration
|
||||
|
||||
### 🔆 1. ADAPTATION DE L'ÉCLAIRAGE (CRITIQUE):
|
||||
|
||||
**Analyser la scène originale:**
|
||||
- Identifier la source principale de lumière (fenêtre, luminaire, etc.)
|
||||
- Noter la direction exacte des rayons lumineux
|
||||
- Observer l'intensité (douce, dure, diffuse)
|
||||
- Identifier les sources secondaires (réflexions, lumières d'appoint)
|
||||
|
||||
**Appliquer sur l'élément inséré:**
|
||||
- Éclairer l'objet depuis la MÊME direction que la scène
|
||||
- Créer des zones d'ombre et de lumière cohérentes sur l'objet
|
||||
- Adapter la luminosité générale de l'objet à la scène
|
||||
- Les parties face à la lumière doivent être plus claires
|
||||
- Les parties opposées doivent être plus sombres
|
||||
- Respecter le ratio de contraste de la scène
|
||||
|
||||
**Température de couleur:**
|
||||
- Lumière naturelle = légèrement bleutée/neutre
|
||||
- Lumière artificielle chaude = teintes orangées/jaunes
|
||||
- Lumière LED = peut être chaude ou froide selon les sources
|
||||
- L'objet inséré DOIT avoir la même dominante chromatique
|
||||
|
||||
### 📐 2. RESPECT DE LA PERSPECTIVE (GÉOMÉTRIE EXACTE):
|
||||
|
||||
**Analyser la perspective de la scène:**
|
||||
- Identifier le ou les points de fuite
|
||||
- Tracer mentalement les lignes de fuite principales
|
||||
- Noter la hauteur de l'horizon (ligne des yeux)
|
||||
- Comprendre si c'est une vue frontale, à 1 point, 2 points, ou 3 points
|
||||
|
||||
**Appliquer sur l'élément inséré:**
|
||||
- L'objet DOIT respecter les mêmes lignes de fuite
|
||||
- Les lignes horizontales de l'objet convergent vers le même point de fuite
|
||||
- Les lignes verticales restent verticales (sauf perspective 3 points)
|
||||
- La déformation perspective doit être cohérente avec la distance
|
||||
|
||||
**Échelle et proportions:**
|
||||
- Comparer l'objet aux éléments de référence (porte ~2m, plan de travail ~90cm)
|
||||
- L'échelle doit être RÉALISTE par rapport à l'environnement
|
||||
- Un objet trop grand ou trop petit brise l'illusion
|
||||
|
||||
### 🌑 3. OMBRES & REFLETS (RÉALISME CRITIQUE):
|
||||
|
||||
**Types d'ombres à créer:**
|
||||
1. **Ombre portée**: ombre projetée sur le sol/mur par l'objet
|
||||
- Direction identique aux ombres existantes dans la scène
|
||||
- Dureté identique (floue si lumière diffuse, nette si directionnelle)
|
||||
- Couleur légèrement teintée par l'environnement
|
||||
|
||||
2. **Ombre de contact (Ambient Occlusion)**:
|
||||
- Zone sombre où l'objet touche le sol/support
|
||||
- ESSENTIELLE pour que l'objet ne flotte pas
|
||||
- Fine ligne sombre à la base de l'objet
|
||||
|
||||
3. **Ombres propres de l'objet**:
|
||||
- Parties de l'objet qui ne reçoivent pas de lumière directe
|
||||
- Cohérentes avec l'éclairage de la scène
|
||||
|
||||
**Reflets à gérer:**
|
||||
- Si sol brillant (carrelage, parquet verni): reflet vertical de l'objet
|
||||
- Si surfaces métalliques proches: reflets de l'environnement sur l'objet
|
||||
- Si l'objet est brillant: reflets des fenêtres/lumières sur l'objet
|
||||
|
||||
### 🎨 4. INTÉGRATION CHROMATIQUE (COLOR GRADING):
|
||||
|
||||
**Analyse de l'ambiance colorimétrique:**
|
||||
- Identifier la dominante de couleur de la scène (chaude, froide, neutre)
|
||||
- Noter le niveau de saturation général
|
||||
- Observer le contraste général (photo vintage = faible, moderne = fort)
|
||||
|
||||
**Application sur l'objet inséré:**
|
||||
- Appliquer la même dominante de couleur
|
||||
- Ajuster la saturation pour correspondre
|
||||
- Unifier les noirs et les blancs avec ceux de la scène
|
||||
- L'objet ne doit pas sembler "plus coloré" ou "moins coloré" que le reste
|
||||
|
||||
**Inter-réflexions (Color Bleeding):**
|
||||
- Si mur rouge à côté, légère teinte rouge sur l'objet proche
|
||||
- Si sol beige clair, lumière rebondie chaude sous l'objet
|
||||
- Ces subtilités créent un réalisme avancé
|
||||
|
||||
### 🔬 5. FINITION & COHÉRENCE OPTIQUE:
|
||||
|
||||
**Profondeur de champ:**
|
||||
- Si la scène a du flou (bokeh) en arrière-plan ou premier-plan
|
||||
- Appliquer le même niveau de netteté/flou sur l'objet selon sa distance
|
||||
|
||||
**Grain & texture photo:**
|
||||
- Si photo légèrement grainée, ajouter le même grain sur l'objet
|
||||
- Cohérence de la qualité d'image globale
|
||||
|
||||
**Bords et détourage:**
|
||||
- AUCUN bord dur visible autour de l'objet inséré
|
||||
- AUCUN halo clair ou foncé sur les contours
|
||||
- Les bords doivent se fondre naturellement avec le fond
|
||||
|
||||
**Résolution et netteté:**
|
||||
- L'objet doit avoir la même netteté que le reste de la scène
|
||||
- Pas de pixelisation, pas de flou artificiel
|
||||
|
||||
### 🧱 POUR LES MATÉRIAUX / TEXTURES SUR SURFACES:
|
||||
|
||||
**Application sur la surface:**
|
||||
1. Appliquer sur 100% de la surface indiquée (du bord au bord)
|
||||
2. AUCUNE trace de l'ancien matériau ne doit rester
|
||||
3. Aucun effet "patch" ou "collage" visible
|
||||
|
||||
**Perspective du matériau:**
|
||||
- Les lignes du carrelage/parquet suivent la perspective du sol
|
||||
- Les motifs du papier peint sont verticaux sur les murs verticaux
|
||||
- L'échelle du motif diminue avec la distance (perspective)
|
||||
|
||||
**Joints et raccords:**
|
||||
- Joints de carrelage visibles et réguliers selon la perspective
|
||||
- Lames de parquet dans la bonne direction
|
||||
- Motifs de papier peint raccordés correctement
|
||||
|
||||
**Réflexion et brillance:**
|
||||
- Si surface mate: pas de reflet spéculaire
|
||||
- Si surface satinée: léger reflet diffus
|
||||
- Si surface brillante: reflets nets des sources lumineuses
|
||||
- Si surface miroir: reflet de l'environnement visible
|
||||
|
||||
### ✅ CHECKLIST QUALITÉ AVANT GÉNÉRATION:
|
||||
□ L'éclairage de l'objet correspond à celui de la scène
|
||||
□ Les ombres sont cohérentes avec les autres ombres de la scène
|
||||
□ L'ombre de contact existe à la base de l'objet
|
||||
□ La perspective de l'objet suit les lignes de fuite de la scène
|
||||
□ L'échelle est réaliste par rapport à l'environnement
|
||||
□ La température de couleur est unifiée
|
||||
□ Aucun halo ou bord dur visible
|
||||
□ L'objet semble photographié dans la scène, pas ajouté numériquement`;
|
||||
|
||||
/**
|
||||
* Construit le prompt optimisé pour la génération
|
||||
*/
|
||||
export function buildOptimizedPrompt(
|
||||
analysis: ImageAnalysis,
|
||||
tasks: ModificationTask[],
|
||||
instructions: GenerationInstruction[],
|
||||
referenceAnalyses?: ReferenceAnalysis[]
|
||||
): string {
|
||||
// Séparer les tâches par type d'action
|
||||
const materialTasks = tasks.filter(
|
||||
(t) => t.actionType === "apply_texture" || !t.actionType
|
||||
);
|
||||
const objectTasks = tasks.filter((t) => t.actionType === "replace_object");
|
||||
const addTasks = tasks.filter((t) => t.actionType === "add_element");
|
||||
|
||||
// Construire les blocs de modification par type
|
||||
const modificationBlocks: string[] = [];
|
||||
|
||||
// BLOC 1: Applications de matériaux sur surfaces
|
||||
if (materialTasks.length > 0) {
|
||||
modificationBlocks.push(`
|
||||
## 🎨 MODIFICATIONS DE SURFACES (Application de matériaux/textures)
|
||||
`);
|
||||
const groupedMaterials = new Map<number, ModificationTask[]>();
|
||||
for (const task of materialTasks) {
|
||||
if (!groupedMaterials.has(task.referenceIndex)) {
|
||||
groupedMaterials.set(task.referenceIndex, []);
|
||||
}
|
||||
groupedMaterials.get(task.referenceIndex)!.push(task);
|
||||
}
|
||||
|
||||
groupedMaterials.forEach((surfaceTasks, refIndex) => {
|
||||
const instruction = instructions[refIndex];
|
||||
const refAnalysis = referenceAnalyses?.[refIndex];
|
||||
const materialName =
|
||||
instruction.referenceName ||
|
||||
refAnalysis?.category ||
|
||||
"matériau de référence";
|
||||
|
||||
// Vérifier si un masque est présent pour cette référence
|
||||
const hasMaskForRef = surfaceTasks.some(t => t.hasMask);
|
||||
const maskAnalysisForRef = surfaceTasks.find(t => t.maskAnalysis)?.maskAnalysis;
|
||||
|
||||
const surfaceDescriptions = surfaceTasks
|
||||
.map((t) => {
|
||||
if (t.targetSurface) {
|
||||
return ` • **${t.targetSurface.name}** (${t.targetSurface.category})
|
||||
- Limites: ${t.targetSurface.boundaries}
|
||||
- Matériau actuel à REMPLACER: ${t.targetSurface.currentMaterial}`;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
// Informations sur le masque si présent
|
||||
const maskInfo = hasMaskForRef && maskAnalysisForRef
|
||||
? `
|
||||
### 🎭 ZONE DÉLIMITÉE PAR MASQUE (CRITIQUE - RESPECTER ABSOLUMENT)
|
||||
⚠️ Un MASQUE COMBINÉ a été fourni montrant EXACTEMENT où appliquer ce matériau.
|
||||
- **Zone identifiée**: ${maskAnalysisForRef.zoneDescription}
|
||||
- **Position**: ${maskAnalysisForRef.position.horizontal} / ${maskAnalysisForRef.position.vertical}
|
||||
- **Couverture**: ${maskAnalysisForRef.coveragePercent}% de l'image
|
||||
- **Zone partielle**: ${maskAnalysisForRef.isPartial ? "OUI - appliquer QUE sur cette zone" : "NON - zone complète"}
|
||||
|
||||
<EFBFBD> **INSTRUCTIONS MASQUE**:
|
||||
1. REGARDER l'image du MASQUE COMBINÉ (montre le matériau dans la zone cible)
|
||||
2. APPLIQUER le matériau UNIQUEMENT dans cette zone précise
|
||||
3. NE PAS déborder en dehors de la zone masquée
|
||||
4. La zone autour du masque = CONSERVER L'ASPECT ORIGINAL
|
||||
`
|
||||
: "";
|
||||
|
||||
modificationBlocks.push(`
|
||||
### SURFACE ${refIndex + 1}: Appliquer "${materialName}"
|
||||
**Image de référence**: IMAGE ${refIndex + 2}
|
||||
**Type**: ${refAnalysis?.type || "matériau"} | **Style**: ${refAnalysis?.style || "non spécifié"}
|
||||
**Couleur principale**: ${refAnalysis?.mainColor || "non spécifiée"} | **Finition**: ${refAnalysis?.finish || "non spécifiée"}
|
||||
${refAnalysis?.pattern ? `**Motif**: ${refAnalysis.pattern}` : ""}
|
||||
${maskInfo}
|
||||
**Surfaces ciblées**:
|
||||
${surfaceDescriptions}
|
||||
|
||||
**Instructions d'application**:
|
||||
1. Examiner attentivement l'IMAGE ${refIndex + 2} pour comprendre: texture, couleur, motifs, reflets
|
||||
2. ${hasMaskForRef ? "APPLIQUER ce matériau UNIQUEMENT dans la zone délimitée par le masque" : "APPLIQUER ce matériau sur 100% de chaque surface listée ci-dessus"}
|
||||
3. AUCUNE trace de l'ancien matériau ne doit rester visible${hasMaskForRef ? " (dans la zone masquée)" : ""}
|
||||
4. Adapter les ombres et reflets à l'éclairage de la scène
|
||||
5. Respecter l'échelle du motif/texture selon la perspective`);
|
||||
});
|
||||
}
|
||||
|
||||
// BLOC 2: Remplacements d'objets
|
||||
if (objectTasks.length > 0) {
|
||||
modificationBlocks.push(`
|
||||
## 🔄 REMPLACEMENTS D'OBJETS (Substitution photoréaliste)
|
||||
`);
|
||||
for (const task of objectTasks) {
|
||||
const instruction = instructions[task.referenceIndex];
|
||||
const refAnalysis = task.referenceAnalysis;
|
||||
const objectName =
|
||||
instruction.referenceName ||
|
||||
refAnalysis?.category ||
|
||||
"objet de référence";
|
||||
const targetObj = task.targetObject;
|
||||
|
||||
const tips = refAnalysis?.integrationTips || [];
|
||||
const tipsText = tips.length > 0
|
||||
? tips.map((tip) => ` - ${tip}`).join("\n")
|
||||
: "";
|
||||
|
||||
modificationBlocks.push(`
|
||||
### OBJET: Remplacer "${targetObj?.name || "objet existant"}" par "${objectName}"
|
||||
**Image de référence**: IMAGE ${task.referenceIndex + 2}
|
||||
**Type de référence**: ${refAnalysis?.type || "objet"}
|
||||
**Style**: ${refAnalysis?.style || "non spécifié"} | **Matériau**: ${refAnalysis?.material || "non spécifié"} | **Couleur**: ${refAnalysis?.mainColor || "non spécifiée"}
|
||||
|
||||
**Objet à SUPPRIMER et REMPLACER**:
|
||||
• **${targetObj?.name || "Objet cible"}**
|
||||
- Catégorie: ${targetObj?.category || "objet"}
|
||||
- Position actuelle: ${targetObj?.position || "dans l'image"}
|
||||
- Style actuel: ${targetObj?.style || "non spécifié"}
|
||||
- Dimensions approximatives: ${targetObj?.estimatedDimensions || "à conserver"}
|
||||
|
||||
**Instructions de remplacement CRITIQUES**:
|
||||
1. SUPPRIMER COMPLÈTEMENT l'objet actuel (${targetObj?.name})
|
||||
2. INSÉRER l'objet visible dans l'IMAGE ${task.referenceIndex + 2} À LA MÊME POSITION EXACTE
|
||||
3. Conserver des DIMENSIONS PROPORTIONNELLES à l'espace disponible
|
||||
4. ADAPTER l'éclairage: observer la lumière de la scène et l'appliquer sur l'objet
|
||||
5. GÉNÉRER des OMBRES RÉALISTES cohérentes avec l'éclairage ambiant
|
||||
6. RESPECTER la perspective exacte de la scène
|
||||
7. L'objet doit sembler PHOTOGRAPHIÉ dans la scène, pas ajouté numériquement
|
||||
${tipsText ? `\n**Conseils d'intégration**:\n${tipsText}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
// BLOC 3: Ajouts d'éléments
|
||||
if (addTasks.length > 0) {
|
||||
modificationBlocks.push(`
|
||||
## ➕ AJOUTS D'ÉLÉMENTS (Nouvelles insertions photoréalistes)
|
||||
`);
|
||||
for (const task of addTasks) {
|
||||
const instruction = instructions[task.referenceIndex];
|
||||
const refAnalysis = task.referenceAnalysis;
|
||||
const elementName =
|
||||
instruction.referenceName || refAnalysis?.category || "élément";
|
||||
|
||||
const tips = refAnalysis?.integrationTips || [];
|
||||
const tipsText = tips.length > 0
|
||||
? tips.map((tip) => ` - ${tip}`).join("\n")
|
||||
: "";
|
||||
|
||||
// Construire les informations de quantité et position
|
||||
const quantityInfo = task.quantity
|
||||
? `**⚠️ QUANTITÉ PRÉCISE**: EXACTEMENT ${task.quantity} élément(s) - ${task.quantityText || ""}`
|
||||
: task.quantityText
|
||||
? `**Quantité**: ${task.quantityText}`
|
||||
: "";
|
||||
|
||||
const positionInfo = task.positionConstraints
|
||||
? `**⚠️ ZONE PRÉCISE**: ${task.positionConstraints.description}`
|
||||
: "";
|
||||
|
||||
const sideInfo = task.positionConstraints?.side
|
||||
? `**⚠️ POSITIONNEMENT**: Côté ${
|
||||
task.positionConstraints.side === "right" ? "DROIT" :
|
||||
task.positionConstraints.side === "left" ? "GAUCHE" :
|
||||
task.positionConstraints.side === "center" ? "CENTRE" :
|
||||
task.positionConstraints.side === "top" ? "HAUT" : "BAS"
|
||||
} UNIQUEMENT - NE PAS couvrir tout l'espace`
|
||||
: "";
|
||||
|
||||
const areaInfo = task.positionConstraints?.area === "partial"
|
||||
? `**⚠️ COUVERTURE**: PARTIELLE - seulement une section, PAS la totalité`
|
||||
: "";
|
||||
|
||||
// Informations sur le masque si présent
|
||||
const maskInfo = task.hasMask && task.maskAnalysis
|
||||
? `
|
||||
### 🎭 ZONE DÉLIMITÉE PAR MASQUE (CRITIQUE - RESPECTER ABSOLUMENT)
|
||||
⚠️ Un MASQUE COMBINÉ a été fourni montrant EXACTEMENT où placer cet élément.
|
||||
- **Zone identifiée**: ${task.maskAnalysis.zoneDescription}
|
||||
- **Type d'élément**: ${task.maskAnalysis.elementType}
|
||||
- **Position**: ${task.maskAnalysis.position.horizontal} / ${task.maskAnalysis.position.vertical}
|
||||
- **Couverture**: ${task.maskAnalysis.coveragePercent}% de l'image
|
||||
- **Zone partielle**: ${task.maskAnalysis.isPartial ? "OUI - ne couvrir QUE cette zone" : "NON - zone complète"}
|
||||
|
||||
<EFBFBD> **INSTRUCTIONS MASQUE**:
|
||||
1. REGARDER l'image du MASQUE COMBINÉ fournie (montre la référence dans la zone cible)
|
||||
2. APPLIQUER l'élément UNIQUEMENT dans cette zone précise
|
||||
3. NE PAS déborder en dehors de la zone masquée
|
||||
4. La zone avec la référence visible = C'EST LÀ qu'il faut appliquer
|
||||
`
|
||||
: "";
|
||||
|
||||
modificationBlocks.push(`
|
||||
### AJOUT: Insérer "${elementName}"
|
||||
**Image de référence**: IMAGE ${task.referenceIndex + 2}
|
||||
**Type**: ${refAnalysis?.type || "élément"}
|
||||
**Instruction originale**: "${task.specificInstructions}"
|
||||
${maskInfo}
|
||||
${quantityInfo ? `${quantityInfo}\n` : ""}${sideInfo ? `${sideInfo}\n` : ""}${areaInfo ? `${areaInfo}\n` : ""}${positionInfo ? `${positionInfo}\n` : ""}
|
||||
**Style**: ${refAnalysis?.style || "non spécifié"} | **Dimensions**: ${refAnalysis?.dimensions || "à adapter"}
|
||||
|
||||
**Instructions d'insertion CRITIQUES**:
|
||||
1. Examiner l'élément dans l'IMAGE ${task.referenceIndex + 2}
|
||||
2. ${task.quantity ? `PLACER EXACTEMENT ${task.quantity} éléments` : "Insérer l'élément"} à la position spécifiée${task.hasMask ? " (voir MASQUE COMBINÉ)" : ""}
|
||||
3. ${task.positionConstraints?.side ? `POSITIONNER uniquement sur le côté ${task.positionConstraints.side === "right" ? "DROIT" : task.positionConstraints.side === "left" ? "GAUCHE" : task.positionConstraints.side.toUpperCase()} de la zone` : "Choisir un emplacement approprié"}
|
||||
4. ${task.positionConstraints?.area === "partial" ? "NE PAS couvrir toute la surface - seulement une PARTIE" : "Intégrer naturellement dans l'espace"}
|
||||
5. ADAPTER la taille et perspective à la scène existante
|
||||
6. CRÉER des ombres réalistes cohérentes avec l'éclairage
|
||||
7. INTÉGRER chromatiquement avec l'ambiance de la pièce
|
||||
8. L'élément doit sembler APPARTENIR à la scène originale
|
||||
${tipsText ? `\n**Conseils d'intégration**:\n${tipsText}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Construction de la liste des objets identifiés
|
||||
const objectsListing = (analysis.objects || [])
|
||||
.map(
|
||||
(o) =>
|
||||
` - ${o.name} (${o.category}) - Position: ${o.position} - Style: ${o.style}`
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
// Construction de la liste des surfaces
|
||||
const surfacesListing = (analysis.surfaces || [])
|
||||
.map(
|
||||
(s) =>
|
||||
` - ${s.name} (${s.category}) - Matériau: ${s.currentMaterial}`
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
// Construction de la liste des équipements techniques
|
||||
const equipmentListing = (analysis.technicalEquipment || [])
|
||||
.map((e) => ` - ${e.type} (${e.category}) - Position: ${e.position}`)
|
||||
.join("\n");
|
||||
|
||||
return `# 🎯 MISSION: TRANSFORMATION D'ESPACE PHOTORÉALISTE
|
||||
|
||||
Tu es un MOTEUR DE RENDU IA EXPERT spécialisé dans la visualisation de projets d'aménagement.
|
||||
Tu peux réaliser TOUS types de modifications pour TOUS les métiers du bâtiment et de l'habitat:
|
||||
• Décoration intérieure & extérieure
|
||||
• Rénovation, architecture, agencement
|
||||
• Menuiserie, fermetures, façades
|
||||
• Plomberie, électricité, chauffage
|
||||
• Énergies renouvelables (solaire, PAC)
|
||||
• Paysagisme, piscine, spa
|
||||
• Et tous autres métiers de l'habitat
|
||||
|
||||
${STRICT_FRAMING_RULES}
|
||||
|
||||
${PHOTOREALISTIC_INSERTION_RULES}
|
||||
|
||||
## 📊 ANALYSE DE L'IMAGE ORIGINALE (IMAGE 1)
|
||||
|
||||
**Type d'espace**: ${analysis.spaceType} (${analysis.environment})
|
||||
**Éclairage**: ${analysis.lighting?.type || "non déterminé"} - ${analysis.lighting?.direction || ""}
|
||||
**Intensité**: ${analysis.lighting?.intensity || "medium"} | **Température**: ${analysis.lighting?.temperature || "neutral"}
|
||||
**Perspective**: ${analysis.perspective?.viewType || "frontale"} - ${analysis.perspective?.description || ""}
|
||||
**Hauteur caméra**: ${analysis.perspective?.cameraHeight || "eye_level"}
|
||||
|
||||
### Surfaces identifiées:
|
||||
${surfacesListing || " Aucune surface spécifique identifiée"}
|
||||
|
||||
### Objets identifiés:
|
||||
${objectsListing || " Aucun objet spécifique identifié"}
|
||||
|
||||
### Équipements techniques:
|
||||
${equipmentListing || " Aucun équipement technique identifié"}
|
||||
|
||||
## 🖼️ IMAGES FOURNIES
|
||||
- **IMAGE 1**: Photo originale de l'espace (AVANT transformation) - CADRAGE À RESPECTER ABSOLUMENT
|
||||
- **IMAGES 2, 3, ...**: Éléments de RÉFÉRENCE (matériaux, objets, équipements à intégrer)
|
||||
|
||||
${modificationBlocks.join("\n")}
|
||||
|
||||
## 📋 RÈGLES FINALES ABSOLUES
|
||||
|
||||
### POUR TOUS LES TYPES DE MODIFICATIONS:
|
||||
1. Le CADRAGE doit être STRICTEMENT IDENTIQUE à l'IMAGE 1
|
||||
2. La PERSPECTIVE doit être EXACTEMENT la même
|
||||
3. Les PROPORTIONS de l'image ne changent PAS
|
||||
4. L'ÉCLAIRAGE des éléments modifiés doit être COHÉRENT avec la scène
|
||||
5. Les OMBRES doivent être RÉALISTES et cohérentes
|
||||
6. Le rendu doit être PHOTORÉALISTE de qualité professionnelle
|
||||
|
||||
### NE PAS MODIFIER (sauf si explicitement demandé):
|
||||
- Les éléments non mentionnés dans les instructions
|
||||
- La structure architecturale (position des murs, fenêtres, portes)
|
||||
- L'éclairage naturel existant
|
||||
- La perspective et le point de vue
|
||||
|
||||
### QUALITÉ ATTENDUE:
|
||||
- Rendu indiscernable d'une vraie photographie
|
||||
- Intégration parfaite des éléments modifiés
|
||||
- Aucun artefact, halo, ou défaut visible
|
||||
- Cohérence globale de la scène
|
||||
|
||||
## 🎬 ACTION FINALE
|
||||
Génère UNE SEULE image photoréaliste montrant l'espace APRÈS toutes les transformations demandées.
|
||||
L'image doit avoir EXACTEMENT le même cadrage, la même perspective, et les mêmes proportions que l'IMAGE 1.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt simplifié pour les retries
|
||||
*/
|
||||
export function buildSimplifiedRetryPrompt(
|
||||
instructions: GenerationInstruction[],
|
||||
tasks: ModificationTask[],
|
||||
attempt: number
|
||||
): string {
|
||||
const mods = instructions
|
||||
.map((instr, i) => {
|
||||
const relevantTasks = tasks.filter((t) => t.referenceIndex === i);
|
||||
const targets = relevantTasks
|
||||
.map((t) => t.targetSurface?.name || t.targetObject?.name || "cible")
|
||||
.join(", ");
|
||||
const action =
|
||||
relevantTasks[0]?.actionType === "replace_object"
|
||||
? "Remplacer par l'objet de"
|
||||
: relevantTasks[0]?.actionType === "add_element"
|
||||
? "Ajouter l'élément de"
|
||||
: "Appliquer le matériau de";
|
||||
return `${i + 1}. Cibles: ${targets || instr.location}
|
||||
${action} l'IMAGE ${i + 2}${instr.referenceName ? ` (${instr.referenceName})` : ""}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
return `# TRANSFORMATION D'ESPACE - TENTATIVE ${attempt}
|
||||
|
||||
Génère une image APRÈS TRANSFORMATION basée sur l'IMAGE 1 (photo originale).
|
||||
|
||||
## MODIFICATIONS REQUISES:
|
||||
${mods}
|
||||
|
||||
## RÈGLES ABSOLUES:
|
||||
1. CADRAGE IDENTIQUE à l'IMAGE 1 - pas de zoom, pas de recadrage
|
||||
2. PERSPECTIVE IDENTIQUE - même angle de vue, même point de vue
|
||||
3. Pour les MATÉRIAUX: appliquer sur 100% de la surface indiquée
|
||||
4. Pour les OBJETS: remplacer intégralement avec éclairage et ombres réalistes
|
||||
5. Rendu PHOTORÉALISTE de qualité professionnelle
|
||||
|
||||
## ACTION:
|
||||
Génère l'image maintenant avec le même cadrage exact que l'IMAGE 1.`;
|
||||
}
|
||||
2680
src/lib/gemini/types/categories.ts
Normal file
500
src/lib/gemini/types/index.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES POUR LE SYSTÈME DE GÉNÉRATION AVANT/APRÈS
|
||||
// ============================================================================
|
||||
|
||||
import type {
|
||||
ElementCategory,
|
||||
ModificationType,
|
||||
ModificationAction,
|
||||
ReferenceType,
|
||||
ReferenceAction,
|
||||
} from "./categories";
|
||||
|
||||
// Re-export des catégories
|
||||
export * from "./categories";
|
||||
|
||||
// ============================================================================
|
||||
// INSTRUCTIONS DE GÉNÉRATION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Instruction de modification fournie par l'utilisateur
|
||||
*/
|
||||
export interface GenerationInstruction {
|
||||
/** Description de la zone ou élément à modifier */
|
||||
location: string;
|
||||
/** Chemin vers l'image de référence */
|
||||
referenceImagePath: string;
|
||||
/** Chemin vers l'image de masque noir/blanc (optionnel) */
|
||||
maskImagePath?: string;
|
||||
/** Chemin vers le masque combiné (référence dans la zone) - généré côté serveur */
|
||||
combinedMaskPath?: string;
|
||||
/** Données du masque combiné en base64 (pour envoi à l'IA) */
|
||||
combinedMaskBase64?: string;
|
||||
/** Nom donné à la référence par l'utilisateur */
|
||||
referenceName?: string;
|
||||
/** Type de modification demandée */
|
||||
modificationType?: ModificationType;
|
||||
/** Détails supplémentaires sur la modification */
|
||||
additionalDetails?: string;
|
||||
/** Catégorie de l'élément cible (optionnel, détecté automatiquement) */
|
||||
targetCategory?: ElementCategory;
|
||||
/** Analyse du masque par l'IA */
|
||||
maskAnalysis?: MaskAnalysisInfo;
|
||||
/** Instruction améliorée basée sur l'analyse du masque */
|
||||
improvedLocation?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Informations sur l'analyse du masque
|
||||
*/
|
||||
export interface MaskAnalysisInfo {
|
||||
/** Description de la zone délimitée par le masque */
|
||||
zoneDescription: string;
|
||||
/** Type d'élément couvert */
|
||||
elementType: "surface" | "object" | "area" | "multiple";
|
||||
/** Éléments identifiés dans le masque */
|
||||
elementsInMask: string[];
|
||||
/** Position dans l'image */
|
||||
position: {
|
||||
horizontal: "left" | "center" | "right" | "full-width";
|
||||
vertical: "top" | "middle" | "bottom" | "full-height";
|
||||
};
|
||||
/** Pourcentage de couverture */
|
||||
coveragePercent: number;
|
||||
/** Zone partielle ou totale */
|
||||
isPartial: boolean;
|
||||
/** Corrections suggérées */
|
||||
instructionCorrections: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options de génération d'image
|
||||
*/
|
||||
export interface GenerationOptions {
|
||||
/** Ratio d'aspect de l'image générée */
|
||||
aspectRatio?: "1:1" | "3:4" | "4:3" | "9:16" | "16:9";
|
||||
/** Qualité de l'image générée */
|
||||
quality?: "standard" | "high" | "ultra";
|
||||
/** Style de rendu */
|
||||
renderStyle?: "photorealistic" | "artistic" | "technical";
|
||||
}
|
||||
|
||||
/**
|
||||
* Résultat de la génération
|
||||
*/
|
||||
export interface GenerationResult {
|
||||
/** Chemin vers l'image générée */
|
||||
imagePath: string;
|
||||
/** Description de l'image générée */
|
||||
description: string;
|
||||
/** Nombre de tentatives effectuées */
|
||||
attempts: number;
|
||||
/** Détails de l'analyse initiale */
|
||||
analysisDetails?: ImageAnalysis;
|
||||
/** Durée totale de génération en ms */
|
||||
duration?: number;
|
||||
/** Chemins vers les masques combinés (pour debug) */
|
||||
combinedMaskPaths?: string[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ANALYSE D'IMAGE
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Résultat de l'analyse d'une image par l'agent analyste
|
||||
*/
|
||||
export interface ImageAnalysis {
|
||||
/** Type d'espace identifié */
|
||||
spaceType: string;
|
||||
/** Indique si c'est un espace intérieur ou extérieur */
|
||||
environment: "interior" | "exterior" | "mixed";
|
||||
/** Zones/surfaces identifiées */
|
||||
surfaces: SurfaceInfo[];
|
||||
/** Objets identifiés */
|
||||
objects: ObjectInfo[];
|
||||
/** Description de l'éclairage */
|
||||
lighting: LightingInfo;
|
||||
/** Perspective et cadrage */
|
||||
perspective: PerspectiveInfo;
|
||||
/** Matériaux existants détectés */
|
||||
existingMaterials: MaterialInfo[];
|
||||
/** Équipements techniques visibles */
|
||||
technicalEquipment?: TechnicalEquipmentInfo[];
|
||||
/** Végétation visible */
|
||||
vegetation?: VegetationInfo[];
|
||||
/** Métiers potentiellement concernés */
|
||||
relevantTrades?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Information sur une surface identifiée
|
||||
*/
|
||||
export interface SurfaceInfo {
|
||||
/** ID unique de la surface */
|
||||
id: string;
|
||||
/** Nom descriptif */
|
||||
name: string;
|
||||
/** Description détaillée */
|
||||
description: string;
|
||||
/** Délimitations de la surface */
|
||||
boundaries: string;
|
||||
/** Matériau actuel */
|
||||
currentMaterial: string;
|
||||
/** Catégorie d'élément */
|
||||
category: ElementCategory;
|
||||
/** État de la surface */
|
||||
condition?: "excellent" | "good" | "fair" | "poor" | "damaged";
|
||||
/** Estimation de la surface visible (%) */
|
||||
visiblePercentage?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information sur un objet identifié
|
||||
*/
|
||||
export interface ObjectInfo {
|
||||
/** ID unique de l'objet */
|
||||
id: string;
|
||||
/** Nom de l'objet */
|
||||
name: string;
|
||||
/** Catégorie d'élément */
|
||||
category: ElementCategory;
|
||||
/** Sous-catégorie plus précise */
|
||||
subcategory?: string;
|
||||
/** Description détaillée */
|
||||
description: string;
|
||||
/** Position dans l'image */
|
||||
position: string;
|
||||
/** Style de l'objet */
|
||||
style: string;
|
||||
/** Matériau principal */
|
||||
material: string;
|
||||
/** Couleur dominante */
|
||||
color: string;
|
||||
/** Dimensions estimées */
|
||||
estimatedDimensions?: string;
|
||||
/** Marque identifiée (si visible) */
|
||||
brand?: string;
|
||||
/** État de l'objet */
|
||||
condition?: "new" | "good" | "used" | "worn" | "damaged";
|
||||
}
|
||||
|
||||
/**
|
||||
* Information sur l'éclairage
|
||||
*/
|
||||
export interface LightingInfo {
|
||||
/** Type d'éclairage principal */
|
||||
type: "natural" | "artificial" | "mixed";
|
||||
/** Direction de la lumière principale */
|
||||
direction: string;
|
||||
/** Intensité lumineuse */
|
||||
intensity: "low" | "medium" | "high";
|
||||
/** Température de couleur estimée */
|
||||
temperature: "warm" | "neutral" | "cool";
|
||||
/** Présence d'ombres marquées */
|
||||
shadows: "soft" | "medium" | "hard";
|
||||
/** Heure estimée (pour lumière naturelle) */
|
||||
estimatedTimeOfDay?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information sur la perspective
|
||||
*/
|
||||
export interface PerspectiveInfo {
|
||||
/** Type de vue */
|
||||
viewType: "frontal" | "angular" | "overhead" | "low_angle" | "eye_level";
|
||||
/** Description de la perspective */
|
||||
description: string;
|
||||
/** Hauteur estimée de la caméra */
|
||||
cameraHeight: "floor_level" | "eye_level" | "elevated" | "aerial";
|
||||
/** Largeur de champ estimée */
|
||||
fieldOfView: "narrow" | "normal" | "wide" | "ultra_wide";
|
||||
/** Point de fuite identifiable */
|
||||
vanishingPoint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information sur un matériau existant
|
||||
*/
|
||||
export interface MaterialInfo {
|
||||
/** Zone concernée */
|
||||
zone: string;
|
||||
/** Type de matériau */
|
||||
type: string;
|
||||
/** Couleur */
|
||||
color: string;
|
||||
/** Texture */
|
||||
texture: string;
|
||||
/** Finition */
|
||||
finish?: "matte" | "satin" | "glossy" | "textured";
|
||||
/** État */
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information sur un équipement technique
|
||||
*/
|
||||
export interface TechnicalEquipmentInfo {
|
||||
/** ID unique */
|
||||
id: string;
|
||||
/** Type d'équipement */
|
||||
type: string;
|
||||
/** Catégorie */
|
||||
category: ElementCategory;
|
||||
/** Position */
|
||||
position: string;
|
||||
/** Marque/Modèle si visible */
|
||||
brandModel?: string;
|
||||
/** État apparent */
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information sur la végétation
|
||||
*/
|
||||
export interface VegetationInfo {
|
||||
/** ID unique */
|
||||
id: string;
|
||||
/** Type de végétation */
|
||||
type: string;
|
||||
/** Catégorie */
|
||||
category: ElementCategory;
|
||||
/** Position */
|
||||
position: string;
|
||||
/** Taille estimée */
|
||||
size?: "small" | "medium" | "large";
|
||||
/** État de santé */
|
||||
health?: "healthy" | "fair" | "poor";
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ANALYSE DE RÉFÉRENCE
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Résultat de l'analyse d'une image de référence
|
||||
*/
|
||||
export interface ReferenceAnalysis {
|
||||
/** Type de référence identifié */
|
||||
type: ReferenceType;
|
||||
/** Catégorie principale */
|
||||
category: string;
|
||||
/** Sous-catégorie */
|
||||
subcategory?: string;
|
||||
/** Description détaillée */
|
||||
description: string;
|
||||
/** Couleur principale */
|
||||
mainColor: string;
|
||||
/** Couleurs secondaires */
|
||||
secondaryColors?: string[];
|
||||
/** Style */
|
||||
style: string;
|
||||
/** Matériau principal */
|
||||
material: string;
|
||||
/** Matériaux secondaires */
|
||||
secondaryMaterials?: string[];
|
||||
/** Action recommandée */
|
||||
action: ReferenceAction;
|
||||
/** Finition */
|
||||
finish?: string;
|
||||
/** Pattern/Motif identifié */
|
||||
pattern?: string;
|
||||
/** Dimensions si identifiables */
|
||||
dimensions?: string;
|
||||
/** Marque si identifiable */
|
||||
brand?: string;
|
||||
/** Qualité de l'image de référence */
|
||||
imageQuality: "excellent" | "good" | "acceptable" | "poor";
|
||||
/** Conseils pour une meilleure intégration */
|
||||
integrationTips?: string[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PLAN DE MODIFICATION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Plan de modification généré par l'agent planificateur
|
||||
*/
|
||||
export interface ModificationPlan {
|
||||
/** Analyse originale de l'image */
|
||||
originalAnalysis: ImageAnalysis;
|
||||
/** Tâches de modification à effectuer */
|
||||
tasks: ModificationTask[];
|
||||
/** Prompt global construit */
|
||||
globalPrompt: string;
|
||||
/** Avertissements ou notes */
|
||||
warnings?: string[];
|
||||
/** Instructions enrichies avec compréhension du langage naturel */
|
||||
enrichedInstructions?: EnrichedInstruction[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Instruction enrichie après parsing intelligent
|
||||
*/
|
||||
export interface EnrichedInstruction {
|
||||
/** Texte original de l'instruction */
|
||||
originalText: string;
|
||||
/** Action identifiée */
|
||||
action: "add" | "replace" | "apply_texture" | "remove" | "modify";
|
||||
/** Quantité demandée */
|
||||
quantity: number | "all" | "some" | "partial";
|
||||
/** Texte de quantité (ex: "3 panneaux solaires") */
|
||||
quantityText: string;
|
||||
/** Élément cible identifié */
|
||||
targetElement: string;
|
||||
/** Zone cible identifiée */
|
||||
targetZone: string;
|
||||
/** Contraintes de zone */
|
||||
zoneConstraints: {
|
||||
side?: "left" | "right" | "center" | "top" | "bottom";
|
||||
area?: "partial" | "full" | "specific";
|
||||
description: string;
|
||||
};
|
||||
/** Style demandé */
|
||||
style?: string;
|
||||
/** Couleur demandée */
|
||||
color?: string;
|
||||
/** Notes additionnelles */
|
||||
additionalNotes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Contraintes de positionnement
|
||||
*/
|
||||
export interface PositionConstraints {
|
||||
/** Côté ciblé */
|
||||
side?: "left" | "right" | "center" | "top" | "bottom";
|
||||
/** Type de zone */
|
||||
area?: "partial" | "full" | "specific";
|
||||
/** Description textuelle */
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tâche de modification individuelle
|
||||
*/
|
||||
export interface ModificationTask {
|
||||
/** Priorité de la tâche (ordre d'exécution) */
|
||||
priority: number;
|
||||
/** Surface cible (pour les modifications de surface) */
|
||||
targetSurface?: SurfaceInfo;
|
||||
/** Objet cible (pour les remplacements d'objet) */
|
||||
targetObject?: ObjectInfo;
|
||||
/** Zone cible textuelle (si pas de surface/objet précis) */
|
||||
targetZone?: string;
|
||||
/** Nouveau matériau/élément à appliquer */
|
||||
targetMaterial: string;
|
||||
/** Index de l'image de référence */
|
||||
referenceIndex: number;
|
||||
/** Instructions spécifiques */
|
||||
specificInstructions: string;
|
||||
/** Type d'action */
|
||||
actionType: ReferenceAction;
|
||||
/** Analyse de la référence */
|
||||
referenceAnalysis?: ReferenceAnalysis;
|
||||
/** Catégorie de l'élément modifié */
|
||||
elementCategory?: ElementCategory;
|
||||
/** Confidence de la correspondance (0-1) */
|
||||
matchConfidence?: number;
|
||||
/** Quantité d'éléments à ajouter/modifier */
|
||||
quantity?: number;
|
||||
/** Texte de quantité */
|
||||
quantityText?: string;
|
||||
/** Contraintes de positionnement */
|
||||
positionConstraints?: PositionConstraints;
|
||||
/** Instruction enrichie associée */
|
||||
enrichedInstruction?: EnrichedInstruction;
|
||||
/** Indique si un masque est associé à cette tâche */
|
||||
hasMask?: boolean;
|
||||
/** Analyse du masque associé */
|
||||
maskAnalysis?: MaskAnalysisInfo;
|
||||
/** Image du masque combiné en base64 */
|
||||
combinedMaskBase64?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DONNÉES D'IMAGE
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Données d'image préparées pour l'API
|
||||
*/
|
||||
export interface PreparedImage {
|
||||
/** Image encodée en base64 */
|
||||
base64: string;
|
||||
/** Type MIME de l'image */
|
||||
mimeType: string;
|
||||
/** Taille en octets */
|
||||
sizeBytes?: number;
|
||||
/** Dimensions originales */
|
||||
originalDimensions?: { width: number; height: number };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CALLBACKS & EVENTS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Événement de progression
|
||||
*/
|
||||
export interface ProgressEvent {
|
||||
/** Type d'événement */
|
||||
type: "log" | "step" | "error" | "warning";
|
||||
/** Icône associée */
|
||||
icon?: string;
|
||||
/** Message */
|
||||
message?: string;
|
||||
/** Étape en cours */
|
||||
step?: string;
|
||||
/** Statut de l'étape */
|
||||
status?: "pending" | "loading" | "done" | "error";
|
||||
/** Données additionnelles */
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback de progression
|
||||
*/
|
||||
export type ProgressCallback = (event: ProgressEvent) => void;
|
||||
|
||||
// ============================================================================
|
||||
// CONFIGURATION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Configuration du système de génération
|
||||
*/
|
||||
export interface GenerationConfig {
|
||||
/** Nombre maximum de tentatives */
|
||||
maxRetries: number;
|
||||
/** Délai initial entre les tentatives (ms) */
|
||||
initialDelayMs: number;
|
||||
/** Délai maximum entre les tentatives (ms) */
|
||||
maxDelayMs: number;
|
||||
/** Multiplicateur de backoff */
|
||||
backoffMultiplier: number;
|
||||
/** Taille maximum d'image en octets */
|
||||
maxImageSizeBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration des modèles AI
|
||||
*/
|
||||
export interface ModelConfig {
|
||||
/** Modèle pour l'analyse */
|
||||
analyzer: string;
|
||||
/** Modèle pour la génération d'image */
|
||||
generator: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration d'image générée
|
||||
*/
|
||||
export interface ImageConfig {
|
||||
/** Ratio d'aspect */
|
||||
aspectRatio: "1:1" | "3:4" | "4:3" | "9:16" | "16:9";
|
||||
/** Taille de l'image */
|
||||
imageSize: "1K" | "2K" | "4K";
|
||||
}
|
||||
488
src/lib/gemini/utils/image.ts
Normal file
@@ -0,0 +1,488 @@
|
||||
// ============================================================================
|
||||
// UTILITAIRES POUR LE TRAITEMENT D'IMAGES
|
||||
// ============================================================================
|
||||
|
||||
import heicConvert from "heic-convert";
|
||||
import sharp from "sharp";
|
||||
import { getImageBuffer } from "../../storage";
|
||||
import { APPLE_FORMATS, MIME_TYPES, GENERATION_CONFIG } from "../config";
|
||||
import type { PreparedImage } from "../types";
|
||||
|
||||
/**
|
||||
* Obtient le type MIME d'un fichier basé sur son extension
|
||||
*/
|
||||
export function getMimeType(filePath: string): string {
|
||||
const ext = filePath.toLowerCase().split(".").pop();
|
||||
return MIME_TYPES[ext || ""] || "image/jpeg";
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause asynchrone
|
||||
*/
|
||||
export async function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Prépare une image pour l'API Gemini
|
||||
* - Lit l'image depuis S3
|
||||
* - Convertit les formats Apple (HEIC, HEIF) en JPEG
|
||||
* - Optimise les images trop volumineuses (>4MB)
|
||||
*/
|
||||
export async function prepareImageForAPI(
|
||||
imagePath: string
|
||||
): Promise<PreparedImage> {
|
||||
const ext = imagePath.toLowerCase().split(".").pop() || "";
|
||||
let buffer: Buffer;
|
||||
|
||||
try {
|
||||
buffer = await getImageBuffer(imagePath);
|
||||
} catch (error: any) {
|
||||
console.error(`Erreur lecture image S3: ${imagePath}`, error);
|
||||
throw new Error(
|
||||
`Image introuvable sur S3: ${imagePath}. ${error?.message || ""}`
|
||||
);
|
||||
}
|
||||
|
||||
let mimeType = getMimeType(imagePath);
|
||||
|
||||
// Conversion des formats Apple (HEIC, HEIF) avec heic-convert
|
||||
if (APPLE_FORMATS.includes(ext)) {
|
||||
console.log(` 🔄 Conversion ${ext.toUpperCase()} → JPEG pour API...`);
|
||||
try {
|
||||
const converted = await heicConvert({
|
||||
buffer: buffer,
|
||||
format: "JPEG",
|
||||
quality: 0.9,
|
||||
});
|
||||
buffer = Buffer.from(converted);
|
||||
mimeType = "image/jpeg";
|
||||
console.log(
|
||||
` ✓ Converti pour API: ${(buffer.length / 1024).toFixed(0)} KB`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(` ❌ Erreur conversion ${ext}:`, error);
|
||||
throw new Error(
|
||||
`Impossible de convertir ${ext.toUpperCase()}. Format non supporté.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Optimisation si l'image est trop volumineuse (>4MB) avec sharp
|
||||
if (buffer.length > GENERATION_CONFIG.maxImageSizeBytes) {
|
||||
console.log(
|
||||
` 📐 Image trop volumineuse (${(buffer.length / 1024 / 1024).toFixed(
|
||||
1
|
||||
)}MB), optimisation...`
|
||||
);
|
||||
try {
|
||||
buffer = Buffer.from(
|
||||
await sharp(buffer)
|
||||
.resize(2048, 2048, { fit: "inside", withoutEnlargement: true })
|
||||
.jpeg({ quality: 85 })
|
||||
.toBuffer()
|
||||
);
|
||||
mimeType = "image/jpeg";
|
||||
console.log(` ✓ Optimisé: ${(buffer.length / 1024).toFixed(0)} KB`);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
` ⚠️ Optimisation échouée, utilisation de l'image originale`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
base64: buffer.toString("base64"),
|
||||
mimeType,
|
||||
sizeBytes: buffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Informations pour une zone de masque annotée
|
||||
*/
|
||||
export interface MaskZoneInfo {
|
||||
maskImage: PreparedImage;
|
||||
referenceImage: PreparedImage;
|
||||
referenceName: string;
|
||||
instruction: string;
|
||||
referenceIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un masque combiné où la zone blanche du masque contient la référence
|
||||
* et la zone noire contient l'image originale
|
||||
*/
|
||||
export async function createCombinedMaskImage(
|
||||
originalImage: PreparedImage,
|
||||
maskImage: PreparedImage,
|
||||
referenceImage: PreparedImage
|
||||
): Promise<PreparedImage> {
|
||||
console.log(" 🎭 Création du masque combiné...");
|
||||
|
||||
try {
|
||||
// Décoder les images depuis base64
|
||||
const originalBuffer = Buffer.from(originalImage.base64, "base64");
|
||||
const maskBuffer = Buffer.from(maskImage.base64, "base64");
|
||||
const referenceBuffer = Buffer.from(referenceImage.base64, "base64");
|
||||
|
||||
// Obtenir les dimensions de l'image originale
|
||||
const originalMetadata = await sharp(originalBuffer).metadata();
|
||||
const { width, height } = originalMetadata;
|
||||
|
||||
if (!width || !height) {
|
||||
throw new Error("Impossible de lire les dimensions de l'image");
|
||||
}
|
||||
|
||||
// Redimensionner le masque aux dimensions de l'image originale
|
||||
const maskResized = await sharp(maskBuffer)
|
||||
.resize(width, height, { fit: "fill" })
|
||||
.greyscale()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
// Redimensionner la référence aux dimensions de l'image originale
|
||||
const referenceResized = await sharp(referenceBuffer)
|
||||
.resize(width, height, { fit: "cover", position: "center" })
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
// Charger l'image originale en RGBA
|
||||
const originalRGBA = await sharp(originalBuffer)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
// Créer le masque combiné :
|
||||
// Zone BLANCHE = RÉFÉRENCE (montrer ce qui sera appliqué)
|
||||
// Zone NOIRE = IMAGE ORIGINALE (contexte)
|
||||
const combinedBuffer = Buffer.alloc(width * height * 4);
|
||||
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const imgIdx = i * 4;
|
||||
const maskValue = maskResized[i];
|
||||
|
||||
if (maskValue > 127) {
|
||||
// Zone blanche = RÉFÉRENCE
|
||||
combinedBuffer[imgIdx] = referenceResized[imgIdx];
|
||||
combinedBuffer[imgIdx + 1] = referenceResized[imgIdx + 1];
|
||||
combinedBuffer[imgIdx + 2] = referenceResized[imgIdx + 2];
|
||||
combinedBuffer[imgIdx + 3] = 255;
|
||||
} else {
|
||||
// Zone noire = IMAGE ORIGINALE
|
||||
combinedBuffer[imgIdx] = originalRGBA[imgIdx];
|
||||
combinedBuffer[imgIdx + 1] = originalRGBA[imgIdx + 1];
|
||||
combinedBuffer[imgIdx + 2] = originalRGBA[imgIdx + 2];
|
||||
combinedBuffer[imgIdx + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir en PNG
|
||||
const finalBuffer = await sharp(combinedBuffer, {
|
||||
raw: { width, height, channels: 4 },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
console.log(` ✓ Masque combiné créé: ${(finalBuffer.length / 1024).toFixed(0)} KB`);
|
||||
|
||||
return {
|
||||
base64: finalBuffer.toString("base64"),
|
||||
mimeType: "image/png",
|
||||
sizeBytes: finalBuffer.length,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(" ❌ Erreur création masque combiné:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Couleurs pour les annotations de chaque zone (palette distincte)
|
||||
*/
|
||||
const ANNOTATION_COLORS = [
|
||||
{ r: 255, g: 0, b: 0 }, // Rouge
|
||||
{ r: 0, g: 150, b: 255 }, // Bleu
|
||||
{ r: 0, g: 200, b: 0 }, // Vert
|
||||
{ r: 255, g: 150, b: 0 }, // Orange
|
||||
{ r: 200, g: 0, b: 255 }, // Violet
|
||||
{ r: 0, g: 200, b: 200 }, // Cyan
|
||||
];
|
||||
|
||||
/**
|
||||
* Trouve le centre de gravité d'une zone blanche dans un masque
|
||||
*/
|
||||
async function findMaskCenter(
|
||||
maskBuffer: Buffer,
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<{ x: number; y: number }> {
|
||||
let sumX = 0, sumY = 0, count = 0;
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = y * width + x;
|
||||
if (maskBuffer[idx] > 127) {
|
||||
sumX += x;
|
||||
sumY += y;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
return { x: width / 2, y: height / 2 };
|
||||
}
|
||||
|
||||
return { x: Math.round(sumX / count), y: Math.round(sumY / count) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une image annotée avec toutes les zones fusionnées et des annotations
|
||||
* Fusionne TOUS les masques en UN SEUL avec des annotations visuelles
|
||||
*/
|
||||
export async function createMergedAnnotatedMask(
|
||||
originalImage: PreparedImage,
|
||||
zones: MaskZoneInfo[]
|
||||
): Promise<PreparedImage> {
|
||||
console.log(` 🎭 Fusion de ${zones.length} masque(s) avec annotations...`);
|
||||
|
||||
try {
|
||||
const originalBuffer = Buffer.from(originalImage.base64, "base64");
|
||||
const originalMetadata = await sharp(originalBuffer).metadata();
|
||||
const { width, height } = originalMetadata;
|
||||
|
||||
if (!width || !height) {
|
||||
throw new Error("Impossible de lire les dimensions de l'image");
|
||||
}
|
||||
|
||||
// Charger l'image originale en RGBA
|
||||
const originalRGBA = await sharp(originalBuffer)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
// Buffer final pour l'image fusionnée
|
||||
const mergedBuffer = Buffer.alloc(width * height * 4);
|
||||
|
||||
// Copier l'image originale comme base
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const imgIdx = i * 4;
|
||||
mergedBuffer[imgIdx] = originalRGBA[imgIdx];
|
||||
mergedBuffer[imgIdx + 1] = originalRGBA[imgIdx + 1];
|
||||
mergedBuffer[imgIdx + 2] = originalRGBA[imgIdx + 2];
|
||||
mergedBuffer[imgIdx + 3] = 255;
|
||||
}
|
||||
|
||||
// Stocker les infos pour les annotations SVG
|
||||
const annotationData: Array<{
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
color: { r: number; g: number; b: number };
|
||||
label: string;
|
||||
instruction: string;
|
||||
}> = [];
|
||||
|
||||
// Appliquer chaque masque avec sa référence
|
||||
for (let zoneIdx = 0; zoneIdx < zones.length; zoneIdx++) {
|
||||
const zone = zones[zoneIdx];
|
||||
const color = ANNOTATION_COLORS[zoneIdx % ANNOTATION_COLORS.length];
|
||||
|
||||
const maskBuffer = Buffer.from(zone.maskImage.base64, "base64");
|
||||
const referenceBuffer = Buffer.from(zone.referenceImage.base64, "base64");
|
||||
|
||||
// Redimensionner le masque
|
||||
const maskResized = await sharp(maskBuffer)
|
||||
.resize(width, height, { fit: "fill" })
|
||||
.greyscale()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
// Redimensionner la référence
|
||||
const referenceResized = await sharp(referenceBuffer)
|
||||
.resize(width, height, { fit: "cover", position: "center" })
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
// Trouver le centre du masque pour l'annotation
|
||||
const center = await findMaskCenter(maskResized, width, height);
|
||||
|
||||
// Appliquer la référence dans la zone du masque
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const imgIdx = i * 4;
|
||||
const maskValue = maskResized[i];
|
||||
|
||||
if (maskValue > 127) {
|
||||
// Zone blanche = appliquer la RÉFÉRENCE
|
||||
mergedBuffer[imgIdx] = referenceResized[imgIdx];
|
||||
mergedBuffer[imgIdx + 1] = referenceResized[imgIdx + 1];
|
||||
mergedBuffer[imgIdx + 2] = referenceResized[imgIdx + 2];
|
||||
mergedBuffer[imgIdx + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
// Dessiner un contour coloré autour de la zone
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = y * width + x;
|
||||
const maskValue = maskResized[idx];
|
||||
|
||||
// Détecter les bords (pixels blancs avec un voisin noir)
|
||||
if (maskValue > 127) {
|
||||
const isEdge =
|
||||
(x > 0 && maskResized[idx - 1] <= 127) ||
|
||||
(x < width - 1 && maskResized[idx + 1] <= 127) ||
|
||||
(y > 0 && maskResized[idx - width] <= 127) ||
|
||||
(y < height - 1 && maskResized[idx + width] <= 127);
|
||||
|
||||
if (isEdge) {
|
||||
// Dessiner un contour épais (3 pixels)
|
||||
for (let dy = -2; dy <= 2; dy++) {
|
||||
for (let dx = -2; dx <= 2; dx++) {
|
||||
const nx = x + dx;
|
||||
const ny = y + dy;
|
||||
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
|
||||
const nIdx = (ny * width + nx) * 4;
|
||||
mergedBuffer[nIdx] = color.r;
|
||||
mergedBuffer[nIdx + 1] = color.g;
|
||||
mergedBuffer[nIdx + 2] = color.b;
|
||||
mergedBuffer[nIdx + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stocker les données pour l'annotation
|
||||
annotationData.push({
|
||||
centerX: center.x,
|
||||
centerY: center.y,
|
||||
color,
|
||||
label: zone.referenceName || `Ref ${zone.referenceIndex + 1}`,
|
||||
instruction: zone.instruction,
|
||||
});
|
||||
}
|
||||
|
||||
// Convertir le buffer en image
|
||||
let mergedImage = await sharp(mergedBuffer, {
|
||||
raw: { width, height, channels: 4 },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
// Créer les annotations SVG
|
||||
const svgAnnotations = createAnnotationsSVG(annotationData, width, height);
|
||||
|
||||
// Superposer les annotations SVG
|
||||
mergedImage = await sharp(mergedImage)
|
||||
.composite([
|
||||
{
|
||||
input: Buffer.from(svgAnnotations),
|
||||
top: 0,
|
||||
left: 0,
|
||||
},
|
||||
])
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
console.log(` ✓ Masque fusionné annoté créé: ${(mergedImage.length / 1024).toFixed(0)} KB`);
|
||||
console.log(` 📍 ${annotationData.length} zone(s) annotée(s)`);
|
||||
|
||||
return {
|
||||
base64: mergedImage.toString("base64"),
|
||||
mimeType: "image/png",
|
||||
sizeBytes: mergedImage.length,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(" ❌ Erreur création masque fusionné:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée le SVG des annotations (flèches, labels, instructions)
|
||||
*/
|
||||
function createAnnotationsSVG(
|
||||
annotations: Array<{
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
color: { r: number; g: number; b: number };
|
||||
label: string;
|
||||
instruction: string;
|
||||
}>,
|
||||
width: number,
|
||||
height: number
|
||||
): string {
|
||||
let svgContent = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="2" dy="2" stdDeviation="3" flood-color="#000" flood-opacity="0.5"/>
|
||||
</filter>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="white" stroke="black" stroke-width="0.5"/>
|
||||
</marker>
|
||||
</defs>`;
|
||||
|
||||
for (let i = 0; i < annotations.length; i++) {
|
||||
const ann = annotations[i];
|
||||
const { r, g, b } = ann.color;
|
||||
const colorStr = `rgb(${r},${g},${b})`;
|
||||
|
||||
// Position du label (décalé vers le haut-droite ou autre selon la position)
|
||||
let labelX = ann.centerX + 60;
|
||||
let labelY = ann.centerY - 40;
|
||||
|
||||
// Ajuster si le label sort de l'image
|
||||
if (labelX + 200 > width) labelX = ann.centerX - 200;
|
||||
if (labelY < 50) labelY = ann.centerY + 80;
|
||||
if (labelY + 60 > height) labelY = height - 70;
|
||||
|
||||
// Numéro dans un cercle au centre
|
||||
svgContent += `
|
||||
<circle cx="${ann.centerX}" cy="${ann.centerY}" r="25" fill="${colorStr}" stroke="white" stroke-width="3" filter="url(#shadow)"/>
|
||||
<text x="${ann.centerX}" y="${ann.centerY + 8}" text-anchor="middle" font-family="Arial, sans-serif" font-size="24" font-weight="bold" fill="white">${i + 1}</text>`;
|
||||
|
||||
// Flèche du label vers le centre
|
||||
svgContent += `
|
||||
<line x1="${labelX}" y1="${labelY + 15}" x2="${ann.centerX + 30}" y2="${ann.centerY}" stroke="white" stroke-width="3" marker-end="url(#arrowhead)"/>
|
||||
<line x1="${labelX}" y1="${labelY + 15}" x2="${ann.centerX + 30}" y2="${ann.centerY}" stroke="${colorStr}" stroke-width="2"/>`;
|
||||
|
||||
// Box du label avec fond
|
||||
const labelWidth = Math.max(ann.label.length * 10, ann.instruction.length * 7, 150);
|
||||
svgContent += `
|
||||
<rect x="${labelX - 5}" y="${labelY - 25}" width="${labelWidth + 10}" height="65" rx="8" fill="rgba(0,0,0,0.8)" stroke="${colorStr}" stroke-width="2"/>`;
|
||||
|
||||
// Texte du label
|
||||
svgContent += `
|
||||
<text x="${labelX + 5}" y="${labelY - 5}" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="${colorStr}">[${i + 1}] ${escapeXml(ann.label)}</text>
|
||||
<text x="${labelX + 5}" y="${labelY + 15}" font-family="Arial, sans-serif" font-size="12" fill="white">${escapeXml(truncateText(ann.instruction, 30))}</text>
|
||||
<text x="${labelX + 5}" y="${labelY + 32}" font-family="Arial, sans-serif" font-size="10" fill="#aaa">→ Appliquer ici</text>`;
|
||||
}
|
||||
|
||||
svgContent += `</svg>`;
|
||||
return svgContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Échappe les caractères spéciaux XML
|
||||
*/
|
||||
function escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tronque le texte si trop long
|
||||
*/
|
||||
function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength - 3) + "...";
|
||||
}
|
||||
139
src/lib/plans.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { PlanType } from "./db";
|
||||
import { getSubscription, type Subscription } from "./subscriptions";
|
||||
|
||||
/**
|
||||
* Utilitaire pour déterminer le plan d'un utilisateur
|
||||
* basé sur les abonnements Stripe
|
||||
*/
|
||||
|
||||
// IDs des utilisateurs admin (générations illimitées)
|
||||
// Définir dans .env: ADMIN_USER_IDS=user_xxx,user_yyy
|
||||
const ADMIN_USER_IDS_RAW =
|
||||
typeof process !== "undefined" && process.env?.ADMIN_USER_IDS
|
||||
? process.env.ADMIN_USER_IDS
|
||||
: typeof import.meta !== "undefined" && import.meta.env?.ADMIN_USER_IDS
|
||||
? import.meta.env.ADMIN_USER_IDS
|
||||
: "";
|
||||
|
||||
const ADMIN_USER_IDS = ADMIN_USER_IDS_RAW.split(",")
|
||||
.map((id: string) => id.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
export function isAdminUser(userId: string): boolean {
|
||||
return ADMIN_USER_IDS.includes(userId);
|
||||
}
|
||||
|
||||
export interface UserPlanInfo {
|
||||
planType: PlanType;
|
||||
planName: string;
|
||||
monthlyLimit: number | null;
|
||||
totalLimit: number | null;
|
||||
isPaid: boolean;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
// Plan admin avec générations illimitées
|
||||
export const ADMIN_PLAN: UserPlanInfo = {
|
||||
planType: "pro",
|
||||
planName: "Admin",
|
||||
monthlyLimit: null, // Illimité
|
||||
totalLimit: null, // Illimité
|
||||
isPaid: true,
|
||||
isAdmin: true,
|
||||
};
|
||||
|
||||
// Définition des plans
|
||||
export const PLANS: Record<PlanType, UserPlanInfo> = {
|
||||
pro: {
|
||||
planType: "pro",
|
||||
planName: "Pro (50 projets/mois)",
|
||||
monthlyLimit: 50,
|
||||
totalLimit: null,
|
||||
isPaid: true,
|
||||
},
|
||||
standard: {
|
||||
planType: "standard",
|
||||
planName: "Standard (25 projets/mois)",
|
||||
monthlyLimit: 25,
|
||||
totalLimit: null,
|
||||
isPaid: true,
|
||||
},
|
||||
free: {
|
||||
planType: "free",
|
||||
planName: "Gratuit",
|
||||
monthlyLimit: null,
|
||||
totalLimit: 3,
|
||||
isPaid: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Plan gratuit par défaut
|
||||
export const FREE_PLAN = PLANS.free;
|
||||
|
||||
/**
|
||||
* Récupère le plan d'un utilisateur depuis son abonnement Stripe
|
||||
*/
|
||||
function getStripeSubscriptionPlan(
|
||||
subscription: Subscription
|
||||
): UserPlanInfo | null {
|
||||
// Vérifier si l'abonnement est actif
|
||||
if (subscription.status !== "active") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Vérifier si dans la période de validité
|
||||
if (subscription.current_period_end) {
|
||||
const endDate = new Date(subscription.current_period_end);
|
||||
if (endDate < new Date()) {
|
||||
return null; // Période expirée
|
||||
}
|
||||
}
|
||||
|
||||
// Mapper le plan_type vers notre système
|
||||
switch (subscription.plan_type) {
|
||||
case "pro":
|
||||
return PLANS.pro;
|
||||
case "standard":
|
||||
return PLANS.standard;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine le plan d'un utilisateur en vérifiant:
|
||||
* 1. Si admin (illimité)
|
||||
* 2. Abonnement Stripe actif
|
||||
* 3. Plan gratuit par défaut
|
||||
*/
|
||||
export function getUserPlan(userId?: string | null): UserPlanInfo {
|
||||
// 1. Vérifier si l'utilisateur est admin
|
||||
if (userId && isAdminUser(userId)) {
|
||||
return ADMIN_PLAN;
|
||||
}
|
||||
|
||||
// 2. Vérifier l'abonnement Stripe
|
||||
if (userId) {
|
||||
try {
|
||||
const stripeSubscription = getSubscription(userId);
|
||||
if (stripeSubscription) {
|
||||
const stripePlan = getStripeSubscriptionPlan(stripeSubscription);
|
||||
if (stripePlan) {
|
||||
return stripePlan;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de la vérification abonnement Stripe:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Par défaut, plan gratuit
|
||||
return FREE_PLAN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir les infos du plan par son type
|
||||
*/
|
||||
export function getPlanInfo(planType: PlanType): UserPlanInfo {
|
||||
return PLANS[planType] || FREE_PLAN;
|
||||
}
|
||||
381
src/lib/storage.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import aws4 from "aws4";
|
||||
import https from "https";
|
||||
import heicConvert from "heic-convert";
|
||||
import sharp from "sharp";
|
||||
|
||||
// Configuration S3 Hetzner depuis les variables d'environnement
|
||||
// Astro utilise import.meta.env, avec fallback sur process.env pour compatibilité
|
||||
const S3_REGION = import.meta.env.S3_REGION || process.env.S3_REGION || "hel1";
|
||||
const S3_ACCESS_KEY =
|
||||
import.meta.env.S3_ACCESS_KEY || process.env.S3_ACCESS_KEY || "";
|
||||
const S3_SECRET_KEY =
|
||||
import.meta.env.S3_SECRET_KEY || process.env.S3_SECRET_KEY || "";
|
||||
const S3_BUCKET =
|
||||
import.meta.env.S3_BUCKET || process.env.S3_BUCKET || "primadopu";
|
||||
|
||||
// Hetzner utilise le style virtual-hosted: bucket.region.your-objectstorage.com
|
||||
const S3_HOST = `${S3_BUCKET}.${S3_REGION}.your-objectstorage.com`;
|
||||
|
||||
// Formats Apple nécessitant une conversion
|
||||
const APPLE_FORMATS = ["heic", "heif", "hif"];
|
||||
|
||||
// Formats supportés par l'API Gemini
|
||||
const SUPPORTED_FORMATS = ["jpg", "jpeg", "png", "gif", "webp"];
|
||||
|
||||
/**
|
||||
* Signe une requête pour S3 Hetzner
|
||||
*/
|
||||
function signRequest(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: Buffer,
|
||||
contentType?: string
|
||||
): { url: string; headers: Record<string, string> } {
|
||||
const opts: aws4.Request = {
|
||||
host: S3_HOST,
|
||||
path: path,
|
||||
method: method,
|
||||
service: "s3",
|
||||
region: S3_REGION,
|
||||
headers: {
|
||||
"Content-Type": contentType || "application/octet-stream",
|
||||
// Hetzner S3 requiert UNSIGNED-PAYLOAD pour éviter les erreurs InvalidArgument
|
||||
"x-amz-content-sha256": "UNSIGNED-PAYLOAD",
|
||||
},
|
||||
};
|
||||
|
||||
if (body) {
|
||||
opts.headers!["Content-Length"] = body.length.toString();
|
||||
// Ne pas passer le body à aws4 pour éviter le calcul du hash
|
||||
// On utilise UNSIGNED-PAYLOAD à la place
|
||||
}
|
||||
|
||||
const signed = aws4.sign(opts, {
|
||||
accessKeyId: S3_ACCESS_KEY,
|
||||
secretAccessKey: S3_SECRET_KEY,
|
||||
});
|
||||
|
||||
return {
|
||||
url: `https://${S3_HOST}${path}`,
|
||||
headers: signed.headers as Record<string, string>,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit une image HEIC/HEIF en JPEG si nécessaire
|
||||
*/
|
||||
async function convertImageIfNeeded(
|
||||
buffer: Buffer,
|
||||
originalExt: string
|
||||
): Promise<{ buffer: Buffer; ext: string; converted: boolean }> {
|
||||
const extLower = originalExt.toLowerCase();
|
||||
|
||||
// Si c'est un format Apple, convertir en JPEG avec heic-convert
|
||||
if (APPLE_FORMATS.includes(extLower)) {
|
||||
console.log(` 🔄 Conversion ${extLower.toUpperCase()} → JPEG...`);
|
||||
try {
|
||||
const converted = await heicConvert({
|
||||
buffer: buffer,
|
||||
format: "JPEG",
|
||||
quality: 0.92,
|
||||
});
|
||||
const convertedBuffer = Buffer.from(converted);
|
||||
console.log(
|
||||
` ✓ Converti: ${(buffer.length / 1024).toFixed(0)} KB → ${(
|
||||
convertedBuffer.length / 1024
|
||||
).toFixed(0)} KB`
|
||||
);
|
||||
return { buffer: convertedBuffer, ext: "jpg", converted: true };
|
||||
} catch (error) {
|
||||
console.error(` ❌ Erreur conversion HEIC:`, error);
|
||||
throw new Error(
|
||||
`Impossible de convertir le fichier ${extLower.toUpperCase()}. Veuillez utiliser un format JPEG ou PNG.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Si c'est déjà un format supporté, retourner tel quel
|
||||
if (SUPPORTED_FORMATS.includes(extLower)) {
|
||||
return { buffer, ext: extLower, converted: false };
|
||||
}
|
||||
|
||||
// Format inconnu - tenter une conversion en JPEG via sharp
|
||||
console.log(` ⚠️ Format inconnu (${extLower}), tentative de conversion...`);
|
||||
try {
|
||||
const convertedBuffer = await sharp(buffer)
|
||||
.jpeg({ quality: 90 })
|
||||
.toBuffer();
|
||||
return {
|
||||
buffer: Buffer.from(convertedBuffer),
|
||||
ext: "jpg",
|
||||
converted: true,
|
||||
};
|
||||
} catch {
|
||||
throw new Error(`Format d'image non supporté: ${extLower}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir le type MIME à partir de l'extension
|
||||
*/
|
||||
function getMimeType(ext: string): string {
|
||||
const mimeTypes: Record<string, string> = {
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
};
|
||||
return mimeTypes[ext.toLowerCase()] || "application/octet-stream";
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload un buffer vers S3 Hetzner en utilisant https natif
|
||||
*/
|
||||
async function uploadToS3(
|
||||
s3Key: string,
|
||||
buffer: Buffer,
|
||||
contentType: string
|
||||
): Promise<void> {
|
||||
const path = `/${s3Key}`;
|
||||
const { headers } = signRequest("PUT", path, buffer, contentType);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: S3_HOST,
|
||||
port: 443,
|
||||
path: path,
|
||||
method: "PUT",
|
||||
headers: headers,
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => {
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
console.error("Erreur S3 upload:", res.statusCode, data);
|
||||
reject(new Error(`S3 upload failed: ${res.statusCode} - ${data}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.on("error", (e) => reject(e));
|
||||
req.write(buffer);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde une image sur S3 Hetzner
|
||||
* @returns Le chemin S3 de l'image (utilisé comme clé)
|
||||
*/
|
||||
export async function saveImage(
|
||||
file: File,
|
||||
userId: string,
|
||||
type: "references" | "originals"
|
||||
): Promise<string> {
|
||||
try {
|
||||
const originalBuffer = Buffer.from(await file.arrayBuffer());
|
||||
const originalExt = file.name.split(".").pop() || "jpg";
|
||||
|
||||
// Convertir si nécessaire (HEIC, HEIF, etc.)
|
||||
const { buffer, ext, converted } = await convertImageIfNeeded(
|
||||
originalBuffer,
|
||||
originalExt
|
||||
);
|
||||
|
||||
const filename = `${userId}-${Date.now()}-${crypto.randomUUID()}.${ext}`;
|
||||
const s3Key = `${type}/${filename}`;
|
||||
|
||||
// Upload vers S3
|
||||
await uploadToS3(s3Key, buffer, getMimeType(ext));
|
||||
|
||||
if (converted) {
|
||||
console.log(` 💾 Sauvegardé sur S3 (converti): ${s3Key}`);
|
||||
} else {
|
||||
console.log(` 💾 Sauvegardé sur S3: ${s3Key}`);
|
||||
}
|
||||
|
||||
// Retourner le chemin S3 (sera utilisé pour construire l'URL)
|
||||
return `/${s3Key}`;
|
||||
} catch (error) {
|
||||
console.error("Erreur upload S3:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde un buffer directement sur S3 (pour les images générées)
|
||||
* @returns Le chemin S3 de l'image
|
||||
*/
|
||||
export async function saveBuffer(
|
||||
buffer: Buffer,
|
||||
filename: string,
|
||||
type: "generated" | "references" | "originals"
|
||||
): Promise<string> {
|
||||
try {
|
||||
const ext = filename.split(".").pop() || "png";
|
||||
const s3Key = `${type}/${filename}`;
|
||||
|
||||
await uploadToS3(s3Key, buffer, getMimeType(ext));
|
||||
|
||||
console.log(` 💾 Sauvegardé sur S3: ${s3Key}`);
|
||||
return `/${s3Key}`;
|
||||
} catch (error) {
|
||||
console.error("Erreur upload S3:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère une image depuis S3 sous forme de Buffer
|
||||
* @param s3Path Le chemin S3 (ex: /references/xxx.jpg)
|
||||
*/
|
||||
export async function getImageBuffer(s3Path: string): Promise<Buffer> {
|
||||
// Enlever le / initial si présent et /uploads si présent (compatibilité ancien format)
|
||||
let s3Key = s3Path.startsWith("/") ? s3Path.slice(1) : s3Path;
|
||||
if (s3Key.startsWith("uploads/")) {
|
||||
s3Key = s3Key.replace("uploads/", "");
|
||||
}
|
||||
const path = `/${s3Key}`;
|
||||
|
||||
const { headers } = signRequest("GET", path);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: S3_HOST,
|
||||
port: 443,
|
||||
path: path,
|
||||
method: "GET",
|
||||
headers: headers,
|
||||
},
|
||||
(res) => {
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
res.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
} else {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => {
|
||||
console.error(`Erreur lecture S3 ${s3Path}:`, res.statusCode, data);
|
||||
reject(new Error(`S3 GET failed: ${res.statusCode}`));
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
req.on("error", (e) => {
|
||||
console.error(`Erreur lecture S3 ${s3Path}:`, e);
|
||||
reject(e);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si une image existe sur S3 (HEAD request)
|
||||
* @param s3Path Le chemin S3 (ex: /references/xxx.jpg)
|
||||
* @returns true si l'image existe, false sinon
|
||||
*/
|
||||
export async function checkImageExists(s3Path: string): Promise<boolean> {
|
||||
let s3Key = s3Path.startsWith("/") ? s3Path.slice(1) : s3Path;
|
||||
if (s3Key.startsWith("uploads/")) {
|
||||
s3Key = s3Key.replace("uploads/", "");
|
||||
}
|
||||
const path = `/${s3Key}`;
|
||||
|
||||
const { headers } = signRequest("HEAD", path);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: S3_HOST,
|
||||
port: 443,
|
||||
path: path,
|
||||
method: "HEAD",
|
||||
headers: headers,
|
||||
},
|
||||
(res) => {
|
||||
resolve(res.statusCode === 200);
|
||||
}
|
||||
);
|
||||
|
||||
req.on("error", () => {
|
||||
resolve(false);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit l'URL publique S3 pour une image
|
||||
* @param s3Path Le chemin S3 (ex: /references/xxx.jpg)
|
||||
*/
|
||||
export function getPublicUrl(s3Path: string): string {
|
||||
const s3Key = s3Path.startsWith("/") ? s3Path.slice(1) : s3Path;
|
||||
// Format Hetzner virtual-hosted style: https://BUCKET.REGION.your-objectstorage.com/KEY
|
||||
return `https://${S3_HOST}/${s3Key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime une image de S3
|
||||
* @param s3Path Le chemin S3 (ex: /references/xxx.jpg ou /uploads/references/xxx.jpg)
|
||||
*/
|
||||
export async function deleteImage(s3Path: string): Promise<boolean> {
|
||||
// Enlever le / initial et /uploads si présent (compatibilité ancien format)
|
||||
let s3Key = s3Path.startsWith("/") ? s3Path.slice(1) : s3Path;
|
||||
if (s3Key.startsWith("uploads/")) {
|
||||
s3Key = s3Key.replace("uploads/", "");
|
||||
}
|
||||
|
||||
const path = `/${s3Key}`;
|
||||
const { headers } = signRequest("DELETE", path);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: S3_HOST,
|
||||
port: 443,
|
||||
path: path,
|
||||
method: "DELETE",
|
||||
headers: headers,
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => {
|
||||
if (
|
||||
res.statusCode &&
|
||||
((res.statusCode >= 200 && res.statusCode < 300) ||
|
||||
res.statusCode === 404)
|
||||
) {
|
||||
console.log(` 🗑️ Supprimé de S3: ${s3Key}`);
|
||||
resolve(true);
|
||||
} else {
|
||||
console.error(`Erreur suppression S3: ${res.statusCode}`, data);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.on("error", (e) => {
|
||||
console.error(`Erreur suppression S3 ${s3Path}:`, e);
|
||||
resolve(false);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si S3 est configuré
|
||||
*/
|
||||
export function isS3Configured(): boolean {
|
||||
return !!(S3_ACCESS_KEY && S3_SECRET_KEY && S3_BUCKET);
|
||||
}
|
||||
212
src/lib/stripe.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import Stripe from "stripe";
|
||||
|
||||
// Configuration Stripe
|
||||
const STRIPE_SECRET_KEY =
|
||||
import.meta.env.STRIPE_API_KEY_PRIVATE ||
|
||||
process.env.STRIPE_API_KEY_PRIVATE ||
|
||||
"";
|
||||
|
||||
export const stripe = new Stripe(STRIPE_SECRET_KEY, {
|
||||
apiVersion: "2025-12-15.clover",
|
||||
});
|
||||
|
||||
// Types pour les produits Stripe
|
||||
export type ProductType =
|
||||
| "subscription_25" // Abonnement 25 projets/mois
|
||||
| "subscription_50" // Abonnement 50 projets/mois
|
||||
| "credit_with_sub" // Crédit unitaire avec abonnement
|
||||
| "credit_no_sub"; // Crédit unitaire sans abonnement
|
||||
|
||||
export interface StripeProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: ProductType;
|
||||
metadata: Record<string, string>;
|
||||
prices: StripePrice[];
|
||||
}
|
||||
|
||||
export interface StripePrice {
|
||||
id: string;
|
||||
productId: string;
|
||||
unitAmount: number; // En centimes
|
||||
currency: string;
|
||||
recurring: {
|
||||
interval: "month" | "year";
|
||||
intervalCount: number;
|
||||
} | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
// Cache des produits (évite les appels API répétés)
|
||||
let productsCache: StripeProduct[] | null = null;
|
||||
let cacheTimestamp: number = 0;
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Récupère tous les produits actifs depuis Stripe
|
||||
* Utilise un cache pour éviter les appels API répétés
|
||||
*/
|
||||
export async function getStripeProducts(
|
||||
forceRefresh = false
|
||||
): Promise<StripeProduct[]> {
|
||||
const now = Date.now();
|
||||
|
||||
// Utiliser le cache si disponible et pas expiré
|
||||
if (!forceRefresh && productsCache && now - cacheTimestamp < CACHE_DURATION) {
|
||||
return productsCache;
|
||||
}
|
||||
|
||||
try {
|
||||
// Récupérer tous les produits actifs
|
||||
const products = await stripe.products.list({
|
||||
active: true,
|
||||
expand: ["data.default_price"],
|
||||
});
|
||||
|
||||
// Récupérer tous les prix actifs
|
||||
const prices = await stripe.prices.list({
|
||||
active: true,
|
||||
expand: ["data.product"],
|
||||
});
|
||||
|
||||
// Construire la liste des produits avec leurs prix
|
||||
const stripeProducts: StripeProduct[] = products.data.map((product) => {
|
||||
const productPrices = prices.data
|
||||
.filter((price) => {
|
||||
const priceProductId =
|
||||
typeof price.product === "string"
|
||||
? price.product
|
||||
: price.product.id;
|
||||
return priceProductId === product.id;
|
||||
})
|
||||
.map((price) => ({
|
||||
id: price.id,
|
||||
productId: product.id,
|
||||
unitAmount: price.unit_amount || 0,
|
||||
currency: price.currency,
|
||||
recurring: price.recurring
|
||||
? {
|
||||
interval: price.recurring.interval as "month" | "year",
|
||||
intervalCount: price.recurring.interval_count,
|
||||
}
|
||||
: null,
|
||||
active: price.active,
|
||||
}));
|
||||
|
||||
// Déterminer le type de produit basé sur les métadonnées ou le nom
|
||||
const type = detectProductType(product);
|
||||
|
||||
return {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
type,
|
||||
metadata: product.metadata,
|
||||
prices: productPrices,
|
||||
};
|
||||
});
|
||||
|
||||
// Mettre à jour le cache
|
||||
productsCache = stripeProducts;
|
||||
cacheTimestamp = now;
|
||||
|
||||
return stripeProducts;
|
||||
} catch (error) {
|
||||
console.error("Erreur récupération produits Stripe:", error);
|
||||
// Retourner le cache même expiré en cas d'erreur
|
||||
return productsCache || [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecte le type de produit basé sur les métadonnées ou le nom
|
||||
* IMPORTANT: Dans Stripe Dashboard, ajoutez une métadonnée "type" à vos produits:
|
||||
* - subscription_25, subscription_50, credit_with_sub, credit_no_sub
|
||||
*/
|
||||
function detectProductType(product: Stripe.Product): ProductType {
|
||||
// Priorité aux métadonnées
|
||||
if (product.metadata.type) {
|
||||
return product.metadata.type as ProductType;
|
||||
}
|
||||
|
||||
// Sinon, détecter par le nom du produit
|
||||
const name = product.name.toLowerCase();
|
||||
|
||||
if (name.includes("25") && name.includes("abonnement")) {
|
||||
return "subscription_25";
|
||||
}
|
||||
if (name.includes("50") && name.includes("abonnement")) {
|
||||
return "subscription_50";
|
||||
}
|
||||
if (name.includes("sans abonnement") || name.includes("no sub")) {
|
||||
return "credit_no_sub";
|
||||
}
|
||||
if (name.includes("avec abonnement") || name.includes("with sub")) {
|
||||
return "credit_with_sub";
|
||||
}
|
||||
|
||||
// Par défaut, crédit sans abonnement
|
||||
return "credit_no_sub";
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les produits d'abonnement
|
||||
*/
|
||||
export async function getSubscriptionProducts(): Promise<StripeProduct[]> {
|
||||
const products = await getStripeProducts();
|
||||
return products.filter(
|
||||
(p) => p.type === "subscription_25" || p.type === "subscription_50"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les produits de crédits unitaires
|
||||
*/
|
||||
export async function getCreditProducts(): Promise<StripeProduct[]> {
|
||||
const products = await getStripeProducts();
|
||||
return products.filter(
|
||||
(p) => p.type === "credit_with_sub" || p.type === "credit_no_sub"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère un produit par son ID
|
||||
*/
|
||||
export async function getProductById(
|
||||
productId: string
|
||||
): Promise<StripeProduct | undefined> {
|
||||
const products = await getStripeProducts();
|
||||
return products.find((p) => p.id === productId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère un prix par son ID
|
||||
*/
|
||||
export async function getPriceById(
|
||||
priceId: string
|
||||
): Promise<StripePrice | undefined> {
|
||||
const products = await getStripeProducts();
|
||||
for (const product of products) {
|
||||
const price = product.prices.find((p) => p.id === priceId);
|
||||
if (price) return price;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate un prix pour l'affichage
|
||||
*/
|
||||
export function formatPrice(amount: number, currency: string = "eur"): string {
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: currency.toUpperCase(),
|
||||
}).format(amount / 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si Stripe est configuré
|
||||
*/
|
||||
export function isStripeConfigured(): boolean {
|
||||
return !!STRIPE_SECRET_KEY;
|
||||
}
|
||||
363
src/lib/subscriptions.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import db from "./db";
|
||||
import { stripe, type ProductType } from "./stripe";
|
||||
import type { PlanType } from "./db";
|
||||
|
||||
// ==========================================
|
||||
// TABLE DES ABONNEMENTS STRIPE
|
||||
// ==========================================
|
||||
|
||||
// Créer la table des abonnements
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL UNIQUE,
|
||||
stripe_customer_id TEXT,
|
||||
stripe_subscription_id TEXT,
|
||||
plan_type TEXT DEFAULT 'free',
|
||||
credits_balance INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'active',
|
||||
current_period_start TEXT,
|
||||
current_period_end TEXT,
|
||||
cancel_at_period_end INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_customer ON subscriptions(stripe_customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_sub ON subscriptions(stripe_subscription_id);
|
||||
`);
|
||||
|
||||
// Table des achats de crédits
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS credit_purchases (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
stripe_payment_intent_id TEXT,
|
||||
stripe_checkout_session_id TEXT,
|
||||
credits_amount INTEGER NOT NULL,
|
||||
price_paid INTEGER NOT NULL,
|
||||
currency TEXT DEFAULT 'eur',
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_credit_purchases_user ON credit_purchases(user_id);
|
||||
`);
|
||||
|
||||
// ==========================================
|
||||
// TYPES
|
||||
// ==========================================
|
||||
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
user_id: string;
|
||||
stripe_customer_id: string | null;
|
||||
stripe_subscription_id: string | null;
|
||||
plan_type: PlanType;
|
||||
credits_balance: number; // Crédits achetés en plus
|
||||
status: "active" | "canceled" | "past_due" | "incomplete";
|
||||
current_period_start: string | null;
|
||||
current_period_end: string | null;
|
||||
cancel_at_period_end: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreditPurchase {
|
||||
id: string;
|
||||
user_id: string;
|
||||
stripe_payment_intent_id: string | null;
|
||||
stripe_checkout_session_id: string | null;
|
||||
credits_amount: number;
|
||||
price_paid: number;
|
||||
currency: string;
|
||||
status: "pending" | "completed" | "failed";
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// FONCTIONS SUBSCRIPTION
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Récupère l'abonnement d'un utilisateur
|
||||
*/
|
||||
export function getSubscription(userId: string): Subscription | null {
|
||||
return db
|
||||
.prepare("SELECT * FROM subscriptions WHERE user_id = ?")
|
||||
.get(userId) as Subscription | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère un abonnement par son ID Stripe
|
||||
*/
|
||||
export function getSubscriptionByStripeId(
|
||||
stripeSubscriptionId: string
|
||||
): Subscription | null {
|
||||
return db
|
||||
.prepare("SELECT * FROM subscriptions WHERE stripe_subscription_id = ?")
|
||||
.get(stripeSubscriptionId) as Subscription | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère un abonnement par le customer ID Stripe
|
||||
*/
|
||||
export function getSubscriptionByCustomerId(
|
||||
stripeCustomerId: string
|
||||
): Subscription | null {
|
||||
return db
|
||||
.prepare("SELECT * FROM subscriptions WHERE stripe_customer_id = ?")
|
||||
.get(stripeCustomerId) as Subscription | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée ou met à jour l'abonnement d'un utilisateur
|
||||
*/
|
||||
export function upsertSubscription(
|
||||
userId: string,
|
||||
data: Partial<Omit<Subscription, "id" | "user_id" | "created_at">>
|
||||
): Subscription {
|
||||
const existing = getSubscription(userId);
|
||||
|
||||
if (existing) {
|
||||
// Mise à jour
|
||||
const fields = Object.keys(data)
|
||||
.map((k) => `${k} = ?`)
|
||||
.join(", ");
|
||||
const values = Object.values(data);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE subscriptions
|
||||
SET ${fields}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = ?
|
||||
`
|
||||
).run(...values, userId);
|
||||
} else {
|
||||
// Création
|
||||
const id = crypto.randomUUID();
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO subscriptions (id, user_id, plan_type, credits_balance, status)
|
||||
VALUES (?, ?, 'free', 0, 'active')
|
||||
`
|
||||
).run(id, userId);
|
||||
|
||||
// Appliquer les données supplémentaires
|
||||
if (Object.keys(data).length > 0) {
|
||||
const fields = Object.keys(data)
|
||||
.map((k) => `${k} = ?`)
|
||||
.join(", ");
|
||||
const values = Object.values(data);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE subscriptions
|
||||
SET ${fields}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = ?
|
||||
`
|
||||
).run(...values, userId);
|
||||
}
|
||||
}
|
||||
|
||||
return getSubscription(userId)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Annule un abonnement (le passe en free)
|
||||
*/
|
||||
export function cancelSubscription(stripeSubscriptionId: string): void {
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE subscriptions
|
||||
SET plan_type = 'free',
|
||||
status = 'canceled',
|
||||
stripe_subscription_id = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE stripe_subscription_id = ?
|
||||
`
|
||||
).run(stripeSubscriptionId);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// FONCTIONS STRIPE CUSTOMER
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Vérifie si un customer Stripe existe et est valide
|
||||
* Retourne null si le customer n'existe plus
|
||||
*/
|
||||
export async function validateStripeCustomer(
|
||||
customerId: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await stripe.customers.retrieve(customerId);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'resource_missing') {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée ou récupère un customer Stripe pour un utilisateur
|
||||
* Si le customer existe en DB mais pas sur Stripe, il est recréé automatiquement
|
||||
*/
|
||||
export async function getOrCreateStripeCustomer(
|
||||
userId: string,
|
||||
email?: string,
|
||||
name?: string
|
||||
): Promise<string> {
|
||||
const sub = getSubscription(userId);
|
||||
|
||||
if (sub?.stripe_customer_id) {
|
||||
// Vérifier que le customer existe toujours sur Stripe
|
||||
const isValid = await validateStripeCustomer(sub.stripe_customer_id);
|
||||
if (isValid) {
|
||||
return sub.stripe_customer_id;
|
||||
}
|
||||
// Customer supprimé sur Stripe, on va le recréer
|
||||
console.log(`[Stripe] Customer ${sub.stripe_customer_id} introuvable, recréation...`);
|
||||
}
|
||||
|
||||
// Créer un nouveau customer Stripe
|
||||
const customer = await stripe.customers.create({
|
||||
email,
|
||||
name,
|
||||
metadata: { userId },
|
||||
});
|
||||
|
||||
// Sauvegarder dans la DB
|
||||
upsertSubscription(userId, {
|
||||
stripe_customer_id: customer.id,
|
||||
});
|
||||
|
||||
console.log(`[Stripe] Nouveau customer créé: ${customer.id} pour user ${userId}`);
|
||||
return customer.id;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// FONCTIONS CRÉDITS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Ajoute des crédits au solde d'un utilisateur
|
||||
*/
|
||||
export function addCredits(userId: string, amount: number): number {
|
||||
const sub = getSubscription(userId);
|
||||
const newBalance = (sub?.credits_balance || 0) + amount;
|
||||
|
||||
upsertSubscription(userId, {
|
||||
credits_balance: newBalance,
|
||||
});
|
||||
|
||||
return newBalance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilise un crédit du solde (pour les achats unitaires)
|
||||
* Retourne true si un crédit a été utilisé
|
||||
*/
|
||||
export function useCredit(userId: string): boolean {
|
||||
const sub = getSubscription(userId);
|
||||
|
||||
if (!sub || sub.credits_balance <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE subscriptions
|
||||
SET credits_balance = credits_balance - 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = ? AND credits_balance > 0
|
||||
`
|
||||
).run(userId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le solde de crédits d'un utilisateur
|
||||
*/
|
||||
export function getCreditsBalance(userId: string): number {
|
||||
const sub = getSubscription(userId);
|
||||
return sub?.credits_balance || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre un achat de crédits
|
||||
*/
|
||||
export function createCreditPurchase(
|
||||
userId: string,
|
||||
data: {
|
||||
stripe_checkout_session_id?: string;
|
||||
stripe_payment_intent_id?: string;
|
||||
credits_amount: number;
|
||||
price_paid: number;
|
||||
currency?: string;
|
||||
status?: "pending" | "completed" | "failed";
|
||||
}
|
||||
): CreditPurchase {
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO credit_purchases (
|
||||
id, user_id, stripe_checkout_session_id, stripe_payment_intent_id,
|
||||
credits_amount, price_paid, currency, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
).run(
|
||||
id,
|
||||
userId,
|
||||
data.stripe_checkout_session_id || null,
|
||||
data.stripe_payment_intent_id || null,
|
||||
data.credits_amount,
|
||||
data.price_paid,
|
||||
data.currency || "eur",
|
||||
data.status || "pending"
|
||||
);
|
||||
|
||||
return db
|
||||
.prepare("SELECT * FROM credit_purchases WHERE id = ?")
|
||||
.get(id) as CreditPurchase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour le statut d'un achat de crédits
|
||||
*/
|
||||
export function updateCreditPurchaseStatus(
|
||||
checkoutSessionId: string,
|
||||
status: "completed" | "failed"
|
||||
): void {
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE credit_purchases
|
||||
SET status = ?
|
||||
WHERE stripe_checkout_session_id = ?
|
||||
`
|
||||
).run(status, checkoutSessionId);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// FONCTIONS PLAN
|
||||
// ==========================================
|
||||
|
||||
// Mapping du type de produit Stripe vers notre PlanType
|
||||
const PRODUCT_TYPE_TO_PLAN: Record<ProductType, PlanType> = {
|
||||
subscription_25: "standard",
|
||||
subscription_50: "pro",
|
||||
credit_with_sub: "free", // Les crédits n'affectent pas le plan
|
||||
credit_no_sub: "free",
|
||||
};
|
||||
|
||||
/**
|
||||
* Détermine le PlanType depuis un type de produit Stripe
|
||||
*/
|
||||
export function getPlanFromProductType(productType: ProductType): PlanType {
|
||||
return PRODUCT_TYPE_TO_PLAN[productType] || "free";
|
||||
}
|
||||
21
src/middleware.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { clerkMiddleware, createRouteMatcher } from "@clerk/astro/server";
|
||||
|
||||
// Routes publiques qui ne nécessitent pas d'authentification
|
||||
const isPublicRoute = createRouteMatcher([
|
||||
"/",
|
||||
"/sign-in(.*)",
|
||||
"/sign-up(.*)",
|
||||
"/pricing",
|
||||
"/legal/(.*)",
|
||||
"/api/convert-image", // Conversion d'image pour aperçu
|
||||
"/api/images/generated/(.*)", // Images générées publiques pour le partage social
|
||||
]);
|
||||
|
||||
export const onRequest = clerkMiddleware((auth, context) => {
|
||||
// Les routes publiques ne nécessitent pas de protection
|
||||
if (isPublicRoute(context.request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pour les autres routes, l'authentification est gérée par les pages elles-mêmes
|
||||
});
|
||||
1209
src/pages/admin/index.astro
Normal file
201
src/pages/api/admin/delete-user.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { isAdminUser } from "../../../lib/plans";
|
||||
import db from "../../../lib/db";
|
||||
import { stripe } from "../../../lib/stripe";
|
||||
import { getSubscription } from "../../../lib/subscriptions";
|
||||
import { createClerkClient } from "@clerk/astro/server";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
try {
|
||||
// Vérifier l'authentification admin
|
||||
const auth = locals.auth();
|
||||
const adminId = auth.userId;
|
||||
|
||||
if (!adminId || !isAdminUser(adminId)) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: "Accès non autorisé" }),
|
||||
{ status: 403, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
const { userId, confirmDelete } = data;
|
||||
|
||||
if (!userId) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: "userId requis" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Vérifier la confirmation
|
||||
if (confirmDelete !== true) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: "Confirmation requise" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Empêcher la suppression d'un admin
|
||||
if (isAdminUser(userId)) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: "Impossible de supprimer un administrateur",
|
||||
}),
|
||||
{ status: 403, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[ADMIN] ${adminId} supprime l'utilisateur ${userId}`);
|
||||
|
||||
// Statistiques avant suppression
|
||||
const stats = {
|
||||
generations: 0,
|
||||
instructions: 0,
|
||||
references: 0,
|
||||
folders: 0,
|
||||
credits: false,
|
||||
subscription: false,
|
||||
purchases: 0,
|
||||
stripeSubscriptionCanceled: false,
|
||||
stripeCustomerDeleted: false,
|
||||
clerkUserDeleted: false,
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// 1. SUPPRESSION STRIPE (abonnement + client)
|
||||
// ==========================================
|
||||
const subscription = getSubscription(userId);
|
||||
|
||||
// Annuler l'abonnement Stripe si existant
|
||||
if (subscription?.stripe_subscription_id) {
|
||||
try {
|
||||
await stripe.subscriptions.cancel(subscription.stripe_subscription_id);
|
||||
stats.stripeSubscriptionCanceled = true;
|
||||
console.log(
|
||||
`[ADMIN] Abonnement Stripe ${subscription.stripe_subscription_id} annulé`
|
||||
);
|
||||
} catch (stripeError: any) {
|
||||
// Ignorer si l'abonnement n'existe plus
|
||||
if (stripeError.code !== "resource_missing") {
|
||||
console.warn(
|
||||
`[ADMIN] Erreur annulation abonnement Stripe:`,
|
||||
stripeError.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer le client Stripe si existant
|
||||
if (subscription?.stripe_customer_id) {
|
||||
try {
|
||||
await stripe.customers.del(subscription.stripe_customer_id);
|
||||
stats.stripeCustomerDeleted = true;
|
||||
console.log(
|
||||
`[ADMIN] Client Stripe ${subscription.stripe_customer_id} supprimé`
|
||||
);
|
||||
} catch (stripeError: any) {
|
||||
// Ignorer si le client n'existe plus
|
||||
if (stripeError.code !== "resource_missing") {
|
||||
console.warn(
|
||||
`[ADMIN] Erreur suppression client Stripe:`,
|
||||
stripeError.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. SUPPRESSION CLERK
|
||||
// ==========================================
|
||||
try {
|
||||
const clerkClient = createClerkClient({
|
||||
secretKey: import.meta.env.CLERK_SECRET_KEY,
|
||||
});
|
||||
await clerkClient.users.deleteUser(userId);
|
||||
stats.clerkUserDeleted = true;
|
||||
console.log(`[ADMIN] Utilisateur Clerk ${userId} supprimé`);
|
||||
} catch (clerkError: any) {
|
||||
// Ignorer si l'utilisateur n'existe plus dans Clerk
|
||||
if (clerkError.status !== 404) {
|
||||
console.warn(
|
||||
`[ADMIN] Erreur suppression utilisateur Clerk:`,
|
||||
clerkError.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 3. SUPPRESSION BASE DE DONNÉES (CASCADE)
|
||||
// ==========================================
|
||||
|
||||
// 3a. Supprimer les instructions des générations de cet utilisateur
|
||||
const generations = db
|
||||
.prepare("SELECT id FROM generations WHERE user_id = ?")
|
||||
.all(userId) as Array<{ id: string }>;
|
||||
|
||||
for (const gen of generations) {
|
||||
const result = db
|
||||
.prepare("DELETE FROM instructions WHERE generation_id = ?")
|
||||
.run(gen.id);
|
||||
stats.instructions += result.changes;
|
||||
}
|
||||
|
||||
// 3b. Supprimer les générations
|
||||
const genResult = db
|
||||
.prepare("DELETE FROM generations WHERE user_id = ?")
|
||||
.run(userId);
|
||||
stats.generations = genResult.changes;
|
||||
|
||||
// 3d. Supprimer les références (material_refs)
|
||||
const refResult = db
|
||||
.prepare("DELETE FROM material_refs WHERE user_id = ?")
|
||||
.run(userId);
|
||||
stats.references = refResult.changes;
|
||||
|
||||
// 3e. Supprimer les dossiers
|
||||
const folderResult = db
|
||||
.prepare("DELETE FROM folders WHERE user_id = ?")
|
||||
.run(userId);
|
||||
stats.folders = folderResult.changes;
|
||||
|
||||
// 3f. Supprimer les crédits utilisateur
|
||||
const creditsResult = db
|
||||
.prepare("DELETE FROM user_credits WHERE user_id = ?")
|
||||
.run(userId);
|
||||
stats.credits = creditsResult.changes > 0;
|
||||
|
||||
// 3g. Supprimer les achats de crédits
|
||||
const purchasesResult = db
|
||||
.prepare("DELETE FROM credit_purchases WHERE user_id = ?")
|
||||
.run(userId);
|
||||
stats.purchases = purchasesResult.changes;
|
||||
|
||||
// 3h. Supprimer l'abonnement (dans la DB)
|
||||
const subResult = db
|
||||
.prepare("DELETE FROM subscriptions WHERE user_id = ?")
|
||||
.run(userId);
|
||||
stats.subscription = subResult.changes > 0;
|
||||
|
||||
console.log(`[ADMIN] Utilisateur ${userId} supprimé:`, stats);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: "Utilisateur supprimé avec succès",
|
||||
stats,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error("[ADMIN] Erreur delete-user:", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: error.message || "Erreur serveur",
|
||||
}),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
};
|
||||
135
src/pages/api/admin/get-user.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { isAdminUser } from "../../../lib/plans";
|
||||
import { getSubscription } from "../../../lib/subscriptions";
|
||||
import db from "../../../lib/db";
|
||||
|
||||
export const GET: APIRoute = async ({ request, locals, url }) => {
|
||||
try {
|
||||
// Vérifier l'authentification admin
|
||||
const auth = locals.auth();
|
||||
const adminId = auth.userId;
|
||||
|
||||
if (!adminId || !isAdminUser(adminId)) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: "Accès non autorisé" }),
|
||||
{ status: 403, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = url.searchParams.get("userId");
|
||||
|
||||
if (!userId) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: "userId requis" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Récupérer les infos de l'utilisateur
|
||||
const subscription = getSubscription(userId);
|
||||
|
||||
// Récupérer les crédits utilisés
|
||||
const userCredits = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM user_credits WHERE user_id = ?
|
||||
`
|
||||
)
|
||||
.get(userId) as
|
||||
| {
|
||||
total_generations: number;
|
||||
monthly_generations: number;
|
||||
last_generation_month: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
// Récupérer les générations
|
||||
const generations = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
g.*,
|
||||
(SELECT COUNT(*) FROM instructions WHERE generation_id = g.id) as instruction_count
|
||||
FROM generations g
|
||||
WHERE g.user_id = ?
|
||||
ORDER BY g.created_at DESC
|
||||
LIMIT 50
|
||||
`
|
||||
)
|
||||
.all(userId) as Array<{
|
||||
id: string;
|
||||
user_id: string;
|
||||
original_image_path: string;
|
||||
generated_image_path: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
instruction_count: number;
|
||||
}>;
|
||||
|
||||
// Récupérer les références
|
||||
const references = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM material_refs
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50
|
||||
`
|
||||
)
|
||||
.all(userId) as Array<{
|
||||
id: string;
|
||||
name: string | null;
|
||||
image_path: string;
|
||||
folder_id: string | null;
|
||||
created_at: string;
|
||||
}>;
|
||||
|
||||
// Récupérer les achats de crédits
|
||||
const purchases = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM credit_purchases
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
)
|
||||
.all(userId) as Array<{
|
||||
id: string;
|
||||
credits_amount: number;
|
||||
price_paid: number;
|
||||
currency: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}>;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
userId,
|
||||
subscription: subscription || {
|
||||
plan_type: "free",
|
||||
credits_balance: 0,
|
||||
},
|
||||
credits: userCredits || {
|
||||
total_generations: 0,
|
||||
monthly_generations: 0,
|
||||
},
|
||||
generations,
|
||||
references,
|
||||
purchases,
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error("[ADMIN] Erreur get-user:", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: error.message || "Erreur serveur",
|
||||
}),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
};
|
||||
177
src/pages/api/admin/stats.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { isAdminUser } from "../../../lib/plans";
|
||||
import db from "../../../lib/db";
|
||||
|
||||
export const GET: APIRoute = async ({ locals }) => {
|
||||
try {
|
||||
// Vérifier l'authentification admin
|
||||
const auth = locals.auth();
|
||||
const adminId = auth.userId;
|
||||
|
||||
if (!adminId || !isAdminUser(adminId)) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: "Accès non autorisé" }),
|
||||
{ status: 403, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Stats globales
|
||||
const totalUsers = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT COUNT(DISTINCT user_id) as count FROM user_credits
|
||||
`
|
||||
)
|
||||
.get() as { count: number };
|
||||
|
||||
const totalGenerations = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT COUNT(*) as count FROM generations
|
||||
`
|
||||
)
|
||||
.get() as { count: number };
|
||||
|
||||
const completedGenerations = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT COUNT(*) as count FROM generations WHERE status = 'completed'
|
||||
`
|
||||
)
|
||||
.get() as { count: number };
|
||||
|
||||
const failedGenerations = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT COUNT(*) as count FROM generations WHERE status = 'failed'
|
||||
`
|
||||
)
|
||||
.get() as { count: number };
|
||||
|
||||
const totalReferences = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT COUNT(*) as count FROM material_refs
|
||||
`
|
||||
)
|
||||
.get() as { count: number };
|
||||
|
||||
// Générations par période
|
||||
const last24h = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT COUNT(*) as count FROM generations
|
||||
WHERE created_at >= datetime('now', '-24 hours')
|
||||
`
|
||||
)
|
||||
.get() as { count: number };
|
||||
|
||||
const last7days = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT COUNT(*) as count FROM generations
|
||||
WHERE created_at >= datetime('now', '-7 days')
|
||||
`
|
||||
)
|
||||
.get() as { count: number };
|
||||
|
||||
const last30days = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT COUNT(*) as count FROM generations
|
||||
WHERE created_at >= datetime('now', '-30 days')
|
||||
`
|
||||
)
|
||||
.get() as { count: number };
|
||||
|
||||
// Répartition par plan
|
||||
const planStats = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
plan_type,
|
||||
COUNT(*) as count,
|
||||
SUM(credits_balance) as total_bonus_credits
|
||||
FROM subscriptions
|
||||
GROUP BY plan_type
|
||||
`
|
||||
)
|
||||
.all() as Array<{
|
||||
plan_type: string;
|
||||
count: number;
|
||||
total_bonus_credits: number;
|
||||
}>;
|
||||
|
||||
// Revenus (achats de crédits)
|
||||
const totalRevenue = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
SUM(price_paid) as total,
|
||||
COUNT(*) as purchases_count,
|
||||
SUM(credits_amount) as credits_sold
|
||||
FROM credit_purchases
|
||||
WHERE status = 'completed'
|
||||
`
|
||||
)
|
||||
.get() as {
|
||||
total: number;
|
||||
purchases_count: number;
|
||||
credits_sold: number;
|
||||
};
|
||||
|
||||
// Générations par jour (7 derniers jours)
|
||||
const dailyGenerations = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
date(created_at) as day,
|
||||
COUNT(*) as count
|
||||
FROM generations
|
||||
WHERE created_at >= datetime('now', '-7 days')
|
||||
GROUP BY date(created_at)
|
||||
ORDER BY day DESC
|
||||
`
|
||||
)
|
||||
.all() as Array<{ day: string; count: number }>;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
stats: {
|
||||
users: {
|
||||
total: totalUsers.count,
|
||||
byPlan: planStats,
|
||||
},
|
||||
generations: {
|
||||
total: totalGenerations.count,
|
||||
completed: completedGenerations.count,
|
||||
failed: failedGenerations.count,
|
||||
last24h: last24h.count,
|
||||
last7days: last7days.count,
|
||||
last30days: last30days.count,
|
||||
daily: dailyGenerations,
|
||||
},
|
||||
references: {
|
||||
total: totalReferences.count,
|
||||
},
|
||||
revenue: {
|
||||
total: totalRevenue.total || 0,
|
||||
purchasesCount: totalRevenue.purchases_count || 0,
|
||||
creditsSold: totalRevenue.credits_sold || 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error("[ADMIN] Erreur stats:", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: error.message || "Erreur serveur",
|
||||
}),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
};
|
||||
113
src/pages/api/admin/update-user.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { isAdminUser } from "../../../lib/plans";
|
||||
import {
|
||||
upsertSubscription,
|
||||
getSubscription,
|
||||
} from "../../../lib/subscriptions";
|
||||
import { getUserCredits, updateUserCredits } from "../../../lib/db";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
try {
|
||||
// Vérifier l'authentification admin
|
||||
const auth = locals.auth();
|
||||
const adminId = auth.userId;
|
||||
|
||||
if (!adminId || !isAdminUser(adminId)) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: "Accès non autorisé" }),
|
||||
{ status: 403, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
const {
|
||||
userId,
|
||||
planType,
|
||||
creditsBalance, // Crédits bonus (supplémentaires)
|
||||
monthlyGenerations, // Générations ce mois (pour abonnés)
|
||||
totalGenerations, // Générations totales (compteur global / gratuits)
|
||||
} = data;
|
||||
|
||||
if (!userId) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: "userId requis" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Valider le plan
|
||||
const validPlans = ["free", "standard", "pro"];
|
||||
if (planType && !validPlans.includes(planType)) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: false, error: "Plan invalide" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 1. Mettre à jour l'abonnement (plan + crédits bonus)
|
||||
// ==========================================
|
||||
const subscriptionUpdate: Record<string, any> = {};
|
||||
|
||||
if (planType) {
|
||||
subscriptionUpdate.plan_type = planType;
|
||||
}
|
||||
|
||||
if (typeof creditsBalance === "number" && creditsBalance >= 0) {
|
||||
subscriptionUpdate.credits_balance = creditsBalance;
|
||||
}
|
||||
|
||||
const subscription = upsertSubscription(userId, subscriptionUpdate);
|
||||
|
||||
// ==========================================
|
||||
// 2. Mettre à jour les compteurs de crédits (user_credits)
|
||||
// ==========================================
|
||||
const creditsUpdate: {
|
||||
monthly_generations?: number;
|
||||
total_generations?: number;
|
||||
} = {};
|
||||
|
||||
if (typeof monthlyGenerations === "number" && monthlyGenerations >= 0) {
|
||||
creditsUpdate.monthly_generations = monthlyGenerations;
|
||||
}
|
||||
|
||||
if (typeof totalGenerations === "number" && totalGenerations >= 0) {
|
||||
creditsUpdate.total_generations = totalGenerations;
|
||||
}
|
||||
|
||||
let userCredits = getUserCredits(userId);
|
||||
if (Object.keys(creditsUpdate).length > 0) {
|
||||
userCredits = updateUserCredits(userId, creditsUpdate);
|
||||
}
|
||||
|
||||
console.log(`[ADMIN] ${adminId} a modifié ${userId}:`, {
|
||||
plan: planType,
|
||||
bonusCredits: creditsBalance,
|
||||
monthlyGen: monthlyGenerations,
|
||||
totalGen: totalGenerations,
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
user: {
|
||||
userId: subscription.user_id,
|
||||
planType: subscription.plan_type,
|
||||
creditsBalance: subscription.credits_balance,
|
||||
monthlyGenerations: userCredits.monthly_generations,
|
||||
totalGenerations: userCredits.total_generations,
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error("[ADMIN] Erreur update-user:", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: error.message || "Erreur serveur",
|
||||
}),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
};
|
||||
106
src/pages/api/apply-mask.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import sharp from "sharp";
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const imageFile = formData.get("image") as File;
|
||||
const maskFile = formData.get("mask") as File;
|
||||
|
||||
if (!imageFile || !maskFile) {
|
||||
return new Response(JSON.stringify({ error: "Image et masque requis" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Convertir les fichiers en buffers
|
||||
const imageBuffer = Buffer.from(await imageFile.arrayBuffer());
|
||||
const maskBuffer = Buffer.from(await maskFile.arrayBuffer());
|
||||
|
||||
// Charger l'image et le masque avec sharp
|
||||
const image = sharp(imageBuffer);
|
||||
const mask = sharp(maskBuffer);
|
||||
|
||||
// Obtenir les métadonnées de l'image
|
||||
const imageMetadata = await image.metadata();
|
||||
const { width, height } = imageMetadata;
|
||||
|
||||
if (!width || !height) {
|
||||
return new Response(JSON.stringify({ error: "Impossible de lire les dimensions de l'image" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Vérifier que le masque a les mêmes dimensions que l'image
|
||||
const maskMetadata = await mask.metadata();
|
||||
if (maskMetadata.width !== width || maskMetadata.height !== height) {
|
||||
console.warn(`Masque (${maskMetadata.width}x${maskMetadata.height}) vs Image (${width}x${height}) - dimensions différentes`);
|
||||
}
|
||||
|
||||
// Convertir le masque en niveaux de gris (1 canal) pour l'utiliser comme alpha
|
||||
const maskAlpha = await mask
|
||||
.greyscale()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
// Convertir l'image en RGBA
|
||||
const imageRGBA = await sharp(imageBuffer)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
// Créer un nouveau buffer avec le masque appliqué comme alpha
|
||||
const resultBuffer = Buffer.alloc(width * height * 4);
|
||||
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const imgIdx = i * 4;
|
||||
const maskIdx = i;
|
||||
|
||||
// Récupérer la valeur du masque (0-255)
|
||||
const maskValue = maskAlpha[maskIdx];
|
||||
|
||||
// Si le masque est blanc (255), on garde l'image, sinon noir
|
||||
if (maskValue > 127) {
|
||||
resultBuffer[imgIdx] = imageRGBA[imgIdx]; // R
|
||||
resultBuffer[imgIdx + 1] = imageRGBA[imgIdx + 1]; // G
|
||||
resultBuffer[imgIdx + 2] = imageRGBA[imgIdx + 2]; // B
|
||||
resultBuffer[imgIdx + 3] = 255; // A
|
||||
} else {
|
||||
resultBuffer[imgIdx] = 0; // R
|
||||
resultBuffer[imgIdx + 1] = 0; // G
|
||||
resultBuffer[imgIdx + 2] = 0; // B
|
||||
resultBuffer[imgIdx + 3] = 255; // A (opaque noir)
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir le buffer raw en PNG
|
||||
const finalImage = await sharp(resultBuffer, {
|
||||
raw: {
|
||||
width,
|
||||
height,
|
||||
channels: 4,
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
return new Response(new Uint8Array(finalImage), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Content-Disposition": "attachment; filename=masked-preview.png",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de l'application du masque:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Erreur lors du traitement de l'image" }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
83
src/pages/api/convert-image.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import heicConvert from "heic-convert";
|
||||
|
||||
// Headers communs pour toutes les réponses
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
};
|
||||
|
||||
/**
|
||||
* Handler OPTIONS pour les requêtes preflight CORS
|
||||
*/
|
||||
export const OPTIONS: APIRoute = async () => {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: corsHeaders,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Endpoint pour convertir les images HEIC/HEIF en JPEG
|
||||
* Utilisé pour l'aperçu dans le navigateur (qui ne supporte pas ces formats)
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("image") as File | null;
|
||||
|
||||
if (!file) {
|
||||
return new Response(JSON.stringify({ error: "Aucun fichier fourni" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json", ...corsHeaders },
|
||||
});
|
||||
}
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const ext = file.name.toLowerCase().split(".").pop() || "";
|
||||
|
||||
// Formats Apple nécessitant conversion
|
||||
const appleFormats = ["heic", "heif", "hif"];
|
||||
|
||||
if (appleFormats.includes(ext)) {
|
||||
console.log(`🔄 Conversion ${ext.toUpperCase()} → JPEG pour aperçu...`);
|
||||
|
||||
const convertedBuffer = await heicConvert({
|
||||
buffer: Buffer.from(arrayBuffer),
|
||||
format: "JPEG",
|
||||
quality: 0.85,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`✓ Converti: ${(convertedBuffer.byteLength / 1024).toFixed(0)} KB`
|
||||
);
|
||||
|
||||
return new Response(new Uint8Array(convertedBuffer), {
|
||||
headers: {
|
||||
"Content-Type": "image/jpeg",
|
||||
"Cache-Control": "max-age=3600",
|
||||
...corsHeaders,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Pour les autres formats, retourner tel quel
|
||||
return new Response(new Uint8Array(arrayBuffer), {
|
||||
headers: {
|
||||
"Content-Type": file.type || "image/jpeg",
|
||||
"Cache-Control": "max-age=3600",
|
||||
...corsHeaders,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erreur conversion image:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Erreur lors de la conversion de l'image" }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json", ...corsHeaders },
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
459
src/pages/api/generate-stream.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import {
|
||||
createReference,
|
||||
createGeneration,
|
||||
createInstruction,
|
||||
updateGeneration,
|
||||
getReference,
|
||||
canUserGenerate,
|
||||
consumeCredit,
|
||||
} from "../../lib/db";
|
||||
import { saveImage, checkImageExists } from "../../lib/storage";
|
||||
import {
|
||||
generateBeforeAfterWithProgress,
|
||||
type GenerationInstruction,
|
||||
type ModificationType,
|
||||
type ProgressEvent,
|
||||
} from "../../lib/gemini/index";
|
||||
import { getUserPlan, isAdminUser } from "../../lib/plans";
|
||||
import { getCreditsBalance, useCredit } from "../../lib/subscriptions";
|
||||
|
||||
// Nombre max de tentatives pour la génération (retry automatique)
|
||||
const MAX_GENERATION_RETRIES = 2;
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Créer un TransformStream pour le SSE
|
||||
const { readable, writable } = new TransformStream();
|
||||
const writer = writable.getWriter();
|
||||
const encoder = new TextEncoder();
|
||||
let isClosed = false;
|
||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Fonction helper pour envoyer un événement SSE (silencieusement si déconnecté)
|
||||
// IMPORTANT: Le client peut se déconnecter, mais la génération CONTINUE
|
||||
const sendEvent = async (event: string, data: any) => {
|
||||
if (isClosed) return; // Client parti, on continue en silence
|
||||
try {
|
||||
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
await writer.write(encoder.encode(message));
|
||||
} catch (e: any) {
|
||||
// Client déconnecté - on marque comme fermé mais ON CONTINUE le traitement
|
||||
isClosed = true;
|
||||
// Pas de log pour éviter le spam
|
||||
}
|
||||
};
|
||||
|
||||
// Envoyer un heartbeat pour maintenir la connexion active
|
||||
const startHeartbeat = () => {
|
||||
heartbeatInterval = setInterval(async () => {
|
||||
if (isClosed) {
|
||||
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await writer.write(encoder.encode(": heartbeat\n\n"));
|
||||
} catch (e) {
|
||||
isClosed = true;
|
||||
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
||||
}
|
||||
}, 8000); // 8 secondes pour mobile
|
||||
};
|
||||
|
||||
// Fonction pour fermer le writer en toute sécurité
|
||||
const safeClose = async () => {
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
}
|
||||
if (isClosed) return;
|
||||
isClosed = true;
|
||||
try {
|
||||
await writer.close();
|
||||
} catch (e) {
|
||||
// Déjà fermé ou erreur, ignorer
|
||||
}
|
||||
};
|
||||
|
||||
// Lancer le traitement en arrière-plan
|
||||
(async () => {
|
||||
try {
|
||||
const auth = locals.auth();
|
||||
const userId = auth.userId;
|
||||
|
||||
if (!userId) {
|
||||
await sendEvent("error", { message: "Non authentifié" });
|
||||
await safeClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Démarrer le heartbeat pour maintenir la connexion
|
||||
startHeartbeat();
|
||||
|
||||
await sendEvent("log", {
|
||||
icon: "📥",
|
||||
message: "Nouvelle requête de génération",
|
||||
type: "header",
|
||||
});
|
||||
await sendEvent("log", {
|
||||
icon: "👤",
|
||||
message: `Utilisateur: ${userId.substring(0, 10)}...`,
|
||||
});
|
||||
|
||||
// Vérifier le plan de l'utilisateur
|
||||
const isAdmin = isAdminUser(userId);
|
||||
|
||||
// Récupérer le plan Stripe et les crédits bonus
|
||||
const userPlanInfo = getUserPlan(userId);
|
||||
const creditsBalance = getCreditsBalance(userId);
|
||||
|
||||
// Passer les crédits bonus à canUserGenerate pour le compteur total
|
||||
const creditCheck = canUserGenerate(
|
||||
userId,
|
||||
userPlanInfo.planType,
|
||||
isAdmin,
|
||||
creditsBalance
|
||||
);
|
||||
|
||||
// Afficher les crédits bonus s'il y en a
|
||||
const creditsInfo =
|
||||
creditsBalance > 0 ? ` + ${creditsBalance} bonus` : "";
|
||||
|
||||
await sendEvent("log", {
|
||||
icon: "📊",
|
||||
message: `Plan: ${userPlanInfo.planName} | Crédits: ${
|
||||
creditCheck.used
|
||||
}/${isAdmin ? "∞" : creditCheck.totalAvailable}${creditsInfo}${
|
||||
isAdmin ? " (Admin)" : ""
|
||||
}`,
|
||||
});
|
||||
|
||||
// Vérifier si l'utilisateur peut générer (avec les crédits bonus inclus)
|
||||
if (!isAdmin && !creditCheck.canGenerate) {
|
||||
await sendEvent("error", {
|
||||
message: creditCheck.reason,
|
||||
noCredits: true,
|
||||
used: creditCheck.used,
|
||||
limit: creditCheck.limit,
|
||||
});
|
||||
await safeClose();
|
||||
return;
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
let image: File | null = null;
|
||||
let instructionsJson: string | null = null;
|
||||
|
||||
try {
|
||||
formData = await request.formData();
|
||||
image = formData.get("image") as File;
|
||||
instructionsJson = formData.get("instructions") as string;
|
||||
} catch (parseError: any) {
|
||||
console.error("[SSE] Erreur parsing FormData:", parseError);
|
||||
await sendEvent("error", {
|
||||
message: "Erreur lors de la réception des données. Veuillez réessayer."
|
||||
});
|
||||
await safeClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!image || !instructionsJson) {
|
||||
await sendEvent("error", { message: "Image et instructions requises" });
|
||||
await safeClose();
|
||||
return;
|
||||
}
|
||||
|
||||
await sendEvent("step", { step: "upload", status: "active" });
|
||||
await sendEvent("log", {
|
||||
icon: "📷",
|
||||
message: `Image: ${image.name} (${(image.size / 1024).toFixed(0)} KB)`,
|
||||
});
|
||||
|
||||
// Sauvegarder l'image originale
|
||||
const originalImagePath = await saveImage(image, userId, "originals");
|
||||
await sendEvent("log", {
|
||||
icon: "💾",
|
||||
message: `Sauvegardée sur S3`,
|
||||
});
|
||||
await sendEvent("step", { step: "upload", status: "done" });
|
||||
|
||||
// Créer la génération
|
||||
const generation = createGeneration(userId, originalImagePath);
|
||||
updateGeneration(generation.id, { status: "processing" });
|
||||
|
||||
// Envoyer l'ID immédiatement pour permettre le polling si déconnexion
|
||||
await sendEvent("generationId", { id: generation.id });
|
||||
await sendEvent("log", {
|
||||
icon: "🆔",
|
||||
message: `ID: ${generation.id.substring(0, 8)}...`,
|
||||
});
|
||||
|
||||
// Parser les instructions
|
||||
const parsedInstructions = JSON.parse(instructionsJson) as Array<{
|
||||
location: string;
|
||||
referenceId?: string;
|
||||
referenceName?: string;
|
||||
modificationType?: ModificationType;
|
||||
isNew?: boolean;
|
||||
hasMask?: boolean;
|
||||
}>;
|
||||
|
||||
await sendEvent("log", {
|
||||
icon: "📋",
|
||||
message: `${parsedInstructions.length} instruction(s) reçue(s)`,
|
||||
});
|
||||
|
||||
const geminiInstructions: GenerationInstruction[] = [];
|
||||
|
||||
// Traiter chaque instruction
|
||||
for (let i = 0; i < parsedInstructions.length; i++) {
|
||||
const instr = parsedInstructions[i];
|
||||
let referenceId = instr.referenceId;
|
||||
let referencePath = "";
|
||||
let referenceName = "";
|
||||
let maskImagePath: string | undefined;
|
||||
|
||||
// Gérer le masque si présent
|
||||
if (instr.hasMask) {
|
||||
const maskFile = formData.get(`mask_${i}`) as File;
|
||||
if (maskFile) {
|
||||
await sendEvent("log", {
|
||||
icon: "🖌️",
|
||||
message: `Masque détecté pour instruction ${i + 1}`,
|
||||
});
|
||||
maskImagePath = await saveImage(maskFile, userId, "references");
|
||||
await sendEvent("log", {
|
||||
icon: "💾",
|
||||
message: `Masque sauvegardé`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (instr.isNew) {
|
||||
const newRefFile = formData.get(`newRef_${i}`) as File;
|
||||
if (newRefFile) {
|
||||
referenceName =
|
||||
(instr.referenceName && instr.referenceName.trim()) ||
|
||||
newRefFile.name.replace(/\.[^.]+$/, "") ||
|
||||
`Référence ${i + 1}`;
|
||||
await sendEvent("log", {
|
||||
icon: "📤",
|
||||
message: `Nouvelle réf: ${referenceName}`,
|
||||
});
|
||||
const newRefPath = await saveImage(
|
||||
newRefFile,
|
||||
userId,
|
||||
"references"
|
||||
);
|
||||
const newRef = createReference(userId, newRefPath, referenceName);
|
||||
referenceId = newRef.id;
|
||||
referencePath = newRefPath;
|
||||
}
|
||||
} else if (referenceId) {
|
||||
const ref = getReference(referenceId);
|
||||
if (ref) {
|
||||
// Vérifier que l'image existe sur S3
|
||||
const imageExists = await checkImageExists(ref.image_path);
|
||||
if (!imageExists) {
|
||||
await sendEvent("log", {
|
||||
icon: "⚠️",
|
||||
message: `Réf "${
|
||||
ref.name || referenceId
|
||||
}" introuvable sur S3, ignorée`,
|
||||
type: "warning",
|
||||
});
|
||||
continue; // Passer à l'instruction suivante
|
||||
}
|
||||
referencePath = ref.image_path;
|
||||
referenceName =
|
||||
(instr.referenceName && instr.referenceName.trim()) ||
|
||||
(ref.name && ref.name.trim()) ||
|
||||
`Référence ${i + 1}`;
|
||||
await sendEvent("log", {
|
||||
icon: "📎",
|
||||
message: `Réf existante: ${referenceName}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (referenceId && referencePath) {
|
||||
createInstruction(
|
||||
generation.id,
|
||||
instr.location,
|
||||
referenceId,
|
||||
maskImagePath
|
||||
);
|
||||
|
||||
const instruction: GenerationInstruction = {
|
||||
location: instr.location,
|
||||
referenceImagePath: referencePath,
|
||||
referenceName: referenceName,
|
||||
maskImagePath: maskImagePath,
|
||||
};
|
||||
|
||||
if (instr.modificationType) {
|
||||
instruction.modificationType = instr.modificationType;
|
||||
}
|
||||
|
||||
geminiInstructions.push(instruction);
|
||||
await sendEvent("log", {
|
||||
icon: "✓",
|
||||
message: `[${i + 1}] "${instr.location}" → ${referenceName}${
|
||||
maskImagePath ? " (avec masque)" : ""
|
||||
}`,
|
||||
type: "success",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (geminiInstructions.length === 0) {
|
||||
updateGeneration(generation.id, { status: "failed" });
|
||||
await sendEvent("error", { message: "Aucune instruction valide" });
|
||||
await safeClose();
|
||||
return;
|
||||
}
|
||||
|
||||
await sendEvent("log", {
|
||||
icon: "🚀",
|
||||
message: `Lancement avec ${geminiInstructions.length} instruction(s)...`,
|
||||
type: "header",
|
||||
});
|
||||
|
||||
// Callback de progression pour Gemini (silencieux si déconnecté)
|
||||
const onProgress = (event: ProgressEvent) => {
|
||||
if (event.type === "step") {
|
||||
sendEvent("step", {
|
||||
step: event.step,
|
||||
status: event.status === "loading" ? "active" : event.status,
|
||||
});
|
||||
} else if (event.type === "log") {
|
||||
sendEvent("log", { icon: event.icon, message: event.message });
|
||||
}
|
||||
// On n'envoie plus les erreurs ici, on les gère dans le retry
|
||||
};
|
||||
|
||||
// GÉNÉRATION AVEC RETRY AUTOMATIQUE
|
||||
let result: any = null;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_GENERATION_RETRIES; attempt++) {
|
||||
try {
|
||||
if (attempt > 1) {
|
||||
await sendEvent("log", { icon: "🔄", message: `Tentative ${attempt}/${MAX_GENERATION_RETRIES}...` });
|
||||
// Attendre 2 secondes avant de réessayer
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
result = await generateBeforeAfterWithProgress(
|
||||
originalImagePath,
|
||||
geminiInstructions,
|
||||
"generated",
|
||||
generation.id,
|
||||
onProgress,
|
||||
{}
|
||||
);
|
||||
|
||||
// Succès! On sort de la boucle
|
||||
break;
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
console.error(`[Generate] Tentative ${attempt}/${MAX_GENERATION_RETRIES} échouée:`, error.message);
|
||||
|
||||
// Si c'est la dernière tentative, on ne réessaye pas
|
||||
if (attempt === MAX_GENERATION_RETRIES) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier le résultat
|
||||
if (result) {
|
||||
// SUCCÈS
|
||||
updateGeneration(generation.id, {
|
||||
status: "completed",
|
||||
generated_image_path: result.imagePath,
|
||||
});
|
||||
|
||||
// Consommer un crédit (mensuel d'abord, puis bonus si nécessaire)
|
||||
if (!isAdmin) {
|
||||
const creditResult = consumeCredit(
|
||||
userId,
|
||||
userPlanInfo.planType,
|
||||
creditsBalance,
|
||||
() => useCredit(userId)
|
||||
);
|
||||
|
||||
if (creditResult.usedBonus) {
|
||||
await sendEvent("log", {
|
||||
icon: "💎",
|
||||
message: `Crédit bonus utilisé`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
await sendEvent("log", {
|
||||
icon: "✅",
|
||||
message: `Terminé en ${duration}s`,
|
||||
type: "success",
|
||||
});
|
||||
|
||||
// Log des masques combinés si présents
|
||||
if (result.combinedMaskPaths && result.combinedMaskPaths.length > 0) {
|
||||
await sendEvent("log", {
|
||||
icon: "🎭",
|
||||
message: `${result.combinedMaskPaths.length} masque(s) combiné(s) disponible(s)`,
|
||||
});
|
||||
}
|
||||
|
||||
await sendEvent("complete", {
|
||||
success: true,
|
||||
generationId: generation.id,
|
||||
originalImage: originalImagePath,
|
||||
generatedImage: result.imagePath,
|
||||
description: result.description,
|
||||
attempts: result.attempts,
|
||||
duration: parseFloat(duration),
|
||||
combinedMaskPaths: result.combinedMaskPaths,
|
||||
});
|
||||
} else {
|
||||
// ÉCHEC après toutes les tentatives
|
||||
updateGeneration(generation.id, { status: "failed" });
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
const errorMsg = lastError?.message || "Échec de la génération";
|
||||
await sendEvent("log", {
|
||||
icon: "❌",
|
||||
message: `Échec après ${duration}s et ${MAX_GENERATION_RETRIES} tentatives: ${errorMsg}`,
|
||||
type: "error",
|
||||
});
|
||||
await sendEvent("error", { message: errorMsg });
|
||||
}
|
||||
|
||||
await safeClose();
|
||||
} catch (error: any) {
|
||||
// Ne pas logger les déconnexions client
|
||||
if (error?.code !== 'ECONNRESET' && error?.message !== 'aborted') {
|
||||
console.error("[Generate] Erreur:", error);
|
||||
}
|
||||
await sendEvent("error", { message: error?.message || "Erreur serveur" });
|
||||
await safeClose();
|
||||
}
|
||||
})().catch((err: any) => {
|
||||
// Capture toute erreur non gérée (ex: client déconnecté pendant l'écriture)
|
||||
// Ne pas logger les erreurs ECONNRESET/aborted (spam sur mobile)
|
||||
if (err?.code !== 'ECONNRESET' && err?.message !== 'aborted') {
|
||||
console.error("[SSE] Erreur fatale non gérée:", err);
|
||||
}
|
||||
safeClose().catch(() => {});
|
||||
});
|
||||
|
||||
return new Response(readable, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no", // Indique à Nginx de ne pas bufferiser
|
||||
},
|
||||
});
|
||||
};
|
||||
269
src/pages/api/generate.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import {
|
||||
createReference,
|
||||
createGeneration,
|
||||
createInstruction,
|
||||
updateGeneration,
|
||||
getReference,
|
||||
canUserGenerate,
|
||||
consumeCredit,
|
||||
type PlanType,
|
||||
} from "../../lib/db";
|
||||
import { saveImage } from "../../lib/storage";
|
||||
import {
|
||||
generateBeforeAfter,
|
||||
type GenerationInstruction,
|
||||
type ModificationType,
|
||||
} from "../../lib/gemini/index";
|
||||
import { getUserPlan, isAdminUser } from "../../lib/plans";
|
||||
import { getCreditsBalance, useCredit } from "../../lib/subscriptions";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const auth = locals.auth();
|
||||
const userId = auth.userId;
|
||||
|
||||
if (!userId) {
|
||||
return new Response(JSON.stringify({ error: "Non authentifié" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
console.log("\n" + "═".repeat(50));
|
||||
console.log("📥 NOUVELLE REQUÊTE DE GÉNÉRATION");
|
||||
console.log("═".repeat(50));
|
||||
console.log(`👤 User: ${userId}`);
|
||||
|
||||
// Vérifier le plan de l'utilisateur et ses crédits
|
||||
const userPlanInfo = getUserPlan(userId);
|
||||
|
||||
// Les admins ont des générations illimitées
|
||||
const isAdmin = isAdminUser(userId);
|
||||
|
||||
// Récupérer les crédits bonus
|
||||
const bonusCredits = getCreditsBalance(userId);
|
||||
|
||||
// Passer les crédits bonus à canUserGenerate pour le compteur total
|
||||
const creditCheck = canUserGenerate(
|
||||
userId,
|
||||
userPlanInfo.planType,
|
||||
isAdmin,
|
||||
bonusCredits
|
||||
);
|
||||
|
||||
console.log(
|
||||
`📊 Plan: ${userPlanInfo.planName} | Admin: ${isAdmin} | Crédits: ${
|
||||
creditCheck.used
|
||||
}/${isAdmin ? "∞" : creditCheck.totalAvailable}${
|
||||
bonusCredits > 0 ? ` (+${bonusCredits} bonus)` : ""
|
||||
}`
|
||||
);
|
||||
|
||||
if (!isAdmin && !creditCheck.canGenerate) {
|
||||
console.log(`⛔ Limite atteinte: ${creditCheck.reason}`);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: creditCheck.reason,
|
||||
noCredits: true,
|
||||
used: creditCheck.used,
|
||||
limit: creditCheck.limit,
|
||||
}),
|
||||
{
|
||||
status: 403,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const image = formData.get("image") as File;
|
||||
const instructionsJson = formData.get("instructions") as string;
|
||||
|
||||
if (!image || !instructionsJson) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Image et instructions requises" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`📷 Image originale: ${image.name} (${(image.size / 1024).toFixed(0)} KB)`
|
||||
);
|
||||
|
||||
// Sauvegarder l'image originale
|
||||
const originalImagePath = await saveImage(image, userId, "originals");
|
||||
console.log(`💾 Sauvegardée: ${originalImagePath}`);
|
||||
|
||||
// Créer la génération
|
||||
const generation = createGeneration(userId, originalImagePath);
|
||||
updateGeneration(generation.id, { status: "processing" });
|
||||
console.log(`🆔 Generation ID: ${generation.id}`);
|
||||
|
||||
// Parser les instructions
|
||||
const parsedInstructions = JSON.parse(instructionsJson) as Array<{
|
||||
location: string;
|
||||
referenceId?: string;
|
||||
referenceName?: string;
|
||||
modificationType?: ModificationType;
|
||||
isNew?: boolean;
|
||||
}>;
|
||||
|
||||
console.log(`\n📋 Instructions reçues: ${parsedInstructions.length}`);
|
||||
|
||||
const geminiInstructions: GenerationInstruction[] = [];
|
||||
|
||||
// Traiter chaque instruction
|
||||
for (let i = 0; i < parsedInstructions.length; i++) {
|
||||
const instr = parsedInstructions[i];
|
||||
let referenceId = instr.referenceId;
|
||||
let referencePath = "";
|
||||
// Utiliser le nom fourni, sinon chercher dans la DB, sinon générer un nom
|
||||
let referenceName = "";
|
||||
|
||||
if (instr.isNew) {
|
||||
// Nouvelle référence uploadée
|
||||
const newRefFile = formData.get(`newRef_${i}`) as File;
|
||||
if (newRefFile) {
|
||||
// Utiliser le nom donné par l'utilisateur, ou le nom du fichier sans extension
|
||||
referenceName =
|
||||
(instr.referenceName && instr.referenceName.trim()) ||
|
||||
newRefFile.name.replace(/\.[^.]+$/, "") ||
|
||||
`Référence ${i + 1}`;
|
||||
console.log(` 📤 Nouvelle référence ${i + 1}: ${referenceName}`);
|
||||
const newRefPath = await saveImage(newRefFile, userId, "references");
|
||||
const newRef = createReference(userId, newRefPath, referenceName);
|
||||
referenceId = newRef.id;
|
||||
referencePath = newRefPath;
|
||||
}
|
||||
} else if (referenceId) {
|
||||
// Référence existante
|
||||
const ref = getReference(referenceId);
|
||||
if (ref) {
|
||||
referencePath = ref.image_path;
|
||||
// Priorité: nom de l'instruction > nom en DB > nom généré
|
||||
referenceName =
|
||||
(instr.referenceName && instr.referenceName.trim()) ||
|
||||
(ref.name && ref.name.trim()) ||
|
||||
`Référence ${i + 1}`;
|
||||
console.log(` 📎 Référence existante ${i + 1}: ${referenceName}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (referenceId && referencePath) {
|
||||
createInstruction(generation.id, instr.location, referenceId);
|
||||
|
||||
const instruction: GenerationInstruction = {
|
||||
location: instr.location,
|
||||
referenceImagePath: referencePath, // Chemin S3 directement
|
||||
referenceName: referenceName,
|
||||
};
|
||||
|
||||
// Ajouter le type de modification si spécifié
|
||||
if (instr.modificationType) {
|
||||
instruction.modificationType = instr.modificationType;
|
||||
}
|
||||
|
||||
geminiInstructions.push(instruction);
|
||||
console.log(` ✓ [${i + 1}] "${instr.location}" → ${referenceName}`);
|
||||
} else {
|
||||
console.warn(` ⚠️ Instruction ${i + 1} ignorée: référence manquante`);
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier qu'il y a des instructions valides
|
||||
if (geminiInstructions.length === 0) {
|
||||
updateGeneration(generation.id, { status: "failed" });
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Aucune instruction valide fournie" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Générer avec Gemini
|
||||
console.log(
|
||||
`\n🚀 Lancement de la génération avec ${geminiInstructions.length} instruction(s)...`
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await generateBeforeAfter(
|
||||
originalImagePath, // Chemin S3 directement
|
||||
geminiInstructions,
|
||||
"generated", // Type de stockage S3
|
||||
generation.id
|
||||
);
|
||||
|
||||
updateGeneration(generation.id, {
|
||||
status: "completed",
|
||||
generated_image_path: result.imagePath,
|
||||
});
|
||||
|
||||
// Consommer un crédit (mensuel d'abord, puis bonus si nécessaire)
|
||||
if (!isAdmin) {
|
||||
const creditResult = consumeCredit(
|
||||
userId,
|
||||
userPlanInfo.planType,
|
||||
bonusCredits,
|
||||
() => useCredit(userId)
|
||||
);
|
||||
console.log(
|
||||
`💳 Crédit consommé pour ${userId} (bonus: ${creditResult.usedBonus})`
|
||||
);
|
||||
}
|
||||
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.log(`\n✅ GÉNÉRATION TERMINÉE en ${duration}s`);
|
||||
console.log(` 📁 Résultat: ${result.imagePath}`);
|
||||
console.log(` 🔄 Tentatives: ${result.attempts}`);
|
||||
console.log("═".repeat(50) + "\n");
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
generationId: generation.id,
|
||||
originalImage: originalImagePath,
|
||||
generatedImage: result.imagePath,
|
||||
description: result.description,
|
||||
attempts: result.attempts,
|
||||
duration: parseFloat(duration),
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
} catch (geminiError: any) {
|
||||
updateGeneration(generation.id, { status: "failed" });
|
||||
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
console.error(`\n❌ GÉNÉRATION ÉCHOUÉE après ${duration}s`);
|
||||
console.error(` Erreur: ${geminiError.message}`);
|
||||
console.log("═".repeat(50) + "\n");
|
||||
|
||||
throw geminiError;
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.message || "Erreur serveur inconnue";
|
||||
console.error("❌ Erreur API generate:", errorMessage);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: errorMessage,
|
||||
details:
|
||||
process.env.NODE_ENV === "development" ? error.stack : undefined,
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
58
src/pages/api/generation-status.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { getGeneration } from "../../lib/db";
|
||||
|
||||
/**
|
||||
* API pour vérifier le statut d'une génération
|
||||
* Utilisé pour le polling si le SSE se déconnecte
|
||||
*/
|
||||
export const GET: APIRoute = async ({ url, locals }) => {
|
||||
const auth = locals.auth();
|
||||
const userId = auth.userId;
|
||||
|
||||
if (!userId) {
|
||||
return new Response(JSON.stringify({ error: "Non authentifié" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const generationId = url.searchParams.get("id");
|
||||
|
||||
if (!generationId) {
|
||||
return new Response(JSON.stringify({ error: "ID de génération requis" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const generation = getGeneration(generationId);
|
||||
|
||||
if (!generation) {
|
||||
return new Response(JSON.stringify({ error: "Génération introuvable" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Vérifier que la génération appartient à l'utilisateur
|
||||
if (generation.user_id !== userId) {
|
||||
return new Response(JSON.stringify({ error: "Accès non autorisé" }), {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: generation.id,
|
||||
status: generation.status,
|
||||
originalImage: generation.original_image_path,
|
||||
generatedImage: generation.generated_image_path,
|
||||
createdAt: generation.created_at,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
};
|
||||
81
src/pages/api/images/[...path].ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { getImageBuffer } from "../../../lib/storage";
|
||||
import { isAdminUser } from "../../../lib/plans";
|
||||
|
||||
export const GET: APIRoute = async ({ params, locals }) => {
|
||||
const imagePath = params.path;
|
||||
|
||||
if (!imagePath) {
|
||||
return new Response("Image non trouvée", { status: 404 });
|
||||
}
|
||||
|
||||
// Sécurité : empêcher les path traversal
|
||||
if (imagePath.includes("..")) {
|
||||
return new Response("Chemin invalide", { status: 400 });
|
||||
}
|
||||
|
||||
// Déterminer le type d'image depuis le chemin
|
||||
// Format: {type}/{userId}-{timestamp}-{uuid}.{ext}
|
||||
const pathParts = imagePath.split("/");
|
||||
const imageType = pathParts[0]; // "generated", "originals", ou "references"
|
||||
|
||||
// Les images générées sont publiques (pour le partage social)
|
||||
const isPublicImage = imageType === "generated";
|
||||
|
||||
if (!isPublicImage) {
|
||||
// Pour les images privées (originals, references), vérifier l'authentification
|
||||
const auth = locals.auth();
|
||||
const userId = auth.userId;
|
||||
|
||||
if (!userId) {
|
||||
return new Response("Non authentifié", { status: 401 });
|
||||
}
|
||||
|
||||
// Les admins ont accès à toutes les images (pour le debug)
|
||||
const isAdmin = isAdminUser(userId);
|
||||
|
||||
// Vérifier que l'utilisateur a accès à cette image (sauf admin)
|
||||
if (!isAdmin && pathParts.length >= 2) {
|
||||
const filename = pathParts[pathParts.length - 1];
|
||||
const fileUserIdMatch = filename.match(/^(user_[^-]+)/);
|
||||
if (fileUserIdMatch) {
|
||||
const fileUserId = fileUserIdMatch[1];
|
||||
if (fileUserId !== userId) {
|
||||
console.warn(
|
||||
`🚫 Accès refusé: ${userId} tente d'accéder à l'image de ${fileUserId}`
|
||||
);
|
||||
return new Response("Accès non autorisé", { status: 403 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Servir l'image depuis S3 via proxy (bucket privé)
|
||||
try {
|
||||
// S'assurer que le chemin commence par /
|
||||
const s3Path = imagePath.startsWith("/") ? imagePath : `/${imagePath}`;
|
||||
const imageBuffer = await getImageBuffer(s3Path);
|
||||
|
||||
// Déterminer le type MIME
|
||||
const ext = imagePath.toLowerCase().split(".").pop();
|
||||
const mimeTypes: Record<string, string> = {
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
png: "image/png",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
};
|
||||
const contentType = mimeTypes[ext || ""] || "application/octet-stream";
|
||||
|
||||
return new Response(new Uint8Array(imageBuffer), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=31536000",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erreur lecture image S3:", error);
|
||||
return new Response("Image non trouvée", { status: 404 });
|
||||
}
|
||||
};
|
||||
256
src/pages/api/process-mask.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
// ============================================================================
|
||||
// API PROCESS-MASK - Traitement des masques pour la génération
|
||||
// ============================================================================
|
||||
// Cette API crée le "masque spécial combiné" où :
|
||||
// - La zone NOIRE du masque original est remplacée par l'image de référence
|
||||
// - Cela montre à l'IA "cette référence va ICI"
|
||||
// ============================================================================
|
||||
|
||||
import type { APIRoute } from "astro";
|
||||
import sharp from "sharp";
|
||||
|
||||
export interface ProcessMaskResult {
|
||||
/** Masque combiné en base64 (référence visible dans la zone noire) */
|
||||
combinedMaskBase64: string;
|
||||
/** Masque noir/blanc original en base64 */
|
||||
originalMaskBase64: string;
|
||||
/** Dimensions du masque */
|
||||
dimensions: { width: number; height: number };
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const originalImageFile = formData.get("originalImage") as File;
|
||||
const maskFile = formData.get("mask") as File;
|
||||
const referenceFile = formData.get("reference") as File;
|
||||
|
||||
if (!originalImageFile || !maskFile || !referenceFile) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Image originale, masque et référence requis" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Convertir les fichiers en buffers
|
||||
const originalBuffer = Buffer.from(await originalImageFile.arrayBuffer());
|
||||
const maskBuffer = Buffer.from(await maskFile.arrayBuffer());
|
||||
const referenceBuffer = Buffer.from(await referenceFile.arrayBuffer());
|
||||
|
||||
// Obtenir les métadonnées de l'image originale
|
||||
const originalMetadata = await sharp(originalBuffer).metadata();
|
||||
const { width, height } = originalMetadata;
|
||||
|
||||
if (!width || !height) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Impossible de lire les dimensions de l'image" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Redimensionner le masque aux dimensions de l'image originale si nécessaire
|
||||
const maskResized = await sharp(maskBuffer)
|
||||
.resize(width, height, { fit: "fill" })
|
||||
.greyscale()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
// Redimensionner la référence aux dimensions de l'image originale
|
||||
// On utilise "cover" pour remplir toute la zone sans déformation
|
||||
const referenceResized = await sharp(referenceBuffer)
|
||||
.resize(width, height, { fit: "cover", position: "center" })
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
// Charger l'image originale en RGBA
|
||||
const originalRGBA = await sharp(originalBuffer)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
// Créer le masque combiné :
|
||||
// - Zone BLANCHE (masque > 127) = image originale visible (zone à modifier)
|
||||
// - Zone NOIRE (masque <= 127) = image originale (contexte)
|
||||
// MAIS pour le "masque spécial combiné" on veut :
|
||||
// - Zone BLANCHE = RÉFÉRENCE (montrer ce qui sera appliqué)
|
||||
// - Zone NOIRE = image originale (contexte)
|
||||
|
||||
const combinedBuffer = Buffer.alloc(width * height * 4);
|
||||
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const imgIdx = i * 4;
|
||||
const maskValue = maskResized[i];
|
||||
|
||||
// Si le masque est BLANC (zone sélectionnée) → mettre la RÉFÉRENCE
|
||||
// Si le masque est NOIR (contexte) → garder l'image originale
|
||||
if (maskValue > 127) {
|
||||
// Zone blanche = RÉFÉRENCE
|
||||
combinedBuffer[imgIdx] = referenceResized[imgIdx]; // R
|
||||
combinedBuffer[imgIdx + 1] = referenceResized[imgIdx + 1]; // G
|
||||
combinedBuffer[imgIdx + 2] = referenceResized[imgIdx + 2]; // B
|
||||
combinedBuffer[imgIdx + 3] = 255; // A
|
||||
} else {
|
||||
// Zone noire = IMAGE ORIGINALE
|
||||
combinedBuffer[imgIdx] = originalRGBA[imgIdx]; // R
|
||||
combinedBuffer[imgIdx + 1] = originalRGBA[imgIdx + 1]; // G
|
||||
combinedBuffer[imgIdx + 2] = originalRGBA[imgIdx + 2]; // B
|
||||
combinedBuffer[imgIdx + 3] = 255; // A
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir le buffer en PNG
|
||||
const combinedImage = await sharp(combinedBuffer, {
|
||||
raw: { width, height, channels: 4 },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
// Convertir le masque original en PNG propre
|
||||
const cleanMask = await sharp(maskBuffer)
|
||||
.resize(width, height, { fit: "fill" })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const result: ProcessMaskResult = {
|
||||
combinedMaskBase64: combinedImage.toString("base64"),
|
||||
originalMaskBase64: cleanMask.toString("base64"),
|
||||
dimensions: { width, height },
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erreur lors du traitement du masque:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Erreur lors du traitement du masque" }),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Version pour utilisation côté serveur (dans generate-stream)
|
||||
*/
|
||||
export async function createCombinedMask(
|
||||
originalBuffer: Buffer,
|
||||
maskBuffer: Buffer,
|
||||
referenceBuffer: Buffer
|
||||
): Promise<{ combinedBuffer: Buffer; width: number; height: number }> {
|
||||
// Obtenir les métadonnées de l'image originale
|
||||
const originalMetadata = await sharp(originalBuffer).metadata();
|
||||
const { width, height } = originalMetadata;
|
||||
|
||||
if (!width || !height) {
|
||||
throw new Error("Impossible de lire les dimensions de l'image");
|
||||
}
|
||||
|
||||
// Redimensionner le masque aux dimensions de l'image originale
|
||||
const maskResized = await sharp(maskBuffer)
|
||||
.resize(width, height, { fit: "fill" })
|
||||
.greyscale()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
// Redimensionner la référence aux dimensions de l'image originale
|
||||
const referenceResized = await sharp(referenceBuffer)
|
||||
.resize(width, height, { fit: "cover", position: "center" })
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
// Charger l'image originale en RGBA
|
||||
const originalRGBA = await sharp(originalBuffer)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
// Créer le masque combiné
|
||||
const combinedBuffer = Buffer.alloc(width * height * 4);
|
||||
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const imgIdx = i * 4;
|
||||
const maskValue = maskResized[i];
|
||||
|
||||
if (maskValue > 127) {
|
||||
// Zone blanche = RÉFÉRENCE
|
||||
combinedBuffer[imgIdx] = referenceResized[imgIdx];
|
||||
combinedBuffer[imgIdx + 1] = referenceResized[imgIdx + 1];
|
||||
combinedBuffer[imgIdx + 2] = referenceResized[imgIdx + 2];
|
||||
combinedBuffer[imgIdx + 3] = 255;
|
||||
} else {
|
||||
// Zone noire = IMAGE ORIGINALE
|
||||
combinedBuffer[imgIdx] = originalRGBA[imgIdx];
|
||||
combinedBuffer[imgIdx + 1] = originalRGBA[imgIdx + 1];
|
||||
combinedBuffer[imgIdx + 2] = originalRGBA[imgIdx + 2];
|
||||
combinedBuffer[imgIdx + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir en PNG
|
||||
const finalBuffer = await sharp(combinedBuffer, {
|
||||
raw: { width, height, channels: 4 },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
return { combinedBuffer: finalBuffer, width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un masque aperçu (image originale visible à travers le masque)
|
||||
*/
|
||||
export async function createPreviewMask(
|
||||
originalBuffer: Buffer,
|
||||
maskBuffer: Buffer
|
||||
): Promise<Buffer> {
|
||||
const originalMetadata = await sharp(originalBuffer).metadata();
|
||||
const { width, height } = originalMetadata;
|
||||
|
||||
if (!width || !height) {
|
||||
throw new Error("Impossible de lire les dimensions de l'image");
|
||||
}
|
||||
|
||||
// Redimensionner le masque
|
||||
const maskResized = await sharp(maskBuffer)
|
||||
.resize(width, height, { fit: "fill" })
|
||||
.greyscale()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
// Charger l'image originale en RGBA
|
||||
const originalRGBA = await sharp(originalBuffer)
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
// Créer l'aperçu : zone blanche = original visible, zone noire = noir
|
||||
const previewBuffer = Buffer.alloc(width * height * 4);
|
||||
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const imgIdx = i * 4;
|
||||
const maskValue = maskResized[i];
|
||||
|
||||
if (maskValue > 127) {
|
||||
// Zone blanche = IMAGE ORIGINALE visible
|
||||
previewBuffer[imgIdx] = originalRGBA[imgIdx];
|
||||
previewBuffer[imgIdx + 1] = originalRGBA[imgIdx + 1];
|
||||
previewBuffer[imgIdx + 2] = originalRGBA[imgIdx + 2];
|
||||
previewBuffer[imgIdx + 3] = 255;
|
||||
} else {
|
||||
// Zone noire = NOIR
|
||||
previewBuffer[imgIdx] = 0;
|
||||
previewBuffer[imgIdx + 1] = 0;
|
||||
previewBuffer[imgIdx + 2] = 0;
|
||||
previewBuffer[imgIdx + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return sharp(previewBuffer, {
|
||||
raw: { width, height, channels: 4 },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
150
src/pages/api/stripe/create-checkout.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { stripe, getPriceById, getProductById } from "../../../lib/stripe";
|
||||
import {
|
||||
getOrCreateStripeCustomer,
|
||||
getSubscription,
|
||||
} from "../../../lib/subscriptions";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
try {
|
||||
const auth = locals.auth();
|
||||
const userId = auth.userId;
|
||||
|
||||
if (!userId) {
|
||||
return new Response(JSON.stringify({ error: "Non authentifié" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
let { priceId, quantity = 1 } = await request.json();
|
||||
|
||||
// Valider la quantité (minimum 1)
|
||||
quantity = Math.max(1, parseInt(quantity) || 1);
|
||||
|
||||
if (!priceId) {
|
||||
return new Response(JSON.stringify({ error: "priceId requis" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Vérifier que le prix existe
|
||||
const price = await getPriceById(priceId);
|
||||
if (!price) {
|
||||
return new Response(JSON.stringify({ error: "Prix invalide" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Récupérer les infos utilisateur depuis Clerk
|
||||
const user = await locals.currentUser();
|
||||
const email = user?.emailAddresses?.[0]?.emailAddress;
|
||||
const name = user?.firstName
|
||||
? `${user.firstName} ${user.lastName || ""}`.trim()
|
||||
: undefined;
|
||||
|
||||
// Créer ou récupérer le customer Stripe
|
||||
const customerId = await getOrCreateStripeCustomer(userId, email, name);
|
||||
|
||||
// Récupérer le produit pour savoir le type
|
||||
const product = await getProductById(price.productId);
|
||||
const isSubscription = price.recurring !== null;
|
||||
|
||||
console.log(`🛒 Checkout: priceId=${priceId}, quantity=${quantity}`);
|
||||
console.log(
|
||||
`🛒 Product: id=${product?.id}, type=${product?.type}, name=${product?.name}`
|
||||
);
|
||||
console.log(
|
||||
`🛒 isSubscription=${isSubscription}, recurring=${JSON.stringify(
|
||||
price.recurring
|
||||
)}`
|
||||
);
|
||||
|
||||
// Construire l'URL de base
|
||||
const baseUrl = new URL(request.url).origin;
|
||||
|
||||
// Créer la session Checkout
|
||||
const sessionParams: any = {
|
||||
customer: customerId,
|
||||
line_items: [
|
||||
{
|
||||
price: priceId,
|
||||
quantity: isSubscription ? 1 : quantity,
|
||||
},
|
||||
],
|
||||
success_url: `${baseUrl}/profile?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${baseUrl}/pricing?checkout=canceled`,
|
||||
metadata: {
|
||||
userId,
|
||||
productType: product?.type || "unknown",
|
||||
quantity: quantity.toString(),
|
||||
},
|
||||
locale: "fr",
|
||||
allow_promotion_codes: true,
|
||||
};
|
||||
|
||||
// Mode différent selon abonnement ou achat unique
|
||||
if (isSubscription) {
|
||||
// Vérifier si l'utilisateur a déjà un abonnement Stripe actif
|
||||
const existingSub = getSubscription(userId);
|
||||
if (
|
||||
existingSub?.stripe_subscription_id &&
|
||||
existingSub.status === "active"
|
||||
) {
|
||||
// Annuler l'ancien abonnement Stripe avant d'en créer un nouveau
|
||||
// Ou rediriger vers le portail pour changer de plan
|
||||
console.log(
|
||||
`🔄 Utilisateur ${userId} a déjà un abonnement actif, redirection vers changement de plan`
|
||||
);
|
||||
|
||||
// Option: Annuler l'ancien et créer le nouveau
|
||||
try {
|
||||
await stripe.subscriptions.cancel(
|
||||
existingSub.stripe_subscription_id,
|
||||
{
|
||||
prorate: true, // Rembourser au prorata
|
||||
}
|
||||
);
|
||||
console.log(
|
||||
`❌ Ancien abonnement ${existingSub.stripe_subscription_id} annulé`
|
||||
);
|
||||
} catch (cancelError: any) {
|
||||
console.error(`Erreur annulation abonnement:`, cancelError.message);
|
||||
// Si erreur, on continue quand même (l'abonnement était peut-être déjà annulé)
|
||||
}
|
||||
}
|
||||
|
||||
sessionParams.mode = "subscription";
|
||||
sessionParams.subscription_data = {
|
||||
metadata: { userId },
|
||||
};
|
||||
} else {
|
||||
sessionParams.mode = "payment";
|
||||
sessionParams.payment_intent_data = {
|
||||
metadata: {
|
||||
userId,
|
||||
quantity: quantity.toString(),
|
||||
productType: product?.type || "unknown",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const session = await stripe.checkout.sessions.create(sessionParams);
|
||||
|
||||
return new Response(JSON.stringify({ url: session.url }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Erreur création checkout:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: error.message || "Erreur serveur" }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
75
src/pages/api/stripe/create-portal.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { stripe } from "../../../lib/stripe";
|
||||
import { getSubscription, getOrCreateStripeCustomer } from "../../../lib/subscriptions";
|
||||
|
||||
export const POST: APIRoute = async ({ request, locals }) => {
|
||||
try {
|
||||
const auth = locals.auth();
|
||||
const userId = auth.userId;
|
||||
|
||||
if (!userId) {
|
||||
return new Response(JSON.stringify({ error: "Non authentifié" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const subscription = getSubscription(userId);
|
||||
|
||||
if (!subscription?.stripe_customer_id) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Pas de compte Stripe associé",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Créer une session du portail de facturation Stripe
|
||||
const baseUrl = new URL(request.url).origin;
|
||||
|
||||
try {
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: subscription.stripe_customer_id,
|
||||
return_url: `${baseUrl}/profile`,
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ url: session.url }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (stripeError: any) {
|
||||
// Gérer le cas où le client Stripe n'existe plus -> recréer automatiquement
|
||||
if (stripeError.code === 'resource_missing') {
|
||||
console.log(`[Stripe] Customer introuvable, recréation automatique pour user ${userId}`);
|
||||
|
||||
// Recréer le customer Stripe
|
||||
const newCustomerId = await getOrCreateStripeCustomer(userId);
|
||||
|
||||
// Réessayer de créer la session
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: newCustomerId,
|
||||
return_url: `${baseUrl}/profile`,
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ url: session.url }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
throw stripeError; // Relancer les autres erreurs
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Erreur création portail:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: error.message || "Erreur serveur" }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
38
src/pages/api/stripe/debug.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { getSubscription } from "../../../lib/subscriptions";
|
||||
import db from "../../../lib/db";
|
||||
|
||||
export const GET: APIRoute = async ({ locals }) => {
|
||||
const auth = locals.auth();
|
||||
const userId = auth.userId;
|
||||
|
||||
// Récupérer tous les abonnements pour debug
|
||||
const allSubscriptions = db
|
||||
.prepare("SELECT * FROM subscriptions LIMIT 20")
|
||||
.all();
|
||||
|
||||
// Récupérer tous les achats de crédits
|
||||
const allCreditPurchases = db
|
||||
.prepare("SELECT * FROM credit_purchases LIMIT 20")
|
||||
.all();
|
||||
|
||||
// L'abonnement de l'utilisateur connecté (si connecté)
|
||||
const userSubscription = userId ? getSubscription(userId) : null;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify(
|
||||
{
|
||||
currentUserId: userId || "non connecté",
|
||||
userSubscription,
|
||||
allSubscriptions,
|
||||
allCreditPurchases,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
};
|
||||
78
src/pages/api/stripe/products.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import {
|
||||
stripe,
|
||||
getStripeProducts,
|
||||
getSubscriptionProducts,
|
||||
getCreditProducts,
|
||||
formatPrice,
|
||||
type StripeProduct,
|
||||
} from "../../../lib/stripe";
|
||||
import { getSubscription } from "../../../lib/subscriptions";
|
||||
|
||||
export const GET: APIRoute = async ({ locals }) => {
|
||||
try {
|
||||
const auth = locals.auth();
|
||||
const userId = auth.userId;
|
||||
|
||||
// Récupérer tous les produits
|
||||
const allProducts = await getStripeProducts();
|
||||
const subscriptionProducts = allProducts.filter(
|
||||
(p) => p.type === "subscription_25" || p.type === "subscription_50"
|
||||
);
|
||||
const creditProducts = allProducts.filter(
|
||||
(p) => p.type === "credit_with_sub" || p.type === "credit_no_sub"
|
||||
);
|
||||
|
||||
// Récupérer l'abonnement actuel de l'utilisateur
|
||||
let currentPlan = null;
|
||||
if (userId) {
|
||||
const sub = getSubscription(userId);
|
||||
if (sub) {
|
||||
currentPlan = {
|
||||
planType: sub.plan_type,
|
||||
status: sub.status,
|
||||
creditsBalance: sub.credits_balance,
|
||||
cancelAtPeriodEnd: sub.cancel_at_period_end === 1,
|
||||
currentPeriodEnd: sub.current_period_end,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Formater les produits pour le frontend
|
||||
const formatProduct = (product: StripeProduct) => ({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
type: product.type,
|
||||
prices: product.prices.map((price) => ({
|
||||
id: price.id,
|
||||
amount: price.unitAmount,
|
||||
amountFormatted: formatPrice(price.unitAmount, price.currency),
|
||||
currency: price.currency,
|
||||
recurring: price.recurring,
|
||||
interval: price.recurring?.interval || null,
|
||||
})),
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
subscriptions: subscriptionProducts.map(formatProduct),
|
||||
credits: creditProducts.map(formatProduct),
|
||||
currentPlan,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error("Erreur récupération produits:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: error.message || "Erreur serveur" }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
335
src/pages/api/stripe/webhook.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { stripe, type ProductType } from "../../../lib/stripe";
|
||||
import {
|
||||
upsertSubscription,
|
||||
cancelSubscription,
|
||||
getSubscriptionByStripeId,
|
||||
getSubscriptionByCustomerId,
|
||||
addCredits,
|
||||
createCreditPurchase,
|
||||
updateCreditPurchaseStatus,
|
||||
getPlanFromProductType,
|
||||
} from "../../../lib/subscriptions";
|
||||
import type Stripe from "stripe";
|
||||
|
||||
// Récupérer le webhook secret depuis les variables d'environnement
|
||||
const WEBHOOK_SECRET =
|
||||
import.meta.env.STRIPE_WEBHOOK_SECRET ||
|
||||
process.env.STRIPE_WEBHOOK_SECRET ||
|
||||
"";
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
console.log("🔔 Webhook endpoint appelé");
|
||||
|
||||
const body = await request.text();
|
||||
const sig = request.headers.get("stripe-signature");
|
||||
|
||||
console.log(`🔔 Signature présente: ${!!sig}`);
|
||||
console.log(`🔔 WEBHOOK_SECRET configuré: ${!!WEBHOOK_SECRET}`);
|
||||
|
||||
if (!sig) {
|
||||
console.error("Webhook: Signature manquante");
|
||||
return new Response("Signature manquante", { status: 400 });
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
|
||||
try {
|
||||
// Vérifier la signature si le secret est configuré
|
||||
if (WEBHOOK_SECRET) {
|
||||
event = stripe.webhooks.constructEvent(body, sig, WEBHOOK_SECRET);
|
||||
} else {
|
||||
// En dev sans signature (pas recommandé en prod)
|
||||
console.warn("⚠️ Webhook secret non configuré - signature non vérifiée");
|
||||
event = JSON.parse(body) as Stripe.Event;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Webhook signature verification failed:", err.message);
|
||||
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
|
||||
}
|
||||
|
||||
console.log(`📩 Webhook Stripe: ${event.type}`);
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
// ==========================================
|
||||
// CHECKOUT COMPLÉTÉ
|
||||
// ==========================================
|
||||
case "checkout.session.completed": {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
|
||||
// Debug: log toutes les metadata
|
||||
console.log("🔍 Session metadata:", JSON.stringify(session.metadata));
|
||||
console.log("🔍 Session customer:", session.customer);
|
||||
console.log("🔍 Session mode:", session.mode);
|
||||
console.log("🔍 Session subscription:", session.subscription);
|
||||
|
||||
const userId = session.metadata?.userId;
|
||||
const productType = session.metadata?.productType as
|
||||
| ProductType
|
||||
| undefined;
|
||||
const quantity = parseInt(session.metadata?.quantity || "1", 10);
|
||||
|
||||
if (!userId) {
|
||||
console.error("Webhook: userId manquant dans metadata");
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ Checkout complété pour user ${userId}, type: ${productType}`
|
||||
);
|
||||
|
||||
if (session.mode === "subscription") {
|
||||
// Abonnement créé - La création effective est gérée par invoice.payment_succeeded
|
||||
// Ici on log juste pour debug car l'abonnement sera créé/mis à jour par l'invoice
|
||||
console.log(
|
||||
`📝 Checkout subscription complété pour ${userId}, subscription: ${session.subscription}`
|
||||
);
|
||||
console.log(
|
||||
`📝 L'abonnement sera créé/mis à jour via invoice.payment_succeeded`
|
||||
);
|
||||
} else if (session.mode === "payment") {
|
||||
// Achat de crédits
|
||||
const creditsToAdd = quantity;
|
||||
|
||||
// Enregistrer l'achat
|
||||
createCreditPurchase(userId, {
|
||||
stripe_checkout_session_id: session.id,
|
||||
stripe_payment_intent_id: session.payment_intent as string,
|
||||
credits_amount: creditsToAdd,
|
||||
price_paid: session.amount_total || 0,
|
||||
currency: session.currency || "eur",
|
||||
status: "completed",
|
||||
});
|
||||
|
||||
// Ajouter les crédits
|
||||
const newBalance = addCredits(userId, creditsToAdd);
|
||||
|
||||
console.log(
|
||||
`💰 ${creditsToAdd} crédits ajoutés pour ${userId} (nouveau solde: ${newBalance})`
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ABONNEMENT MIS À JOUR
|
||||
// ==========================================
|
||||
case "customer.subscription.updated": {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
const sub = getSubscriptionByStripeId(subscription.id);
|
||||
|
||||
if (sub) {
|
||||
// Dans l'API Stripe 2025-12-15.clover, current_period_end est sur les items
|
||||
const item = subscription.items.data[0];
|
||||
const periodEnd = (item as any)?.current_period_end;
|
||||
const periodStart = (item as any)?.current_period_start;
|
||||
const cancelAtPeriodEnd = (subscription as any).cancel_at_period_end;
|
||||
const status = (subscription as any).status;
|
||||
|
||||
// Détecter le changement de plan depuis le produit
|
||||
const productId = item?.price?.product as string;
|
||||
let planType = sub.plan_type;
|
||||
|
||||
if (productId) {
|
||||
try {
|
||||
const product = await stripe.products.retrieve(productId);
|
||||
const productType = product.metadata?.type as
|
||||
| ProductType
|
||||
| undefined;
|
||||
if (productType) {
|
||||
planType = getPlanFromProductType(productType);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Erreur récupération produit: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`🔍 Subscription update: planType=${planType}, periodEnd=${periodEnd}, cancel=${cancelAtPeriodEnd}, status=${status}`
|
||||
);
|
||||
|
||||
// Mettre à jour l'abonnement avec le nouveau plan
|
||||
upsertSubscription(sub.user_id, {
|
||||
plan_type: planType,
|
||||
status: status as any,
|
||||
cancel_at_period_end: cancelAtPeriodEnd ? 1 : 0,
|
||||
...(periodEnd && {
|
||||
current_period_end: new Date(periodEnd * 1000).toISOString(),
|
||||
}),
|
||||
...(periodStart && {
|
||||
current_period_start: new Date(periodStart * 1000).toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
console.log(
|
||||
`🔄 Abonnement mis à jour pour ${sub.user_id} (plan: ${planType})`
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ABONNEMENT ANNULÉ
|
||||
// ==========================================
|
||||
case "customer.subscription.deleted": {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
cancelSubscription(subscription.id);
|
||||
console.log(`❌ Abonnement ${subscription.id} annulé`);
|
||||
break;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PAIEMENT ÉCHOUÉ
|
||||
// ==========================================
|
||||
case "invoice.payment_failed": {
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
const customerId = invoice.customer as string;
|
||||
const sub = getSubscriptionByCustomerId(customerId);
|
||||
|
||||
if (sub) {
|
||||
upsertSubscription(sub.user_id, {
|
||||
status: "past_due",
|
||||
});
|
||||
console.log(`⚠️ Paiement échoué pour ${sub.user_id}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PAIEMENT RÉUSSI (création ou renouvellement)
|
||||
// ==========================================
|
||||
case "invoice.payment_succeeded": {
|
||||
const invoice = event.data.object as Stripe.Invoice;
|
||||
const customerId = invoice.customer as string;
|
||||
|
||||
console.log(`🔍 Invoice billing_reason: ${invoice.billing_reason}`);
|
||||
|
||||
// Récupérer le userId depuis les metadata de la subscription
|
||||
const subscriptionDetails = (invoice as any).parent
|
||||
?.subscription_details;
|
||||
const userId = subscriptionDetails?.metadata?.userId;
|
||||
const subscriptionId = subscriptionDetails?.subscription;
|
||||
|
||||
console.log(
|
||||
`🔍 Invoice userId: ${userId}, subscriptionId: ${subscriptionId}`
|
||||
);
|
||||
|
||||
// Création initiale d'un abonnement
|
||||
if (
|
||||
invoice.billing_reason === "subscription_create" &&
|
||||
userId &&
|
||||
subscriptionId
|
||||
) {
|
||||
try {
|
||||
// Récupérer les détails de la subscription
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
subscriptionId
|
||||
);
|
||||
|
||||
console.log(
|
||||
`🔍 Subscription retrieved:`,
|
||||
JSON.stringify(subscription, null, 2).substring(0, 500)
|
||||
);
|
||||
|
||||
// Déterminer le type de plan depuis le produit
|
||||
const productId = subscription.items.data[0]?.price
|
||||
.product as string;
|
||||
const product = await stripe.products.retrieve(productId);
|
||||
const productType = product.metadata?.type as
|
||||
| ProductType
|
||||
| undefined;
|
||||
const planType = productType
|
||||
? getPlanFromProductType(productType)
|
||||
: "standard";
|
||||
|
||||
console.log(
|
||||
`🔍 Product type: ${productType}, planType: ${planType}`
|
||||
);
|
||||
|
||||
// Dans l'API Stripe 2025-12-15.clover, current_period est sur les items
|
||||
const item = subscription.items.data[0];
|
||||
const periodStart = (item as any)?.current_period_start;
|
||||
const periodEnd = (item as any)?.current_period_end;
|
||||
const cancelAtPeriodEnd = (subscription as any)
|
||||
.cancel_at_period_end;
|
||||
|
||||
console.log(
|
||||
`🔍 Period: ${periodStart} -> ${periodEnd}, cancel: ${cancelAtPeriodEnd}`
|
||||
);
|
||||
|
||||
upsertSubscription(userId, {
|
||||
stripe_customer_id: customerId,
|
||||
stripe_subscription_id: subscriptionId,
|
||||
plan_type: planType,
|
||||
status: "active",
|
||||
current_period_start: periodStart
|
||||
? new Date(periodStart * 1000).toISOString()
|
||||
: null,
|
||||
current_period_end: periodEnd
|
||||
? new Date(periodEnd * 1000).toISOString()
|
||||
: null,
|
||||
cancel_at_period_end: cancelAtPeriodEnd ? 1 : 0,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`✅ Abonnement ${planType} créé via invoice pour ${userId}`
|
||||
);
|
||||
} catch (subError: any) {
|
||||
console.error(
|
||||
`❌ Erreur lors de la création de l'abonnement:`,
|
||||
subError.message
|
||||
);
|
||||
throw subError;
|
||||
}
|
||||
}
|
||||
// Renouvellement d'abonnement
|
||||
else if (invoice.billing_reason === "subscription_cycle") {
|
||||
const sub = getSubscriptionByCustomerId(customerId);
|
||||
if (sub) {
|
||||
upsertSubscription(sub.user_id, {
|
||||
status: "active",
|
||||
});
|
||||
console.log(`✅ Renouvellement réussi pour ${sub.user_id}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PAYMENT INTENT RÉUSSI (achat de crédits one-time)
|
||||
// Note: Les crédits sont déjà ajoutés via checkout.session.completed
|
||||
// Ce handler est gardé pour debug uniquement
|
||||
// ==========================================
|
||||
case "payment_intent.succeeded": {
|
||||
const paymentIntent = event.data.object as Stripe.PaymentIntent;
|
||||
const userId = paymentIntent.metadata?.userId;
|
||||
const quantity = parseInt(paymentIntent.metadata?.quantity || "1", 10);
|
||||
const productType = paymentIntent.metadata?.productType;
|
||||
|
||||
console.log(
|
||||
`🔍 PaymentIntent reçu (ignoré - déjà traité par checkout.session.completed):`,
|
||||
JSON.stringify(paymentIntent.metadata)
|
||||
);
|
||||
|
||||
// NE PAS ajouter de crédits ici - c'est fait dans checkout.session.completed
|
||||
// Cet événement arrive APRÈS checkout.session.completed et causerait un doublon
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.log(`Webhook non géré: ${event.type}`);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ received: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Erreur webhook:", error);
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
};
|
||||
29
src/pages/api/user/delete-account.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { clerkClient } from "@clerk/astro/server";
|
||||
|
||||
export const POST: APIRoute = async (context) => {
|
||||
try {
|
||||
const auth = context.locals.auth();
|
||||
const userId = auth.userId;
|
||||
|
||||
if (!userId) {
|
||||
return new Response(JSON.stringify({ error: "Non authentifié" }), {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
|
||||
// Supprimer l'utilisateur via Clerk
|
||||
await clerkClient(context).users.deleteUser(userId);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, message: "Compte supprimé avec succès" }),
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error("Erreur lors de la suppression du compte:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Erreur lors de la suppression du compte" }),
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
2017
src/pages/generate.astro
Normal file
395
src/pages/history.astro
Normal file
@@ -0,0 +1,395 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import { SignedIn, SignedOut } from "@clerk/astro/components";
|
||||
import { getUserGenerationsPaginated, getGenerationInstructions, deleteGeneration } from "../lib/db";
|
||||
import { deleteImage } from "../lib/storage";
|
||||
import ShareButtons from "../components/ShareButtons.astro";
|
||||
|
||||
const auth = Astro.locals.auth();
|
||||
const userId = auth.userId;
|
||||
|
||||
if (!userId) {
|
||||
return Astro.redirect("/");
|
||||
}
|
||||
|
||||
// Pagination
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
const url = new URL(Astro.request.url);
|
||||
const currentPage = Math.max(1, parseInt(url.searchParams.get("page") || "1"));
|
||||
|
||||
// Fonction helper pour construire l'URL d'image
|
||||
function getImageUrl(path: string): string {
|
||||
if (!path) return '';
|
||||
// Retirer /uploads/ si présent (ancien format)
|
||||
let cleanPath = path.replace('/uploads/', '/').replace('/uploads', '');
|
||||
// Retirer le / initial pour éviter le double slash
|
||||
if (cleanPath.startsWith('/')) cleanPath = cleanPath.slice(1);
|
||||
return `/api/images/${cleanPath}`;
|
||||
}
|
||||
|
||||
// Gestion de la suppression
|
||||
if (Astro.request.method === "POST") {
|
||||
const formData = await Astro.request.formData();
|
||||
const action = formData.get("action");
|
||||
|
||||
if (action === "delete") {
|
||||
const id = formData.get("id") as string;
|
||||
const gen = await import("../lib/db").then(m => m.getGeneration(id));
|
||||
if (gen && gen.user_id === userId) {
|
||||
await deleteImage(gen.original_image_path);
|
||||
if (gen.generated_image_path) {
|
||||
await deleteImage(gen.generated_image_path);
|
||||
}
|
||||
deleteGeneration(id, userId);
|
||||
}
|
||||
}
|
||||
|
||||
return Astro.redirect("/history");
|
||||
}
|
||||
|
||||
// Récupérer les générations paginées directement depuis la DB
|
||||
const { generations, totalItems, totalPages } = getUserGenerationsPaginated(userId, currentPage, ITEMS_PER_PAGE);
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
const endIndex = startIndex + generations.length;
|
||||
|
||||
// Récupérer les instructions pour chaque génération
|
||||
const generationsWithInstructions = generations.map(gen => ({
|
||||
...gen,
|
||||
instructions: getGenerationInstructions(gen.id)
|
||||
}));
|
||||
---
|
||||
|
||||
<Layout title="Historique">
|
||||
<SignedOut>
|
||||
<div class="text-center py-20">
|
||||
<p class="text-slate-600">Veuillez vous connecter pour accéder à cette page.</p>
|
||||
</div>
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Historique</h1>
|
||||
<p class="text-slate-600 mt-2">Vos générations avant/après passées</p>
|
||||
</div>
|
||||
<a
|
||||
href="/generate"
|
||||
class="px-4 py-2 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span>+</span> Nouvelle génération
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{generationsWithInstructions.length === 0 ? (
|
||||
<div class="text-center py-20 bg-white rounded-xl border border-slate-200">
|
||||
<div class="text-6xl mb-4">📜</div>
|
||||
<h3 class="text-xl font-medium text-slate-800 mb-2">Aucune génération</h3>
|
||||
<p class="text-slate-600 mb-6">Vous n'avez pas encore créé de visualisation avant/après</p>
|
||||
<a
|
||||
href="/generate"
|
||||
class="inline-block px-6 py-3 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
Créer ma première génération
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div class="space-y-6">
|
||||
{generationsWithInstructions.map((gen) => (
|
||||
<div class="bg-white rounded-xl border border-slate-200">
|
||||
<div class="p-4 border-b border-slate-200 flex justify-between items-center">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class:list={[
|
||||
"px-3 py-1 rounded-full text-sm font-medium",
|
||||
gen.status === 'completed' ? "bg-green-100 text-green-700" :
|
||||
gen.status === 'processing' ? "bg-yellow-100 text-yellow-700" :
|
||||
gen.status === 'failed' ? "bg-red-100 text-red-700" :
|
||||
"bg-slate-100 text-slate-700"
|
||||
]}>
|
||||
{gen.status === 'completed' ? '✓ Terminé' :
|
||||
gen.status === 'processing' ? '⏳ En cours' :
|
||||
gen.status === 'failed' ? '✗ Échoué' :
|
||||
'○ En attente'}
|
||||
</span>
|
||||
<span class="text-sm text-slate-500">
|
||||
{new Date(gen.created_at).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="action" value="delete" />
|
||||
<input type="hidden" name="id" value={gen.id} />
|
||||
<button
|
||||
type="submit"
|
||||
onclick="return confirm('Supprimer cette génération ?')"
|
||||
class="p-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<!-- Image originale -->
|
||||
<div>
|
||||
<h4 class="font-medium text-slate-700 mb-2">Image originale</h4>
|
||||
<img
|
||||
src={getImageUrl(gen.original_image_path)}
|
||||
alt="Original"
|
||||
class="w-full rounded-lg border border-slate-200 cursor-pointer hover:opacity-90 transition-opacity lightbox-trigger"
|
||||
data-lightbox-src={getImageUrl(gen.original_image_path)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Résultat ou placeholder -->
|
||||
<div>
|
||||
<h4 class="font-medium text-slate-700 mb-2">Résultat IA</h4>
|
||||
{gen.generated_image_path && gen.generated_image_path.startsWith('/') ? (
|
||||
<img
|
||||
src={getImageUrl(gen.generated_image_path)}
|
||||
alt="Image générée"
|
||||
class="w-full rounded-lg border border-slate-200 cursor-pointer hover:opacity-90 transition-opacity lightbox-trigger"
|
||||
data-lightbox-src={getImageUrl(gen.generated_image_path)}
|
||||
/>
|
||||
) : gen.generated_image_path ? (
|
||||
<div class="prose prose-sm max-w-none p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<p class="whitespace-pre-wrap">{gen.generated_image_path}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div class="w-full aspect-video rounded-lg border-2 border-dashed border-slate-300 flex items-center justify-center bg-slate-50">
|
||||
<span class="text-slate-400">
|
||||
{gen.status === 'processing' ? 'Génération en cours...' : 'Pas encore généré'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instructions appliquées -->
|
||||
{gen.instructions.length > 0 && (
|
||||
<div class="mt-4 pt-4 border-t border-slate-200">
|
||||
<h4 class="font-medium text-slate-700 mb-3">Instructions appliquées</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{gen.instructions.map((instr) => (
|
||||
<div class="flex items-center gap-2 px-3 py-2 bg-slate-100 rounded-lg">
|
||||
<img
|
||||
src={getImageUrl(instr.reference.image_path)}
|
||||
alt={instr.reference.name || "Référence"}
|
||||
class="w-8 h-8 rounded object-cover"
|
||||
/>
|
||||
<span class="text-sm">
|
||||
<span class="font-medium">{instr.location}</span>
|
||||
{instr.reference.name && (
|
||||
<span class="text-slate-500"> → {instr.reference.name}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Boutons de partage -->
|
||||
{gen.generated_image_path && gen.generated_image_path.startsWith('/') && (
|
||||
<div class="mt-4 pt-4 border-t border-slate-200">
|
||||
<ShareButtons
|
||||
imageUrl={getImageUrl(gen.generated_image_path)}
|
||||
title="Ma transformation Avant/Après"
|
||||
description="Découvrez cette transformation réalisée avec PrimaDopu !"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<!-- Pagination -->
|
||||
{totalPages > 1 && (
|
||||
<div class="flex items-center justify-center gap-1 sm:gap-2 mt-8 pb-4 flex-wrap">
|
||||
{/* Bouton précédent */}
|
||||
{currentPage > 1 ? (
|
||||
<a
|
||||
href={`/history?page=${currentPage - 1}`}
|
||||
class="p-2 sm:px-4 sm:py-2 bg-white border border-slate-300 rounded-lg text-slate-700 hover:bg-slate-50 transition-colors flex items-center gap-1"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Précédent</span>
|
||||
</a>
|
||||
) : (
|
||||
<span class="p-2 sm:px-4 sm:py-2 bg-slate-100 border border-slate-200 rounded-lg text-slate-400 cursor-not-allowed flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Précédent</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Numéros de page - version smart */}
|
||||
<div class="flex items-center gap-1">
|
||||
{/* Toujours afficher la page 1 */}
|
||||
{currentPage === 1 ? (
|
||||
<span class="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center bg-indigo-600 text-white rounded-lg font-medium text-sm">
|
||||
1
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
href="/history?page=1"
|
||||
class="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center bg-white border border-slate-300 rounded-lg text-slate-700 hover:bg-slate-50 transition-colors text-sm"
|
||||
>
|
||||
1
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Ellipse avant si nécessaire */}
|
||||
{currentPage > 3 && (
|
||||
<span class="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center text-slate-400 text-sm">...</span>
|
||||
)}
|
||||
|
||||
{/* Pages autour de la page courante */}
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
.filter(page => page !== 1 && page !== totalPages && page >= currentPage - 1 && page <= currentPage + 1)
|
||||
.map((page) => (
|
||||
page === currentPage ? (
|
||||
<span class="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center bg-indigo-600 text-white rounded-lg font-medium text-sm">
|
||||
{page}
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
href={`/history?page=${page}`}
|
||||
class="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center bg-white border border-slate-300 rounded-lg text-slate-700 hover:bg-slate-50 transition-colors text-sm"
|
||||
>
|
||||
{page}
|
||||
</a>
|
||||
)
|
||||
))}
|
||||
|
||||
{/* Ellipse après si nécessaire */}
|
||||
{currentPage < totalPages - 2 && (
|
||||
<span class="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center text-slate-400 text-sm">...</span>
|
||||
)}
|
||||
|
||||
{/* Toujours afficher la dernière page (si > 1) */}
|
||||
{totalPages > 1 && (
|
||||
currentPage === totalPages ? (
|
||||
<span class="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center bg-indigo-600 text-white rounded-lg font-medium text-sm">
|
||||
{totalPages}
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
href={`/history?page=${totalPages}`}
|
||||
class="w-9 h-9 sm:w-10 sm:h-10 flex items-center justify-center bg-white border border-slate-300 rounded-lg text-slate-700 hover:bg-slate-50 transition-colors text-sm"
|
||||
>
|
||||
{totalPages}
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bouton suivant */}
|
||||
{currentPage < totalPages ? (
|
||||
<a
|
||||
href={`/history?page=${currentPage + 1}`}
|
||||
class="p-2 sm:px-4 sm:py-2 bg-white border border-slate-300 rounded-lg text-slate-700 hover:bg-slate-50 transition-colors flex items-center gap-1"
|
||||
>
|
||||
<span class="hidden sm:inline">Suivant</span>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</a>
|
||||
) : (
|
||||
<span class="p-2 sm:px-4 sm:py-2 bg-slate-100 border border-slate-200 rounded-lg text-slate-400 cursor-not-allowed flex items-center gap-1">
|
||||
<span class="hidden sm:inline">Suivant</span>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info pagination */}
|
||||
{totalPages > 1 && (
|
||||
<p class="text-center text-sm text-slate-500">
|
||||
Affichage {startIndex + 1} - {Math.min(endIndex, totalItems)} sur {totalItems} générations
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Lightbox pour voir les images en grand -->
|
||||
<div
|
||||
id="lightboxModal"
|
||||
class="fixed inset-0 bg-black/90 hidden items-center justify-center z-[100] cursor-zoom-out"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
id="lightboxClose"
|
||||
class="absolute top-4 right-4 p-2 text-white/80 hover:text-white transition-colors z-10"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<img
|
||||
id="lightboxImg"
|
||||
src=""
|
||||
alt="Image en grand"
|
||||
class="max-w-[95vw] max-h-[95vh] object-contain rounded-lg shadow-2xl cursor-default"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SignedIn>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
// ========== LIGHTBOX ==========
|
||||
const lightboxModal = document.getElementById("lightboxModal") as HTMLDivElement;
|
||||
const lightboxImg = document.getElementById("lightboxImg") as HTMLImageElement;
|
||||
const lightboxClose = document.getElementById("lightboxClose") as HTMLButtonElement;
|
||||
|
||||
function openLightbox(src: string) {
|
||||
lightboxImg.src = src;
|
||||
lightboxModal.classList.remove("hidden");
|
||||
lightboxModal.classList.add("flex");
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
function closeLightbox() {
|
||||
lightboxModal.classList.add("hidden");
|
||||
lightboxModal.classList.remove("flex");
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
|
||||
// Ajouter les listeners sur toutes les images avec la classe lightbox-trigger
|
||||
document.querySelectorAll(".lightbox-trigger").forEach((img) => {
|
||||
img.addEventListener("click", () => {
|
||||
const src = (img as HTMLImageElement).dataset.lightboxSrc || (img as HTMLImageElement).src;
|
||||
if (src) {
|
||||
openLightbox(src);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Fermer la lightbox
|
||||
lightboxClose?.addEventListener("click", closeLightbox);
|
||||
lightboxModal?.addEventListener("click", (e) => {
|
||||
if (e.target === lightboxModal) {
|
||||
closeLightbox();
|
||||
}
|
||||
});
|
||||
|
||||
// Fermer avec Escape
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && !lightboxModal?.classList.contains("hidden")) {
|
||||
closeLightbox();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
1029
src/pages/index.astro
Normal file
271
src/pages/legal/mentions.astro
Normal file
@@ -0,0 +1,271 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
---
|
||||
|
||||
<Layout
|
||||
title="Mentions Légales - PrimaDopu | Informations Juridiques"
|
||||
description="Mentions légales de PrimaDopu. Informations sur l'éditeur, l'hébergeur, la propriété intellectuelle et les conditions d'utilisation du service de visualisation IA."
|
||||
keywords="mentions légales primadopu, éditeur primadopu, hébergeur, informations juridiques"
|
||||
>
|
||||
<div class="max-w-4xl mx-auto py-12">
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center gap-2 text-indigo-600 hover:text-indigo-700 mb-8"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
Retour à l'accueil
|
||||
</a>
|
||||
|
||||
<h1 class="text-4xl font-bold text-slate-900 mb-8">Mentions Légales</h1>
|
||||
|
||||
<div class="prose prose-slate max-w-none">
|
||||
<p class="text-slate-600 mb-8">
|
||||
Conformément aux dispositions des articles 6-III et 19 de la Loi n°
|
||||
2004-575 du 21 juin 2004 pour la Confiance dans l'économie numérique
|
||||
(LCEN), nous portons à la connaissance des utilisateurs les informations
|
||||
suivantes :
|
||||
</p>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
1. Éditeur du site
|
||||
</h2>
|
||||
<div class="bg-slate-50 rounded-xl p-6 text-slate-600">
|
||||
<p class="mb-2"><strong>Nom commercial :</strong> PrimaDopu</p>
|
||||
<p class="mb-2"><strong>Exploitant :</strong> Anthony MENGHI</p>
|
||||
<p class="mb-2"><strong>Statut :</strong> Entrepreneur individuel</p>
|
||||
<p class="mb-2"><strong>SIRET :</strong> 914 116 835 00010</p>
|
||||
<p class="mb-2">
|
||||
<strong>APE :</strong> 74.10Z - Activités spécialisées de design
|
||||
</p>
|
||||
<p class="mb-2">
|
||||
<strong>Email :</strong>
|
||||
<a
|
||||
href="mailto:primadopu.ai@gmail.com"
|
||||
class="text-indigo-600 hover:underline">primadopu.ai@gmail.com</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
2. Directeur de la publication
|
||||
</h2>
|
||||
<div class="bg-slate-50 rounded-xl p-6 text-slate-600">
|
||||
<p class="mb-2"><strong>Nom :</strong> Anthony MENGHI</p>
|
||||
<p>
|
||||
<strong>Contact :</strong>
|
||||
<a
|
||||
href="mailto:primadopu.ai@gmail.com"
|
||||
class="text-indigo-600 hover:underline">primadopu.ai@gmail.com</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
3. Hébergement
|
||||
</h2>
|
||||
<div class="bg-slate-50 rounded-xl p-6 text-slate-600">
|
||||
<p class="mb-2"><strong>Hébergeur :</strong> Hetzner Online GmbH</p>
|
||||
<p class="mb-2">
|
||||
<strong>Adresse :</strong> Industriestr. 25, 91710 Gunzenhausen, Allemagne
|
||||
</p>
|
||||
<p>
|
||||
<strong>Site web :</strong>
|
||||
<a
|
||||
href="https://www.hetzner.com"
|
||||
class="text-indigo-600 hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener">www.hetzner.com</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
4. Services tiers utilisés
|
||||
</h2>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Le site utilise les services tiers suivants :
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-slate-50 rounded-xl p-6">
|
||||
<h3 class="font-semibold text-slate-800 mb-2">
|
||||
Authentification - Clerk
|
||||
</h3>
|
||||
<p class="text-slate-600 text-sm">
|
||||
Clerk, Inc.<br />
|
||||
548 Market St PMB 13723<br />
|
||||
San Francisco, CA 94104<br />
|
||||
États-Unis<br />
|
||||
<a
|
||||
href="https://clerk.com"
|
||||
class="text-indigo-600 hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener">clerk.com</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 rounded-xl p-6">
|
||||
<h3 class="font-semibold text-slate-800 mb-2">
|
||||
Intelligence Artificielle - Google
|
||||
</h3>
|
||||
<p class="text-slate-600 text-sm">
|
||||
Google LLC<br />
|
||||
1600 Amphitheatre Parkway<br />
|
||||
Mountain View, CA 94043<br />
|
||||
États-Unis<br />
|
||||
<a
|
||||
href="https://ai.google.dev"
|
||||
class="text-indigo-600 hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener">ai.google.dev</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
5. Propriété intellectuelle
|
||||
</h2>
|
||||
<p class="text-slate-600 mb-4">
|
||||
L'ensemble de ce site relève de la législation française et
|
||||
internationale sur le droit d'auteur et la propriété intellectuelle.
|
||||
Tous les droits de reproduction sont réservés, y compris pour les
|
||||
documents téléchargeables et les représentations iconographiques et
|
||||
photographiques.
|
||||
</p>
|
||||
<p class="text-slate-600">
|
||||
La reproduction de tout ou partie de ce site sur quelque support que
|
||||
ce soit est formellement interdite sauf autorisation expresse du
|
||||
directeur de la publication.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
6. Protection des données personnelles
|
||||
</h2>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Conformément au Règlement Général sur la Protection des Données (RGPD)
|
||||
et à la loi Informatique et Libertés, vous disposez de droits sur vos
|
||||
données personnelles.
|
||||
</p>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Pour plus d'informations sur le traitement de vos données
|
||||
personnelles, veuillez consulter notre <a
|
||||
href="/legal/privacy"
|
||||
class="text-indigo-600 hover:underline"
|
||||
>Politique de Confidentialité</a
|
||||
>.
|
||||
</p>
|
||||
<div class="bg-slate-50 rounded-xl p-6">
|
||||
<p class="text-slate-600 mb-2">
|
||||
<strong>Délégué à la Protection des Données (DPO) :</strong>
|
||||
</p>
|
||||
<p class="text-slate-600">
|
||||
Contact : <a
|
||||
href="mailto:primadopu.ai@gmail.com"
|
||||
class="text-indigo-600 hover:underline">primadopu.ai@gmail.com</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">7. Cookies</h2>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Le site utilise des cookies essentiels au fonctionnement du service
|
||||
(authentification, préférences). Pour plus d'informations sur
|
||||
l'utilisation des cookies, veuillez consulter notre <a
|
||||
href="/legal/privacy"
|
||||
class="text-indigo-600 hover:underline"
|
||||
>Politique de Confidentialité</a
|
||||
>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
8. Liens hypertextes
|
||||
</h2>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Le site peut contenir des liens vers d'autres sites web. Nous
|
||||
n'exerçons aucun contrôle sur ces sites et déclinons toute
|
||||
responsabilité quant à leur contenu.
|
||||
</p>
|
||||
<p class="text-slate-600">
|
||||
La mise en place de liens vers notre site est soumise à autorisation
|
||||
préalable.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
9. Limitation de responsabilité
|
||||
</h2>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Les informations contenues sur ce site sont aussi précises que
|
||||
possible et le site est régulièrement mis à jour, mais peut toutefois
|
||||
contenir des inexactitudes, des omissions ou des lacunes.
|
||||
</p>
|
||||
<p class="text-slate-600">
|
||||
L'éditeur du site ne pourra être tenu responsable des dommages directs
|
||||
ou indirects résultant de l'utilisation de ce site.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
10. Droit applicable
|
||||
</h2>
|
||||
<p class="text-slate-600">
|
||||
Les présentes mentions légales sont soumises au droit français. En cas
|
||||
de litige, et à défaut de résolution amiable, les tribunaux français
|
||||
seront seuls compétents.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">11. Crédits</h2>
|
||||
<div class="bg-slate-50 rounded-xl p-6 text-slate-600">
|
||||
<p class="mb-2">
|
||||
<strong>Conception et développement :</strong> [À compléter]
|
||||
</p>
|
||||
<p class="mb-2"><strong>Design :</strong> [À compléter]</p>
|
||||
<p><strong>Icônes :</strong> Heroicons</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="mt-12 pt-8 border-t border-slate-200">
|
||||
<p class="text-slate-500 text-sm text-center">
|
||||
Ces mentions légales ont été mises à jour le {
|
||||
new Date().toLocaleDateString("fr-FR", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
236
src/pages/legal/privacy.astro
Normal file
@@ -0,0 +1,236 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
---
|
||||
|
||||
<Layout
|
||||
title="Politique de Confidentialité - PrimaDopu | Protection des Données"
|
||||
description="Politique de confidentialité de PrimaDopu. Découvrez comment nous collectons, utilisons et protégeons vos données personnelles conformément au RGPD."
|
||||
keywords="politique confidentialité primadopu, protection données, RGPD, vie privée"
|
||||
>
|
||||
<div class="max-w-4xl mx-auto py-12">
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center gap-2 text-indigo-600 hover:text-indigo-700 mb-8"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
Retour à l'accueil
|
||||
</a>
|
||||
|
||||
<h1 class="text-4xl font-bold text-slate-900 mb-8">
|
||||
Politique de Confidentialité
|
||||
</h1>
|
||||
|
||||
<div class="prose prose-slate max-w-none">
|
||||
<p class="text-slate-600 mb-8">
|
||||
Dernière mise à jour : {
|
||||
new Date().toLocaleDateString("fr-FR", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
</p>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
1. Introduction
|
||||
</h2>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Bienvenue sur PrimaDopu. Nous accordons une grande importance à la
|
||||
protection de vos données personnelles. Cette politique de
|
||||
confidentialité explique comment nous collectons, utilisons, stockons
|
||||
et protégeons vos informations lorsque vous utilisez notre service.
|
||||
</p>
|
||||
<p class="text-slate-600">
|
||||
En utilisant PrimaDopu, vous acceptez les pratiques décrites dans
|
||||
cette politique.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
2. Données collectées
|
||||
</h2>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Nous collectons les types de données suivants :
|
||||
</p>
|
||||
|
||||
<h3 class="text-xl font-medium text-slate-800 mb-3">
|
||||
2.1 Données d'identification
|
||||
</h3>
|
||||
<ul class="list-disc list-inside text-slate-600 mb-4 space-y-2">
|
||||
<li>Adresse email</li>
|
||||
<li>Nom et prénom (si fournis)</li>
|
||||
<li>Photo de profil (si fournie)</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="text-xl font-medium text-slate-800 mb-3">
|
||||
2.2 Données d'utilisation
|
||||
</h3>
|
||||
<ul class="list-disc list-inside text-slate-600 mb-4 space-y-2">
|
||||
<li>Photos téléchargées pour la génération</li>
|
||||
<li>Images de référence de matériaux</li>
|
||||
<li>Historique des générations</li>
|
||||
<li>Instructions de modification</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="text-xl font-medium text-slate-800 mb-3">
|
||||
2.3 Données techniques
|
||||
</h3>
|
||||
<ul class="list-disc list-inside text-slate-600 space-y-2">
|
||||
<li>Adresse IP</li>
|
||||
<li>Type de navigateur et appareil</li>
|
||||
<li>Pages visitées et durée de visite</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
3. Utilisation des données
|
||||
</h2>
|
||||
<p class="text-slate-600 mb-4">Vos données sont utilisées pour :</p>
|
||||
<ul class="list-disc list-inside text-slate-600 space-y-2">
|
||||
<li>Fournir et améliorer nos services de génération d'images</li>
|
||||
<li>Gérer votre compte utilisateur</li>
|
||||
<li>Stocker votre bibliothèque de matériaux et votre historique</li>
|
||||
<li>
|
||||
Vous envoyer des notifications importantes relatives au service
|
||||
</li>
|
||||
<li>Améliorer la qualité de notre IA (données anonymisées)</li>
|
||||
<li>Assurer la sécurité de notre plateforme</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
4. Partage des données
|
||||
</h2>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Nous ne vendons jamais vos données personnelles. Nous pouvons partager
|
||||
vos données avec :
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-slate-600 space-y-2">
|
||||
<li>
|
||||
<strong>Google</strong> : Pour le traitement des images par
|
||||
l'IA. Les images sont traitées selon la politique de confidentialité de
|
||||
Google.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Clerk</strong> : Pour la gestion de l'authentification.
|
||||
</li>
|
||||
<li><strong>Autorités légales</strong> : Si requis par la loi.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
5. Stockage et sécurité
|
||||
</h2>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Vos données sont stockées de manière sécurisée sur nos serveurs. Nous
|
||||
mettons en œuvre des mesures de sécurité techniques et
|
||||
organisationnelles pour protéger vos informations contre tout accès
|
||||
non autorisé, modification, divulgation ou destruction.
|
||||
</p>
|
||||
<p class="text-slate-600">
|
||||
Les images téléchargées sont stockées de manière privée et ne sont
|
||||
accessibles que par vous.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
6. Conservation des données
|
||||
</h2>
|
||||
<p class="text-slate-600">
|
||||
Nous conservons vos données tant que votre compte est actif. Vous
|
||||
pouvez demander la suppression de vos données à tout moment. Après
|
||||
suppression de votre compte, vos données seront effacées dans un délai
|
||||
de 30 jours, sauf obligation légale de conservation.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
7. Vos droits (RGPD)
|
||||
</h2>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Conformément au RGPD, vous disposez des droits suivants :
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-slate-600 space-y-2">
|
||||
<li>
|
||||
<strong>Droit d'accès</strong> : Obtenir une copie de vos données personnelles
|
||||
</li>
|
||||
<li>
|
||||
<strong>Droit de rectification</strong> : Corriger vos données inexactes
|
||||
</li>
|
||||
<li>
|
||||
<strong>Droit à l'effacement</strong> : Demander la suppression de vos
|
||||
données
|
||||
</li>
|
||||
<li>
|
||||
<strong>Droit à la portabilité</strong> : Recevoir vos données dans un
|
||||
format structuré
|
||||
</li>
|
||||
<li>
|
||||
<strong>Droit d'opposition</strong> : Vous opposer au traitement de vos
|
||||
données
|
||||
</li>
|
||||
<li>
|
||||
<strong>Droit de limitation</strong> : Limiter le traitement de vos données
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-slate-600 mt-4">
|
||||
Pour exercer ces droits, contactez-nous à : <a
|
||||
href="mailto:primadopu.ai@gmail.com"
|
||||
class="text-indigo-600 hover:underline">primadopu.ai@gmail.com</a
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">8. Cookies</h2>
|
||||
<p class="text-slate-600">
|
||||
Nous utilisons des cookies essentiels pour le fonctionnement du
|
||||
service (authentification, préférences). Nous n'utilisons pas de
|
||||
cookies publicitaires ou de tracking tiers.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
9. Modifications
|
||||
</h2>
|
||||
<p class="text-slate-600">
|
||||
Nous pouvons modifier cette politique de confidentialité à tout
|
||||
moment. Les modifications seront publiées sur cette page avec une
|
||||
nouvelle date de mise à jour. Nous vous encourageons à consulter
|
||||
régulièrement cette page.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">10. Contact</h2>
|
||||
<p class="text-slate-600">
|
||||
Pour toute question concernant cette politique de confidentialité,
|
||||
contactez-nous :<br />
|
||||
Email : <a
|
||||
href="mailto:primadopu.ai@gmail.com"
|
||||
class="text-indigo-600 hover:underline">primadopu.ai@gmail.com</a
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
288
src/pages/legal/terms.astro
Normal file
@@ -0,0 +1,288 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
---
|
||||
|
||||
<Layout
|
||||
title="Conditions Générales d'Utilisation - PrimaDopu | CGU"
|
||||
description="Conditions générales d'utilisation de PrimaDopu. Découvrez les règles d'utilisation du service de visualisation avant/après par intelligence artificielle."
|
||||
keywords="CGU primadopu, conditions utilisation, règles service, termes légaux"
|
||||
>
|
||||
<div class="max-w-4xl mx-auto py-12">
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center gap-2 text-indigo-600 hover:text-indigo-700 mb-8"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
Retour à l'accueil
|
||||
</a>
|
||||
|
||||
<h1 class="text-4xl font-bold text-slate-900 mb-8">
|
||||
Conditions Générales d'Utilisation
|
||||
</h1>
|
||||
|
||||
<div class="prose prose-slate max-w-none">
|
||||
<p class="text-slate-600 mb-8">
|
||||
Dernière mise à jour : {
|
||||
new Date().toLocaleDateString("fr-FR", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
</p>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">1. Objet</h2>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Les présentes Conditions Générales d'Utilisation (CGU) régissent
|
||||
l'utilisation du service PrimaDopu, accessible à l'adresse
|
||||
primadopu.fr (ci-après "le Service").
|
||||
</p>
|
||||
<p class="text-slate-600">
|
||||
En créant un compte ou en utilisant le Service, vous acceptez sans
|
||||
réserve les présentes CGU.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
2. Description du Service
|
||||
</h2>
|
||||
<p class="text-slate-600 mb-4">
|
||||
PrimaDopu est un service de visualisation de projets de rénovation
|
||||
utilisant l'intelligence artificielle. Le Service permet aux
|
||||
utilisateurs de :
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-slate-600 space-y-2">
|
||||
<li>Télécharger des photos d'intérieur</li>
|
||||
<li>Créer une bibliothèque de matériaux et textures de référence</li>
|
||||
<li>
|
||||
Générer des visualisations "avant/après" avec les matériaux choisis
|
||||
</li>
|
||||
<li>Consulter l'historique des générations effectuées</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
3. Inscription et compte utilisateur
|
||||
</h2>
|
||||
|
||||
<h3 class="text-xl font-medium text-slate-800 mb-3">
|
||||
3.1 Création de compte
|
||||
</h3>
|
||||
<p class="text-slate-600 mb-4">
|
||||
L'utilisation du Service nécessite la création d'un compte. Vous devez
|
||||
fournir des informations exactes et à jour. Vous êtes responsable de
|
||||
la confidentialité de vos identifiants de connexion.
|
||||
</p>
|
||||
|
||||
<h3 class="text-xl font-medium text-slate-800 mb-3">
|
||||
3.2 Conditions d'âge
|
||||
</h3>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Vous devez être âgé d'au moins 16 ans pour utiliser le Service. Les
|
||||
mineurs doivent obtenir l'autorisation de leurs parents ou tuteurs
|
||||
légaux.
|
||||
</p>
|
||||
|
||||
<h3 class="text-xl font-medium text-slate-800 mb-3">
|
||||
3.3 Sécurité du compte
|
||||
</h3>
|
||||
<p class="text-slate-600">
|
||||
Vous êtes seul responsable des activités effectuées depuis votre
|
||||
compte. En cas d'utilisation non autorisée, vous devez nous en
|
||||
informer immédiatement.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
4. Utilisation du Service
|
||||
</h2>
|
||||
|
||||
<h3 class="text-xl font-medium text-slate-800 mb-3">
|
||||
4.1 Usage autorisé
|
||||
</h3>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Le Service est destiné à un usage personnel ou professionnel légal.
|
||||
Vous vous engagez à :
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-slate-600 mb-4 space-y-2">
|
||||
<li>Utiliser le Service conformément aux lois en vigueur</li>
|
||||
<li>
|
||||
Ne pas télécharger de contenus illicites, offensants ou portant
|
||||
atteinte aux droits de tiers
|
||||
</li>
|
||||
<li>Ne pas tenter de contourner les mesures de sécurité</li>
|
||||
<li>Ne pas utiliser le Service à des fins frauduleuses</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="text-xl font-medium text-slate-800 mb-3">
|
||||
4.2 Contenus interdits
|
||||
</h3>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Il est strictement interdit de télécharger :
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-slate-600 space-y-2">
|
||||
<li>
|
||||
Des contenus à caractère pornographique ou sexuellement explicite
|
||||
</li>
|
||||
<li>Des contenus violents, haineux ou discriminatoires</li>
|
||||
<li>Des contenus portant atteinte à la vie privée d'autrui</li>
|
||||
<li>Des contenus protégés par le droit d'auteur sans autorisation</li>
|
||||
<li>Tout contenu illégal</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
5. Propriété intellectuelle
|
||||
</h2>
|
||||
|
||||
<h3 class="text-xl font-medium text-slate-800 mb-3">
|
||||
5.1 Vos contenus
|
||||
</h3>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Vous conservez tous les droits sur les photos et images que vous
|
||||
téléchargez. En utilisant le Service, vous nous accordez une licence
|
||||
limitée pour traiter vos contenus afin de fournir le Service.
|
||||
</p>
|
||||
|
||||
<h3 class="text-xl font-medium text-slate-800 mb-3">
|
||||
5.2 Images générées
|
||||
</h3>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Les images générées par le Service peuvent être utilisées librement
|
||||
pour un usage personnel ou professionnel. Cependant, nous ne
|
||||
garantissons pas que ces images sont libres de tout droit de tiers.
|
||||
</p>
|
||||
|
||||
<h3 class="text-xl font-medium text-slate-800 mb-3">
|
||||
5.3 Notre propriété
|
||||
</h3>
|
||||
<p class="text-slate-600">
|
||||
Le Service, son design, son code, ses logos et sa marque sont notre
|
||||
propriété exclusive. Toute reproduction non autorisée est interdite.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
6. Limitations de responsabilité
|
||||
</h2>
|
||||
|
||||
<h3 class="text-xl font-medium text-slate-800 mb-3">
|
||||
6.1 Nature du Service
|
||||
</h3>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Le Service utilise l'intelligence artificielle pour générer des
|
||||
visualisations. Ces images sont des approximations et ne constituent
|
||||
pas des rendus architecturaux professionnels ou des engagements sur le
|
||||
résultat final de travaux.
|
||||
</p>
|
||||
|
||||
<h3 class="text-xl font-medium text-slate-800 mb-3">
|
||||
6.2 Disponibilité
|
||||
</h3>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Nous nous efforçons d'assurer la disponibilité du Service 24h/24, mais
|
||||
ne pouvons garantir une disponibilité permanente. Des interruptions
|
||||
pour maintenance peuvent survenir.
|
||||
</p>
|
||||
|
||||
<h3 class="text-xl font-medium text-slate-800 mb-3">6.3 Exclusions</h3>
|
||||
<p class="text-slate-600">
|
||||
Dans les limites permises par la loi, nous déclinons toute
|
||||
responsabilité pour les dommages indirects, pertes de données ou
|
||||
préjudices découlant de l'utilisation du Service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
7. Tarification
|
||||
</h2>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Le Service propose différentes formules d'utilisation. Les tarifs en
|
||||
vigueur sont affichés sur notre site. Nous nous réservons le droit de
|
||||
modifier nos tarifs à tout moment, les modifications prenant effet
|
||||
pour les nouvelles souscriptions uniquement.
|
||||
</p>
|
||||
<p class="text-slate-600">
|
||||
Les paiements sont traités de manière sécurisée par nos partenaires de
|
||||
paiement.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
8. Résiliation
|
||||
</h2>
|
||||
|
||||
<h3 class="text-xl font-medium text-slate-800 mb-3">
|
||||
8.1 Par l'utilisateur
|
||||
</h3>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Vous pouvez supprimer votre compte à tout moment depuis les paramètres
|
||||
de votre profil ou en nous contactant.
|
||||
</p>
|
||||
|
||||
<h3 class="text-xl font-medium text-slate-800 mb-3">8.2 Par nous</h3>
|
||||
<p class="text-slate-600">
|
||||
Nous nous réservons le droit de suspendre ou résilier votre compte en
|
||||
cas de violation des présentes CGU, sans préavis ni remboursement.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
9. Modifications des CGU
|
||||
</h2>
|
||||
<p class="text-slate-600">
|
||||
Nous pouvons modifier ces CGU à tout moment. Les modifications seront
|
||||
notifiées par email ou via le Service. La poursuite de l'utilisation
|
||||
du Service après modification vaut acceptation des nouvelles
|
||||
conditions.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mb-12">
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">
|
||||
10. Droit applicable et litiges
|
||||
</h2>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Les présentes CGU sont régies par le droit français. En cas de litige,
|
||||
les parties s'engagent à rechercher une solution amiable avant toute
|
||||
action judiciaire.
|
||||
</p>
|
||||
<p class="text-slate-600">
|
||||
À défaut d'accord amiable, les tribunaux français seront seuls
|
||||
compétents.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-2xl font-semibold text-slate-900 mb-4">11. Contact</h2>
|
||||
<p class="text-slate-600">
|
||||
Pour toute question concernant ces CGU :<br />
|
||||
Email : <a
|
||||
href="mailto:primadopu.ai@gmail.com"
|
||||
class="text-indigo-600 hover:underline">primadopu.ai@gmail.com</a
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
673
src/pages/library.astro
Normal file
@@ -0,0 +1,673 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import { SignedIn, SignedOut } from "@clerk/astro/components";
|
||||
import {
|
||||
getUserReferences,
|
||||
createReference,
|
||||
deleteReference,
|
||||
getUserFolders,
|
||||
createFolder,
|
||||
deleteFolder,
|
||||
updateFolder,
|
||||
moveReferenceToFolder,
|
||||
} from "../lib/db";
|
||||
import { saveImage, deleteImage } from "../lib/storage";
|
||||
|
||||
const auth = Astro.locals.auth();
|
||||
const userId = auth.userId;
|
||||
|
||||
if (!userId) {
|
||||
return Astro.redirect("/");
|
||||
}
|
||||
|
||||
// Fonction helper pour construire l'URL d'image
|
||||
function getImageUrl(path: string): string {
|
||||
if (!path) return '';
|
||||
let cleanPath = path.replace('/uploads/', '/').replace('/uploads', '');
|
||||
if (cleanPath.startsWith('/')) cleanPath = cleanPath.slice(1);
|
||||
return `/api/images/${cleanPath}`;
|
||||
}
|
||||
|
||||
// Récupérer le dossier sélectionné depuis l'URL
|
||||
const url = new URL(Astro.request.url);
|
||||
const selectedFolderId = url.searchParams.get("folder");
|
||||
|
||||
// Gestion des actions POST
|
||||
if (Astro.request.method === "POST") {
|
||||
const formData = await Astro.request.formData();
|
||||
const action = formData.get("action");
|
||||
|
||||
if (action === "add") {
|
||||
const file = formData.get("image") as File;
|
||||
const name = formData.get("name") as string;
|
||||
const folderId = formData.get("folder_id") as string;
|
||||
|
||||
if (file && file.size > 0) {
|
||||
const imagePath = await saveImage(file, userId, "references");
|
||||
createReference(userId, imagePath, name || undefined, folderId || undefined);
|
||||
}
|
||||
} else if (action === "delete") {
|
||||
const id = formData.get("id") as string;
|
||||
const ref = await import("../lib/db").then((m) => m.getReference(id));
|
||||
if (ref && ref.user_id === userId) {
|
||||
await deleteImage(ref.image_path);
|
||||
deleteReference(id, userId);
|
||||
}
|
||||
} else if (action === "create_folder") {
|
||||
const name = formData.get("name") as string;
|
||||
const color = formData.get("color") as string;
|
||||
if (name) {
|
||||
createFolder(userId, name, color || undefined);
|
||||
}
|
||||
} else if (action === "delete_folder") {
|
||||
const id = formData.get("id") as string;
|
||||
if (id) {
|
||||
deleteFolder(id, userId);
|
||||
}
|
||||
} else if (action === "update_folder") {
|
||||
const id = formData.get("id") as string;
|
||||
const name = formData.get("name") as string;
|
||||
const color = formData.get("color") as string;
|
||||
if (id && name) {
|
||||
updateFolder(id, userId, name, color || undefined);
|
||||
}
|
||||
} else if (action === "move_reference") {
|
||||
const refId = formData.get("ref_id") as string;
|
||||
const folderId = formData.get("folder_id") as string;
|
||||
if (refId) {
|
||||
moveReferenceToFolder(refId, userId, folderId || null);
|
||||
}
|
||||
}
|
||||
|
||||
// Redirection avec cache-buster
|
||||
const redirectUrl = selectedFolderId
|
||||
? `/library?folder=${selectedFolderId}&t=${Date.now()}`
|
||||
: `/library?t=${Date.now()}`;
|
||||
return Astro.redirect(redirectUrl);
|
||||
}
|
||||
|
||||
const folders = getUserFolders(userId);
|
||||
const references = selectedFolderId === "none"
|
||||
? getUserReferences(userId, null)
|
||||
: selectedFolderId
|
||||
? getUserReferences(userId, selectedFolderId)
|
||||
: getUserReferences(userId);
|
||||
|
||||
// Compter les références par dossier
|
||||
const allRefs = getUserReferences(userId);
|
||||
const refsWithoutFolder = allRefs.filter(r => !r.folder_id).length;
|
||||
const refCountByFolder: Record<string, number> = {};
|
||||
folders.forEach(f => {
|
||||
refCountByFolder[f.id] = allRefs.filter(r => r.folder_id === f.id).length;
|
||||
});
|
||||
|
||||
const folderColors = [
|
||||
{ name: "Indigo", value: "#6366f1" },
|
||||
{ name: "Rose", value: "#ec4899" },
|
||||
{ name: "Vert", value: "#22c55e" },
|
||||
{ name: "Orange", value: "#f97316" },
|
||||
{ name: "Bleu", value: "#3b82f6" },
|
||||
{ name: "Violet", value: "#8b5cf6" },
|
||||
{ name: "Rouge", value: "#ef4444" },
|
||||
{ name: "Cyan", value: "#06b6d4" },
|
||||
];
|
||||
|
||||
const currentFolder = selectedFolderId && selectedFolderId !== "none"
|
||||
? folders.find(f => f.id === selectedFolderId)
|
||||
: null;
|
||||
---
|
||||
|
||||
<Layout title="Bibliothèque">
|
||||
<SignedOut>
|
||||
<div class="text-center py-20">
|
||||
<p class="text-slate-600">
|
||||
Veuillez vous connecter pour accéder à cette page.
|
||||
</p>
|
||||
</div>
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<div class="max-w-7xl mx-auto p-4">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold">Ma Bibliothèque</h1>
|
||||
<p class="text-slate-600 mt-1 text-sm sm:text-base">
|
||||
Organisez vos références de matériaux et textures
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
id="addFolderBtn"
|
||||
class="flex-1 sm:flex-none px-3 sm:px-4 py-2 border border-slate-300 text-slate-700 rounded-lg font-medium hover:bg-slate-50 transition-colors flex items-center justify-center gap-2 text-sm sm:text-base"
|
||||
>
|
||||
<span>📁</span> <span class="hidden sm:inline">Nouveau</span> dossier
|
||||
</button>
|
||||
<button
|
||||
id="addReferenceBtn"
|
||||
class="flex-1 sm:flex-none px-3 sm:px-4 py-2 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition-colors flex items-center justify-center gap-2 text-sm sm:text-base"
|
||||
>
|
||||
<span>+</span> <span class="hidden sm:inline">Ajouter une</span> référence
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: Tabs horizontaux pour les dossiers -->
|
||||
<div class="lg:hidden mb-4 overflow-x-auto scrollbar-hide">
|
||||
<div class="flex gap-2 pb-2">
|
||||
<a
|
||||
href="/library"
|
||||
class={`flex-shrink-0 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${!selectedFolderId ? 'bg-indigo-100 text-indigo-700' : 'bg-slate-100 text-slate-600'}`}
|
||||
>
|
||||
📚 Tout ({allRefs.length})
|
||||
</a>
|
||||
<a
|
||||
href="/library?folder=none"
|
||||
class={`flex-shrink-0 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${selectedFolderId === 'none' ? 'bg-indigo-100 text-indigo-700' : 'bg-slate-100 text-slate-600'}`}
|
||||
>
|
||||
📄 Sans dossier ({refsWithoutFolder})
|
||||
</a>
|
||||
{folders.map((folder) => (
|
||||
<a
|
||||
href={`/library?folder=${folder.id}`}
|
||||
class={`flex-shrink-0 px-3 py-2 rounded-lg text-sm font-medium transition-colors flex items-center gap-1.5 ${selectedFolderId === folder.id ? 'bg-indigo-100 text-indigo-700' : 'bg-slate-100 text-slate-600'}`}
|
||||
>
|
||||
<span
|
||||
class="w-2 h-2 rounded-sm"
|
||||
style={`background-color: ${folder.color}`}
|
||||
></span>
|
||||
{folder.name} ({refCountByFolder[folder.id] || 0})
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6">
|
||||
<!-- Sidebar des dossiers (desktop uniquement) -->
|
||||
<div class="w-64 flex-shrink-0 hidden lg:block">
|
||||
<div class="bg-white rounded-xl border border-slate-200 p-4 sticky top-4">
|
||||
<h3 class="font-semibold text-slate-800 mb-3 flex items-center gap-2">
|
||||
<span>📂</span> Dossiers
|
||||
</h3>
|
||||
<nav class="space-y-1">
|
||||
<a
|
||||
href="/library"
|
||||
class={`flex items-center justify-between px-3 py-2 rounded-lg transition-colors ${!selectedFolderId ? 'bg-indigo-50 text-indigo-700' : 'hover:bg-slate-50 text-slate-700'}`}
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<span>📚</span> Tout
|
||||
</span>
|
||||
<span class="text-xs bg-slate-100 px-2 py-0.5 rounded-full">{allRefs.length}</span>
|
||||
</a>
|
||||
<a
|
||||
href="/library?folder=none"
|
||||
class={`flex items-center justify-between px-3 py-2 rounded-lg transition-colors ${selectedFolderId === 'none' ? 'bg-indigo-50 text-indigo-700' : 'hover:bg-slate-50 text-slate-700'}`}
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<span>📄</span> Sans dossier
|
||||
</span>
|
||||
<span class="text-xs bg-slate-100 px-2 py-0.5 rounded-full">{refsWithoutFolder}</span>
|
||||
</a>
|
||||
|
||||
{folders.length > 0 && <hr class="my-2 border-slate-200" />}
|
||||
|
||||
{folders.map((folder) => (
|
||||
<div class="group relative">
|
||||
<a
|
||||
href={`/library?folder=${folder.id}`}
|
||||
class={`flex items-center justify-between px-3 py-2 rounded-lg transition-colors ${selectedFolderId === folder.id ? 'bg-indigo-50 text-indigo-700' : 'hover:bg-slate-50 text-slate-700'}`}
|
||||
>
|
||||
<span class="flex items-center gap-2 truncate">
|
||||
<span
|
||||
class="w-3 h-3 rounded-sm flex-shrink-0"
|
||||
style={`background-color: ${folder.color}`}
|
||||
></span>
|
||||
<span class="truncate">{folder.name}</span>
|
||||
</span>
|
||||
<span class="text-xs bg-slate-100 px-2 py-0.5 rounded-full">{refCountByFolder[folder.id] || 0}</span>
|
||||
</a>
|
||||
<div class="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
<button
|
||||
class="edit-folder-btn p-1 hover:bg-slate-200 rounded text-xs"
|
||||
data-folder-id={folder.id}
|
||||
data-folder-name={folder.name}
|
||||
data-folder-color={folder.color}
|
||||
title="Modifier"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contenu principal -->
|
||||
<div class="flex-1">
|
||||
{currentFolder && (
|
||||
<div class="flex items-center gap-3 mb-4 pb-4 border-b border-slate-200">
|
||||
<span
|
||||
class="w-4 h-4 rounded-sm"
|
||||
style={`background-color: ${currentFolder.color}`}
|
||||
></span>
|
||||
<h2 class="text-xl font-semibold">{currentFolder.name}</h2>
|
||||
<span class="text-slate-500 text-sm">({references.length} références)</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{references.length === 0 ? (
|
||||
<div class="text-center py-20 bg-white rounded-xl border border-slate-200">
|
||||
<div class="text-6xl mb-4">📚</div>
|
||||
<h3 class="text-xl font-medium text-slate-800 mb-2">
|
||||
{selectedFolderId ? "Aucune référence dans ce dossier" : "Aucune référence"}
|
||||
</h3>
|
||||
<p class="text-slate-600 mb-6">
|
||||
{selectedFolderId
|
||||
? "Ajoutez des références ou déplacez-en depuis un autre dossier"
|
||||
: "Commencez par ajouter vos premières références de matériaux"}
|
||||
</p>
|
||||
<button
|
||||
id="addReferenceBtnEmpty"
|
||||
class="px-6 py-3 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
Ajouter une référence
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">
|
||||
{references.map((ref) => (
|
||||
<div
|
||||
class="bg-white rounded-xl border border-slate-200 overflow-hidden group relative"
|
||||
data-ref-id={ref.id}
|
||||
>
|
||||
<img
|
||||
src={getImageUrl(ref.image_path)}
|
||||
alt={ref.name || "Référence"}
|
||||
class="w-full aspect-square object-cover"
|
||||
onerror="this.onerror=null; this.src='/placeholder.png'; this.alt='Image non trouvée';"
|
||||
/>
|
||||
<div class="p-3">
|
||||
<p class="font-medium text-slate-800 truncate">
|
||||
{ref.name || "Sans nom"}
|
||||
</p>
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<p class="text-xs text-slate-500">
|
||||
{new Date(ref.created_at).toLocaleDateString("fr-FR")}
|
||||
</p>
|
||||
{ref.folder_id && folders.find(f => f.id === ref.folder_id) && (
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
style={`background-color: ${folders.find(f => f.id === ref.folder_id)?.color}`}
|
||||
title={folders.find(f => f.id === ref.folder_id)?.name}
|
||||
></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions au survol (desktop) et toujours visibles (mobile) -->
|
||||
<div class="absolute top-2 right-2 flex gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
class="move-ref-btn p-2 bg-white/90 backdrop-blur text-slate-700 rounded-lg hover:bg-white transition-colors shadow-sm"
|
||||
data-ref-id={ref.id}
|
||||
title="Déplacer vers un dossier"
|
||||
>
|
||||
📁
|
||||
</button>
|
||||
<form method="POST" class="inline">
|
||||
<input type="hidden" name="action" value="delete" />
|
||||
<input type="hidden" name="id" value={ref.id} />
|
||||
<button
|
||||
type="submit"
|
||||
onclick="return confirm('Supprimer cette référence ?')"
|
||||
class="p-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors shadow-sm"
|
||||
title="Supprimer"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal d'ajout de référence -->
|
||||
<div
|
||||
id="addModal"
|
||||
class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50"
|
||||
>
|
||||
<div class="bg-white rounded-xl p-6 max-w-md w-full mx-4">
|
||||
<h2 class="text-xl font-semibold mb-4">Ajouter une référence</h2>
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="action" value="add" />
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-slate-700 mb-2">
|
||||
Nom et description
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Ex: Parquet chêne clair, Carrelage métro blanc brillant..."
|
||||
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
/>
|
||||
<p class="text-xs text-slate-500 mt-1">
|
||||
💡 Décrivez le matériau pour aider l'IA à mieux l'appliquer
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-slate-700 mb-2">
|
||||
Dossier
|
||||
</label>
|
||||
<select
|
||||
name="folder_id"
|
||||
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Aucun dossier</option>
|
||||
{folders.map((folder) => (
|
||||
<option value={folder.id} selected={selectedFolderId === folder.id}>
|
||||
{folder.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-slate-700 mb-2">
|
||||
Image *
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
name="image"
|
||||
accept="image/*"
|
||||
required
|
||||
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:bg-indigo-50 file:text-indigo-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
id="cancelAddBtn"
|
||||
class="flex-1 px-4 py-2 border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de création de dossier -->
|
||||
<div
|
||||
id="folderModal"
|
||||
class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50"
|
||||
>
|
||||
<div class="bg-white rounded-xl p-6 max-w-md w-full mx-4">
|
||||
<h2 class="text-xl font-semibold mb-4" id="folderModalTitle">Nouveau dossier</h2>
|
||||
<form method="POST" id="folderForm">
|
||||
<input type="hidden" name="action" value="create_folder" id="folderAction" />
|
||||
<input type="hidden" name="id" value="" id="folderEditId" />
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-slate-700 mb-2">
|
||||
Nom du dossier *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="folderNameInput"
|
||||
placeholder="Ex: Sols, Murs, Cuisine..."
|
||||
required
|
||||
class="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-slate-700 mb-2">
|
||||
Couleur
|
||||
</label>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{folderColors.map((color, index) => (
|
||||
<label class="cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="color"
|
||||
value={color.value}
|
||||
checked={index === 0}
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<span
|
||||
class="block w-8 h-8 rounded-lg border-2 border-transparent peer-checked:border-slate-800 peer-checked:scale-110 transition-all"
|
||||
style={`background-color: ${color.value}`}
|
||||
title={color.name}
|
||||
></span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
id="cancelFolderBtn"
|
||||
class="flex-1 px-4 py-2 border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="deleteFolderBtn"
|
||||
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors hidden"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
id="folderSubmitBtn"
|
||||
>
|
||||
Créer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<!-- Formulaire de suppression séparé -->
|
||||
<form method="POST" id="deleteFolderForm" class="hidden">
|
||||
<input type="hidden" name="action" value="delete_folder" />
|
||||
<input type="hidden" name="id" value="" id="deleteFolderIdInput" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de déplacement -->
|
||||
<div
|
||||
id="moveModal"
|
||||
class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50"
|
||||
>
|
||||
<div class="bg-white rounded-xl p-6 max-w-sm w-full mx-4">
|
||||
<h2 class="text-xl font-semibold mb-4">Déplacer vers</h2>
|
||||
<form method="POST" id="moveForm">
|
||||
<input type="hidden" name="action" value="move_reference" />
|
||||
<input type="hidden" name="ref_id" value="" id="moveRefId" />
|
||||
|
||||
<div class="space-y-2 mb-6 max-h-64 overflow-y-auto">
|
||||
<label class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 cursor-pointer border border-slate-200">
|
||||
<input type="radio" name="folder_id" value="" class="text-indigo-600" />
|
||||
<span class="flex items-center gap-2">
|
||||
<span>📄</span> Sans dossier
|
||||
</span>
|
||||
</label>
|
||||
{folders.map((folder) => (
|
||||
<label class="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 cursor-pointer border border-slate-200">
|
||||
<input type="radio" name="folder_id" value={folder.id} class="text-indigo-600" />
|
||||
<span class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-3 h-3 rounded-sm"
|
||||
style={`background-color: ${folder.color}`}
|
||||
></span>
|
||||
{folder.name}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
id="cancelMoveBtn"
|
||||
class="flex-1 px-4 py-2 border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
Déplacer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</SignedIn>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
// Modal d'ajout de référence
|
||||
const addModal = document.getElementById("addModal");
|
||||
const addReferenceBtn = document.getElementById("addReferenceBtn");
|
||||
const addReferenceBtnEmpty = document.getElementById("addReferenceBtnEmpty");
|
||||
const cancelAddBtn = document.getElementById("cancelAddBtn");
|
||||
|
||||
function openAddModal() {
|
||||
addModal?.classList.remove("hidden");
|
||||
addModal?.classList.add("flex");
|
||||
}
|
||||
|
||||
function closeAddModal() {
|
||||
addModal?.classList.add("hidden");
|
||||
addModal?.classList.remove("flex");
|
||||
}
|
||||
|
||||
addReferenceBtn?.addEventListener("click", openAddModal);
|
||||
addReferenceBtnEmpty?.addEventListener("click", openAddModal);
|
||||
cancelAddBtn?.addEventListener("click", closeAddModal);
|
||||
addModal?.addEventListener("click", (e) => {
|
||||
if (e.target === addModal) closeAddModal();
|
||||
});
|
||||
|
||||
// Modal de dossier
|
||||
const folderModal = document.getElementById("folderModal");
|
||||
const addFolderBtn = document.getElementById("addFolderBtn");
|
||||
const cancelFolderBtn = document.getElementById("cancelFolderBtn");
|
||||
const folderModalTitle = document.getElementById("folderModalTitle");
|
||||
const folderAction = document.getElementById("folderAction") as HTMLInputElement;
|
||||
const folderEditId = document.getElementById("folderEditId") as HTMLInputElement;
|
||||
const folderNameInput = document.getElementById("folderNameInput") as HTMLInputElement;
|
||||
const folderSubmitBtn = document.getElementById("folderSubmitBtn");
|
||||
const deleteFolderBtn = document.getElementById("deleteFolderBtn");
|
||||
const deleteFolderForm = document.getElementById("deleteFolderForm") as HTMLFormElement;
|
||||
const deleteFolderIdInput = document.getElementById("deleteFolderIdInput") as HTMLInputElement;
|
||||
|
||||
function openFolderModal(editMode = false, folder?: { id: string; name: string; color: string }) {
|
||||
if (editMode && folder) {
|
||||
folderModalTitle!.textContent = "Modifier le dossier";
|
||||
folderAction!.value = "update_folder";
|
||||
folderEditId!.value = folder.id;
|
||||
folderNameInput!.value = folder.name;
|
||||
folderSubmitBtn!.textContent = "Enregistrer";
|
||||
deleteFolderBtn?.classList.remove("hidden");
|
||||
deleteFolderIdInput!.value = folder.id;
|
||||
|
||||
// Sélectionner la bonne couleur
|
||||
const colorInputs = document.querySelectorAll('input[name="color"]') as NodeListOf<HTMLInputElement>;
|
||||
colorInputs.forEach(input => {
|
||||
input.checked = input.value === folder.color;
|
||||
});
|
||||
} else {
|
||||
folderModalTitle!.textContent = "Nouveau dossier";
|
||||
folderAction!.value = "create_folder";
|
||||
folderEditId!.value = "";
|
||||
folderNameInput!.value = "";
|
||||
folderSubmitBtn!.textContent = "Créer";
|
||||
deleteFolderBtn?.classList.add("hidden");
|
||||
|
||||
// Réinitialiser la couleur
|
||||
const colorInputs = document.querySelectorAll('input[name="color"]') as NodeListOf<HTMLInputElement>;
|
||||
if (colorInputs[0]) colorInputs[0].checked = true;
|
||||
}
|
||||
|
||||
folderModal?.classList.remove("hidden");
|
||||
folderModal?.classList.add("flex");
|
||||
folderNameInput?.focus();
|
||||
}
|
||||
|
||||
function closeFolderModal() {
|
||||
folderModal?.classList.add("hidden");
|
||||
folderModal?.classList.remove("flex");
|
||||
}
|
||||
|
||||
addFolderBtn?.addEventListener("click", () => openFolderModal());
|
||||
cancelFolderBtn?.addEventListener("click", closeFolderModal);
|
||||
folderModal?.addEventListener("click", (e) => {
|
||||
if (e.target === folderModal) closeFolderModal();
|
||||
});
|
||||
|
||||
deleteFolderBtn?.addEventListener("click", () => {
|
||||
if (confirm("Supprimer ce dossier ? Les références ne seront pas supprimées.")) {
|
||||
deleteFolderForm?.submit();
|
||||
}
|
||||
});
|
||||
|
||||
// Boutons d'édition de dossier
|
||||
document.querySelectorAll(".edit-folder-btn").forEach(btn => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
openFolderModal(true, {
|
||||
id: target.dataset.folderId!,
|
||||
name: target.dataset.folderName!,
|
||||
color: target.dataset.folderColor!,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Modal de déplacement
|
||||
const moveModal = document.getElementById("moveModal");
|
||||
const cancelMoveBtn = document.getElementById("cancelMoveBtn");
|
||||
const moveRefId = document.getElementById("moveRefId") as HTMLInputElement;
|
||||
|
||||
function openMoveModal(refId: string) {
|
||||
moveRefId.value = refId;
|
||||
moveModal?.classList.remove("hidden");
|
||||
moveModal?.classList.add("flex");
|
||||
}
|
||||
|
||||
function closeMoveModal() {
|
||||
moveModal?.classList.add("hidden");
|
||||
moveModal?.classList.remove("flex");
|
||||
}
|
||||
|
||||
document.querySelectorAll(".move-ref-btn").forEach(btn => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
openMoveModal(target.dataset.refId!);
|
||||
});
|
||||
});
|
||||
|
||||
cancelMoveBtn?.addEventListener("click", closeMoveModal);
|
||||
moveModal?.addEventListener("click", (e) => {
|
||||
if (e.target === moveModal) closeMoveModal();
|
||||
});
|
||||
</script>
|
||||
286
src/pages/no-credits.astro
Normal file
@@ -0,0 +1,286 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import { SignedIn, SignedOut } from "@clerk/astro/components";
|
||||
import { getUserCredits, getCreditStats, type PlanType } from "../lib/db";
|
||||
import { getUserPlan } from "../lib/plans";
|
||||
import { getSubscription, getCreditsBalance } from "../lib/subscriptions";
|
||||
|
||||
const auth = Astro.locals.auth();
|
||||
const userId = auth.userId;
|
||||
|
||||
if (!userId) {
|
||||
return Astro.redirect("/sign-in");
|
||||
}
|
||||
|
||||
// Déterminer le plan de l'utilisateur
|
||||
const userPlanInfo = getUserPlan(userId);
|
||||
const isAdmin = userPlanInfo.isAdmin === true;
|
||||
|
||||
// Les admins ne devraient jamais voir cette page
|
||||
if (isAdmin) {
|
||||
return Astro.redirect("/generate");
|
||||
}
|
||||
|
||||
const userPlan = userPlanInfo.planType;
|
||||
const isPaid = userPlanInfo.isPaid;
|
||||
|
||||
// Récupérer les infos Stripe
|
||||
const subscription = getSubscription(userId);
|
||||
const bonusCredits = getCreditsBalance(userId);
|
||||
|
||||
// Passer les crédits bonus aux stats
|
||||
const stats = getCreditStats(userId, userPlan, false, bonusCredits);
|
||||
|
||||
// Déterminer les suggestions selon le plan actuel
|
||||
// Free → Proposer abonnement
|
||||
// Standard → Proposer upgrade Pro OU acheter crédits
|
||||
// Pro → Proposer acheter crédits uniquement
|
||||
---
|
||||
|
||||
<Layout title="Plus de crédits - PrimaDopu">
|
||||
<SignedOut>
|
||||
<div class="text-center py-20">
|
||||
<p class="text-slate-600">
|
||||
Veuillez vous connecter pour accéder à cette page.
|
||||
</p>
|
||||
</div>
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<div class="max-w-3xl mx-auto text-center">
|
||||
<!-- Icône -->
|
||||
<div
|
||||
class="inline-flex items-center justify-center w-24 h-24 bg-orange-100 rounded-full mb-6"
|
||||
>
|
||||
<svg
|
||||
class="w-12 h-12 text-orange-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-bold text-slate-900 mb-4">
|
||||
{userPlan === "free" ? (
|
||||
"Vos 3 générations gratuites sont épuisées"
|
||||
) : userPlan === "pro" ? (
|
||||
"Vous avez utilisé vos 50 générations ce mois"
|
||||
) : (
|
||||
"Vous avez utilisé vos 25 générations ce mois"
|
||||
)}
|
||||
</h1>
|
||||
|
||||
<p class="text-xl text-slate-600 mb-8">
|
||||
{userPlan === "free" ? (
|
||||
"Passez à un abonnement pour continuer à créer des visuels avant/après."
|
||||
) : userPlan === "pro" ? (
|
||||
"Achetez des crédits supplémentaires pour continuer sans attendre le mois prochain."
|
||||
) : (
|
||||
"Passez au plan Pro pour plus de générations, ou achetez des crédits supplémentaires."
|
||||
)}
|
||||
</p>
|
||||
|
||||
<!-- Stats actuelles -->
|
||||
<div class="bg-slate-50 rounded-xl p-6 mb-8">
|
||||
<div class="flex items-center justify-center gap-8 flex-wrap">
|
||||
<div>
|
||||
<div class="text-3xl font-bold text-slate-900">{stats.used}</div>
|
||||
<div class="text-sm text-slate-600">Utilisées</div>
|
||||
</div>
|
||||
<div class="w-px h-12 bg-slate-300 hidden sm:block"></div>
|
||||
<div>
|
||||
<div class="text-3xl font-bold text-slate-900">{stats.limit}</div>
|
||||
<div class="text-sm text-slate-600">Maximum mensuel</div>
|
||||
</div>
|
||||
{bonusCredits > 0 && (
|
||||
<div class="w-px h-12 bg-slate-300 hidden sm:block"></div>
|
||||
<div>
|
||||
<div class="text-3xl font-bold text-amber-600">{bonusCredits}</div>
|
||||
<div class="text-sm text-slate-600">Crédits bonus</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== UTILISATEUR FREE ===== -->
|
||||
{userPlan === "free" && (
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-bold text-slate-900 mb-6">
|
||||
🚀 Choisissez votre abonnement
|
||||
</h2>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<!-- Standard -->
|
||||
<div class="bg-white rounded-xl border-2 border-orange-500 p-6 shadow-lg shadow-orange-100">
|
||||
<div class="text-sm text-orange-600 font-semibold mb-2">
|
||||
Recommandé
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-slate-900 mb-1">Standard</h3>
|
||||
<div class="text-3xl font-bold text-slate-900 mb-2">
|
||||
25<span class="text-lg text-slate-500"> générations/mois</span>
|
||||
</div>
|
||||
<p class="text-slate-600 text-sm mb-4">
|
||||
Parfait pour les créateurs réguliers
|
||||
</p>
|
||||
<a
|
||||
href="/pricing"
|
||||
class="block w-full py-3 px-4 bg-orange-500 text-white rounded-lg font-semibold hover:bg-orange-600 transition-colors text-center"
|
||||
>
|
||||
Voir les tarifs →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Pro -->
|
||||
<div class="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div class="text-sm text-slate-500 font-semibold mb-2">
|
||||
Volume élevé
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-slate-900 mb-1">Pro</h3>
|
||||
<div class="text-3xl font-bold text-slate-900 mb-2">
|
||||
50<span class="text-lg text-slate-500"> générations/mois</span>
|
||||
</div>
|
||||
<p class="text-slate-600 text-sm mb-4">
|
||||
Pour les professionnels exigeants
|
||||
</p>
|
||||
<a
|
||||
href="/pricing"
|
||||
class="block w-full py-3 px-4 border-2 border-slate-800 text-slate-800 rounded-lg font-semibold hover:bg-slate-800 hover:text-white transition-colors text-center"
|
||||
>
|
||||
Voir les tarifs →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- ===== UTILISATEUR STANDARD ===== -->
|
||||
{userPlan === "standard" && (
|
||||
<div class="mb-8">
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<!-- Option 1: Upgrade vers Pro -->
|
||||
<div class="bg-gradient-to-br from-slate-900 to-slate-800 rounded-xl p-6 text-white">
|
||||
<div class="text-sm text-orange-400 font-semibold mb-2">
|
||||
⭐ Recommandé
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2">Passer au Pro</h3>
|
||||
<p class="text-slate-300 text-sm mb-3">
|
||||
Doublez vos générations mensuelles
|
||||
</p>
|
||||
<div class="text-3xl font-bold mb-4">
|
||||
50<span class="text-lg text-slate-400"> /mois</span>
|
||||
</div>
|
||||
<ul class="space-y-2 mb-4 text-sm text-slate-300">
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="text-green-400">✓</span> 50 générations/mois
|
||||
</li>
|
||||
<li class="flex items-center gap-2">
|
||||
<span class="text-green-400">✓</span> Tarif préférentiel crédits
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
id="upgrade-to-pro"
|
||||
class="w-full py-3 px-4 bg-orange-500 text-white rounded-lg font-semibold hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
Passer au Pro →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Option 2: Acheter des crédits -->
|
||||
<div class="bg-white rounded-xl border-2 border-amber-400 p-6">
|
||||
<div class="text-sm text-amber-600 font-semibold mb-2">
|
||||
💎 Solution immédiate
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-slate-900 mb-2">
|
||||
Acheter des crédits
|
||||
</h3>
|
||||
<p class="text-slate-600 text-sm mb-3">
|
||||
Crédits supplémentaires sans expiration
|
||||
</p>
|
||||
<div class="p-3 bg-amber-50 rounded-lg mb-4">
|
||||
<p class="text-amber-800 text-sm font-medium">
|
||||
💡 En tant qu'abonné, vous bénéficiez d'un tarif préférentiel
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/pricing#credits"
|
||||
class="block w-full py-3 px-4 bg-amber-500 text-white rounded-lg font-semibold hover:bg-amber-600 transition-colors text-center"
|
||||
>
|
||||
Acheter des crédits →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- ===== UTILISATEUR PRO ===== -->
|
||||
{userPlan === "pro" && (
|
||||
<div class="mb-8">
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="bg-gradient-to-r from-amber-50 to-yellow-50 border-2 border-amber-400 rounded-xl p-6">
|
||||
<div class="text-3xl mb-4">💎</div>
|
||||
<h3 class="text-xl font-bold text-slate-900 mb-2">
|
||||
Acheter des crédits supplémentaires
|
||||
</h3>
|
||||
<p class="text-slate-600 mb-4">
|
||||
Continuez à créer sans attendre le mois prochain. Vos crédits bonus n'expirent jamais !
|
||||
</p>
|
||||
<div class="p-3 bg-green-50 rounded-lg mb-4 border border-green-200">
|
||||
<p class="text-green-800 text-sm font-medium">
|
||||
✓ En tant qu'abonné Pro, vous bénéficiez du meilleur tarif sur les crédits
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/pricing"
|
||||
class="block w-full py-3 px-4 bg-amber-500 text-white rounded-lg font-semibold hover:bg-amber-600 transition-colors text-center"
|
||||
>
|
||||
Acheter des crédits →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Info renouvellement -->
|
||||
{isPaid && subscription?.current_period_end && (
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-8">
|
||||
<p class="text-blue-800 text-sm">
|
||||
📅 Vos {userPlan === 'pro' ? '50' : '25'} générations mensuelles seront renouvelées le {' '}
|
||||
<strong>
|
||||
{new Date(subscription.current_period_end).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</strong>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Liens -->
|
||||
<div class="flex items-center justify-center gap-6 text-sm">
|
||||
<a href="/pricing" class="text-orange-600 hover:underline font-medium">
|
||||
Voir tous les tarifs →
|
||||
</a>
|
||||
<a href="/history" class="text-slate-600 hover:text-slate-800">
|
||||
Mon historique
|
||||
</a>
|
||||
<a href="/profile" class="text-slate-600 hover:text-slate-800">
|
||||
Mon profil
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</SignedIn>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
// Bouton upgrade vers Pro
|
||||
document.getElementById('upgrade-to-pro')?.addEventListener('click', async () => {
|
||||
// Rediriger vers le pricing avec le toggle sur yearly pour le meilleur tarif
|
||||
window.location.href = '/pricing';
|
||||
});
|
||||
</script>
|
||||
733
src/pages/pricing.astro
Normal file
@@ -0,0 +1,733 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import { SignedIn, SignedOut } from "@clerk/astro/components";
|
||||
import { getUserPlan } from "../lib/plans";
|
||||
import { getSubscription, getCreditsBalance } from "../lib/subscriptions";
|
||||
|
||||
const auth = Astro.locals.auth();
|
||||
const userId = auth.userId;
|
||||
|
||||
// Récupérer le plan actuel si connecté
|
||||
let currentPlan = {
|
||||
planType: "free" as string,
|
||||
isPaid: false,
|
||||
creditsBalance: 0,
|
||||
hasSubscription: false,
|
||||
};
|
||||
|
||||
if (userId) {
|
||||
const userPlanInfo = getUserPlan(userId);
|
||||
const subscription = getSubscription(userId);
|
||||
const creditsBalance = getCreditsBalance(userId);
|
||||
|
||||
currentPlan = {
|
||||
planType: subscription?.plan_type || userPlanInfo.planType,
|
||||
isPaid:
|
||||
userPlanInfo.isPaid ||
|
||||
(subscription?.status === "active" && subscription?.plan_type !== "free"),
|
||||
creditsBalance,
|
||||
hasSubscription: !!subscription?.stripe_subscription_id,
|
||||
};
|
||||
}
|
||||
---
|
||||
|
||||
<Layout
|
||||
title="Tarifs PrimaDopu - Abonnements Home Staging Virtuel & Visualisation IA"
|
||||
description="Découvrez nos offres d'abonnement pour créer des visuels avant/après professionnels avec l'IA. Essai gratuit, plans Standard et Pro. Sans engagement, annulation à tout moment."
|
||||
keywords="tarifs primadopu, prix home staging virtuel, abonnement visualisation IA, coût outil architecte, tarif décoration intérieur IA"
|
||||
disableStickyHeader={true}
|
||||
>
|
||||
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-12">
|
||||
<h1 class="text-4xl md:text-5xl font-bold text-slate-900 mb-4">
|
||||
Tarifs simples et transparents
|
||||
</h1>
|
||||
<p class="text-xl text-slate-600 max-w-2xl mx-auto">
|
||||
Choisissez la formule adaptée à vos besoins. Sans engagement.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Plan actuel (si connecté et abonné) -->
|
||||
<SignedIn>
|
||||
{
|
||||
currentPlan.isPaid && (
|
||||
<div class="mb-8 p-4 bg-gradient-to-r from-orange-50 to-amber-50 rounded-xl border border-orange-200 text-center">
|
||||
<p class="text-orange-800">
|
||||
Vous êtes actuellement sur le plan{" "}
|
||||
<strong class="capitalize">
|
||||
{currentPlan.planType === "pro"
|
||||
? "Pro (50 projets/mois)"
|
||||
: "Standard (25 projets/mois)"}
|
||||
</strong>
|
||||
{currentPlan.creditsBalance > 0 && (
|
||||
<span class="ml-2 bg-orange-500 text-white text-xs px-2 py-1 rounded-full">
|
||||
+{currentPlan.creditsBalance} crédit(s) bonus
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</SignedIn>
|
||||
|
||||
<!-- Toggle Mensuel / Annuel -->
|
||||
<div class="flex justify-center mb-12">
|
||||
<div class="bg-slate-100 p-1.5 rounded-full flex items-center gap-1">
|
||||
<button
|
||||
id="toggle-monthly"
|
||||
class="px-6 py-2.5 rounded-full text-sm font-semibold transition-all billing-toggle"
|
||||
data-billing="monthly"
|
||||
>
|
||||
Mensuel
|
||||
</button>
|
||||
<button
|
||||
id="toggle-yearly"
|
||||
class="px-6 py-2.5 rounded-full text-sm font-semibold transition-all billing-toggle active flex items-center gap-2"
|
||||
data-billing="yearly"
|
||||
>
|
||||
Annuel
|
||||
<span
|
||||
class="bg-green-500 text-white text-xs px-2 py-0.5 rounded-full font-bold"
|
||||
>-25%</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Indicateur de chargement -->
|
||||
<div id="loading-products" class="text-center py-12">
|
||||
<div
|
||||
class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-orange-500"
|
||||
>
|
||||
</div>
|
||||
<p class="mt-4 text-slate-600">Chargement des offres...</p>
|
||||
</div>
|
||||
|
||||
<!-- Grille des abonnements -->
|
||||
<div id="subscriptions-container" class="hidden mb-16">
|
||||
<div
|
||||
id="subscriptions-grid"
|
||||
class="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto"
|
||||
>
|
||||
<!-- Les cartes seront générées dynamiquement -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section crédits supplémentaires -->
|
||||
<div id="credits-section" class="hidden mb-20">
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="text-2xl font-bold text-slate-900 mb-2" id="credits-title">
|
||||
Besoin de plus de générations ?
|
||||
</h2>
|
||||
<p class="text-slate-600">
|
||||
Achetez des crédits supplémentaires valables sans limite de temps.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="credits-grid" class="flex justify-center">
|
||||
<!-- La carte crédits sera générée dynamiquement -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Erreur -->
|
||||
<div id="error-container" class="hidden text-center py-12">
|
||||
<p class="text-red-600 mb-4">Erreur lors du chargement des offres.</p>
|
||||
<button
|
||||
onclick="loadProducts()"
|
||||
class="px-6 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 font-medium"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
<SignedIn>
|
||||
{
|
||||
currentPlan.isPaid && (
|
||||
<div class="mb-8 p-4 bg-gradient-to-r from-orange-50 to-amber-50 rounded-xl border border-orange-200 text-center">
|
||||
<button
|
||||
id="manage-subscription-btn"
|
||||
class="mt-3 text-sm text-orange-600 hover:text-orange-800 underline font-medium"
|
||||
>
|
||||
Gérer mon abonnement →
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</SignedIn>
|
||||
<!-- FAQ -->
|
||||
<div class="bg-white rounded-2xl border border-slate-200 p-8">
|
||||
<h2 class="text-2xl font-bold text-slate-900 mb-6 text-center">
|
||||
Questions fréquentes
|
||||
</h2>
|
||||
<div class="grid md:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-900 mb-2">
|
||||
Qu'est-ce qu'une génération ?
|
||||
</h3>
|
||||
<p class="text-slate-600 text-sm">
|
||||
Une génération = une transformation avant/après. Chaque création
|
||||
utilise un crédit.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-900 mb-2">
|
||||
Les crédits sont-ils reportés ?
|
||||
</h3>
|
||||
<p class="text-slate-600 text-sm">
|
||||
Les crédits mensuels sont remis à zéro chaque mois. Les crédits
|
||||
achetés à l'unité n'expirent jamais.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-900 mb-2">
|
||||
Puis-je annuler à tout moment ?
|
||||
</h3>
|
||||
<p class="text-slate-600 text-sm">
|
||||
Oui, sans engagement. L'abonnement reste actif jusqu'à la fin de la
|
||||
période payée.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-900 mb-2">
|
||||
Quelle est la différence annuel/mensuel ?
|
||||
</h3>
|
||||
<p class="text-slate-600 text-sm">
|
||||
L'abonnement annuel offre 25% de réduction. Vous payez 9 mois pour
|
||||
12 mois d'accès.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.billing-toggle {
|
||||
color: #475569;
|
||||
background-color: transparent;
|
||||
}
|
||||
.billing-toggle.active {
|
||||
background-color: white;
|
||||
color: #0f172a;
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
.plan-card {
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
border-width: 2px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.plan-card.popular {
|
||||
border-color: #f97316;
|
||||
background: linear-gradient(to bottom, #fff7ed, white);
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgb(0 0 0 / 0.25),
|
||||
0 0 0 0 #ffedd5;
|
||||
transform: scale(1.05);
|
||||
z-index: 10;
|
||||
}
|
||||
.plan-card:not(.popular) {
|
||||
border-color: #e2e8f0;
|
||||
background-color: white;
|
||||
}
|
||||
.plan-card:not(.popular):hover {
|
||||
border-color: #fdba74;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script define:vars={{ currentPlan }}>
|
||||
// Variables depuis le serveur
|
||||
const userPlan = currentPlan;
|
||||
|
||||
// État du billing - ANNUEL par défaut
|
||||
let billingPeriod = "yearly";
|
||||
|
||||
// Données des produits
|
||||
let productsData = null;
|
||||
|
||||
// Toggle billing period
|
||||
document.querySelectorAll(".billing-toggle").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
document
|
||||
.querySelectorAll(".billing-toggle")
|
||||
.forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
billingPeriod = btn.dataset.billing;
|
||||
if (productsData) renderSubscriptions(productsData.subscriptions);
|
||||
});
|
||||
});
|
||||
|
||||
async function loadProducts() {
|
||||
const loadingEl = document.getElementById("loading-products");
|
||||
const subsContainer = document.getElementById("subscriptions-container");
|
||||
const creditsSection = document.getElementById("credits-section");
|
||||
const errorContainer = document.getElementById("error-container");
|
||||
|
||||
try {
|
||||
loadingEl?.classList.remove("hidden");
|
||||
subsContainer?.classList.add("hidden");
|
||||
creditsSection?.classList.add("hidden");
|
||||
errorContainer?.classList.add("hidden");
|
||||
|
||||
const response = await fetch("/api/stripe/products");
|
||||
if (!response.ok) throw new Error("Erreur API");
|
||||
|
||||
productsData = await response.json();
|
||||
|
||||
// Afficher les abonnements
|
||||
if (productsData.subscriptions.length > 0) {
|
||||
renderSubscriptions(productsData.subscriptions);
|
||||
subsContainer?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
// Afficher les crédits
|
||||
if (productsData.credits.length > 0) {
|
||||
renderCredits(productsData.credits);
|
||||
creditsSection?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
loadingEl?.classList.add("hidden");
|
||||
} catch (error) {
|
||||
console.error("Erreur chargement produits:", error);
|
||||
loadingEl?.classList.add("hidden");
|
||||
errorContainer?.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function renderSubscriptions(products) {
|
||||
const grid = document.getElementById("subscriptions-grid");
|
||||
grid.innerHTML = "";
|
||||
|
||||
// Trouver les produits
|
||||
const standardProduct = products.find(
|
||||
(p) => p.type === "subscription_25" || p.name.includes("25")
|
||||
);
|
||||
const proProduct = products.find(
|
||||
(p) => p.type === "subscription_50" || p.name.includes("50")
|
||||
);
|
||||
|
||||
// Prix
|
||||
const standardMonthly = standardProduct?.prices.find(
|
||||
(p) => p.interval === "month"
|
||||
);
|
||||
const standardYearly = standardProduct?.prices.find(
|
||||
(p) => p.interval === "year"
|
||||
);
|
||||
const proMonthly = proProduct?.prices.find((p) => p.interval === "month");
|
||||
const proYearly = proProduct?.prices.find((p) => p.interval === "year");
|
||||
|
||||
// Liste complète des fonctionnalités (vert = inclus, gris = non inclus)
|
||||
const features = [
|
||||
{ label: "IA avancée", free: true, standard: true, pro: true },
|
||||
{
|
||||
label: "Résultat en ~3 minutes",
|
||||
free: true,
|
||||
standard: true,
|
||||
pro: true,
|
||||
},
|
||||
{
|
||||
label: "Images haute résolution",
|
||||
free: true,
|
||||
standard: true,
|
||||
pro: true,
|
||||
},
|
||||
{ label: "Historique illimité", free: true, standard: true, pro: true },
|
||||
{
|
||||
label: "Bibliothèque de références",
|
||||
free: true,
|
||||
standard: true,
|
||||
pro: true,
|
||||
},
|
||||
{
|
||||
label: "Crédits -50% (tarif abonné)",
|
||||
free: false,
|
||||
standard: true,
|
||||
pro: true,
|
||||
},
|
||||
{ label: "Support prioritaire", free: false, standard: true, pro: true },
|
||||
{
|
||||
label: "Support pour développement personnalisé",
|
||||
free: false,
|
||||
standard: false,
|
||||
pro: true,
|
||||
},
|
||||
];
|
||||
|
||||
const renderFeature = (label, included, highlight = false) => {
|
||||
if (included) {
|
||||
return `<li class="flex items-center gap-2.5 py-1.5">
|
||||
<svg class="w-5 h-5 text-green-500 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="${highlight ? "font-semibold text-slate-900" : "text-slate-600"}">${label}</span>
|
||||
</li>`;
|
||||
} else {
|
||||
return `<li class="flex items-center gap-2.5 py-1.5">
|
||||
<svg class="w-5 h-5 text-slate-300 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="text-slate-400">${label}</span>
|
||||
</li>`;
|
||||
}
|
||||
};
|
||||
|
||||
const isFreeCurrentPlan = userPlan.planType === "free" && !userPlan.isPaid;
|
||||
const isStandardCurrentPlan = userPlan.planType === "standard";
|
||||
const isProCurrentPlan = userPlan.planType === "pro";
|
||||
|
||||
// Carte GRATUIT
|
||||
const freeCard = document.createElement("div");
|
||||
freeCard.className =
|
||||
"bg-white border-2 border-slate-200 rounded-2xl p-6 flex flex-col h-full";
|
||||
freeCard.innerHTML = `
|
||||
<div class="mb-4">
|
||||
<h3 class="text-xl font-bold text-slate-900">Gratuit</h3>
|
||||
<p class="text-slate-500 text-sm mt-1">Pour découvrir</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-4xl font-bold text-slate-900">0€</span>
|
||||
</div>
|
||||
<p class="text-slate-500 text-sm mt-1">Sans carte bancaire</p>
|
||||
</div>
|
||||
<div class="mb-4 pb-4 border-b border-slate-100">
|
||||
<p class="text-sm font-semibold text-slate-900">✓ 3 générations au total</p>
|
||||
</div>
|
||||
<ul class="space-y-0.5 mb-6 flex-grow text-sm">
|
||||
${features.map((f) => renderFeature(f.label, f.free)).join("")}
|
||||
</ul>
|
||||
${
|
||||
isFreeCurrentPlan
|
||||
? `
|
||||
<div class="py-3 px-4 bg-slate-100 text-slate-600 rounded-xl font-semibold text-center mt-auto">Plan actuel</div>
|
||||
`
|
||||
: `
|
||||
<a href="/sign-up" class="block py-3 px-4 bg-slate-100 text-slate-700 rounded-xl font-semibold text-center hover:bg-slate-200 transition-colors mt-auto">Commencer gratuitement</a>
|
||||
`
|
||||
}
|
||||
`;
|
||||
grid.appendChild(freeCard);
|
||||
|
||||
// Carte STANDARD
|
||||
const standardCard = document.createElement("div");
|
||||
standardCard.className =
|
||||
"bg-white border-2 border-slate-200 rounded-2xl p-6 flex flex-col h-full";
|
||||
const standardPriceMonthly =
|
||||
billingPeriod === "yearly" && standardYearly
|
||||
? Math.round(standardYearly.amount / 12 / 100)
|
||||
: (standardMonthly?.amount || 0) / 100;
|
||||
const standardPriceId =
|
||||
billingPeriod === "yearly" && standardYearly
|
||||
? standardYearly.id
|
||||
: standardMonthly?.id;
|
||||
|
||||
standardCard.innerHTML = `
|
||||
<div class="mb-4">
|
||||
<h3 class="text-xl font-bold text-slate-900">Standard</h3>
|
||||
<p class="text-slate-500 text-sm mt-1">Pour les créateurs réguliers</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-4xl font-bold text-slate-900">${standardPriceMonthly}€</span>
|
||||
<span class="text-slate-500">/mois</span>
|
||||
</div>
|
||||
${
|
||||
billingPeriod === "yearly" && standardYearly
|
||||
? `
|
||||
<p class="text-green-600 text-sm mt-1 font-medium">Facturé ${standardYearly.amountFormatted}/an</p>
|
||||
`
|
||||
: `<p class="text-slate-500 text-sm mt-1">Facturation mensuelle</p>`
|
||||
}
|
||||
</div>
|
||||
<div class="mb-4 pb-4 border-b border-slate-100">
|
||||
<p class="text-sm font-semibold text-slate-900">✓ 25 générations/mois</p>
|
||||
</div>
|
||||
<ul class="space-y-0.5 mb-6 flex-grow text-sm">
|
||||
${features.map((f) => renderFeature(f.label, f.standard)).join("")}
|
||||
</ul>
|
||||
${
|
||||
isStandardCurrentPlan
|
||||
? `
|
||||
<div class="py-3 px-4 bg-orange-100 text-orange-700 rounded-xl font-semibold text-center mt-auto">✓ Votre plan actuel</div>
|
||||
`
|
||||
: `
|
||||
<button onclick="checkout('${standardPriceId}')" class="w-full py-3 px-4 bg-slate-900 text-white rounded-xl font-semibold hover:bg-slate-800 transition-colors mt-auto">${userPlan.isPaid ? "Changer de plan" : "Commencer"}</button>
|
||||
`
|
||||
}
|
||||
`;
|
||||
grid.appendChild(standardCard);
|
||||
|
||||
// Carte PRO (populaire)
|
||||
const proCard = document.createElement("div");
|
||||
proCard.className =
|
||||
"bg-slate-900 border-2 border-orange-500 rounded-2xl p-6 flex flex-col h-full relative";
|
||||
const proPriceMonthly =
|
||||
billingPeriod === "yearly" && proYearly
|
||||
? Math.round(proYearly.amount / 12 / 100)
|
||||
: (proMonthly?.amount || 0) / 100;
|
||||
const proPriceId =
|
||||
billingPeriod === "yearly" && proYearly ? proYearly.id : proMonthly?.id;
|
||||
|
||||
const renderProFeature = (label, included) => {
|
||||
if (included) {
|
||||
return `<li class="flex items-center gap-2.5 py-1.5">
|
||||
<svg class="w-5 h-5 text-green-400 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="text-slate-300">${label}</span>
|
||||
</li>`;
|
||||
} else {
|
||||
return `<li class="flex items-center gap-2.5 py-1.5">
|
||||
<svg class="w-5 h-5 text-slate-600 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="text-slate-500">${label}</span>
|
||||
</li>`;
|
||||
}
|
||||
};
|
||||
|
||||
proCard.innerHTML = `
|
||||
<div class="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<span class="bg-orange-500 text-white text-xs font-bold px-3 py-1 rounded-full uppercase tracking-wide">Le plus populaire</span>
|
||||
</div>
|
||||
<div class="mb-4 mt-2">
|
||||
<h3 class="text-xl font-bold text-white">Pro</h3>
|
||||
<p class="text-slate-400 text-sm mt-1">Pour les professionnels</p>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-4xl font-bold text-white">${proPriceMonthly}€</span>
|
||||
<span class="text-slate-400">/mois</span>
|
||||
</div>
|
||||
${
|
||||
billingPeriod === "yearly" && proYearly
|
||||
? `
|
||||
<p class="text-green-400 text-sm mt-1 font-medium">Facturé ${proYearly.amountFormatted}/an</p>
|
||||
`
|
||||
: `<p class="text-slate-400 text-sm mt-1">Facturation mensuelle</p>`
|
||||
}
|
||||
</div>
|
||||
<div class="mb-4 pb-4 border-b border-slate-700">
|
||||
<p class="text-sm font-semibold text-white">✓ 50 générations/mois</p>
|
||||
</div>
|
||||
<ul class="space-y-0.5 mb-6 flex-grow text-sm">
|
||||
${features.map((f) => renderProFeature(f.label, f.pro)).join("")}
|
||||
</ul>
|
||||
${
|
||||
isProCurrentPlan
|
||||
? `
|
||||
<div class="py-3 px-4 bg-orange-500/20 text-orange-400 rounded-xl font-semibold text-center mt-auto">✓ Votre plan actuel</div>
|
||||
`
|
||||
: `
|
||||
<button onclick="checkout('${proPriceId}')" class="w-full py-3 px-4 bg-orange-500 text-white rounded-xl font-semibold hover:bg-orange-600 transition-colors mt-auto">${userPlan.isPaid ? "Changer de plan" : "Commencer"}</button>
|
||||
`
|
||||
}
|
||||
`;
|
||||
grid.appendChild(proCard);
|
||||
}
|
||||
|
||||
function renderCredits(products) {
|
||||
const grid = document.getElementById("credits-grid");
|
||||
grid.innerHTML = "";
|
||||
|
||||
// Trouver les prix des crédits
|
||||
const creditWithSub = products.find((p) => p.type === "credit_with_sub");
|
||||
const creditNoSub = products.find((p) => p.type === "credit_no_sub");
|
||||
|
||||
// Sélectionner le bon produit selon le statut abonné
|
||||
const product = userPlan.isPaid ? creditWithSub : creditNoSub;
|
||||
const price = product?.prices[0];
|
||||
|
||||
if (!price) return;
|
||||
|
||||
// Calculer la réduction pour le message marketing
|
||||
const priceWithSub = creditWithSub?.prices[0]?.amount || 0;
|
||||
const priceNoSub = creditNoSub?.prices[0]?.amount || 0;
|
||||
const discountPercent =
|
||||
priceNoSub > 0 ? Math.round((1 - priceWithSub / priceNoSub) * 100) : 50;
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className =
|
||||
"rounded-2xl p-6 border-2 border-slate-200 bg-white hover:border-orange-300 transition-all max-w-md";
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="text-center mb-6">
|
||||
<div class="inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-orange-400 to-orange-600 rounded-xl mb-3">
|
||||
<span class="text-2xl">💎</span>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-slate-900">Crédits supplémentaires</h3>
|
||||
<p class="text-slate-500 text-sm mt-1">N'expirent jamais</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center mb-6">
|
||||
<div class="flex items-baseline justify-center gap-1">
|
||||
<span class="text-4xl font-bold text-slate-900">${price.amountFormatted}</span>
|
||||
<span class="text-slate-500">/crédit</span>
|
||||
</div>
|
||||
${
|
||||
userPlan.isPaid
|
||||
? `
|
||||
<div class="mt-2 inline-flex items-center gap-1.5 text-green-600 text-sm font-medium bg-green-50 px-3 py-1 rounded-full">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
Vous économisez ${discountPercent}% (tarif abonné)
|
||||
</div>
|
||||
`
|
||||
: `
|
||||
<div class="mt-3 p-3 bg-orange-50 border border-orange-200 rounded-xl">
|
||||
<p class="text-sm text-orange-800">
|
||||
<span class="font-semibold">💡 Astuce :</span> Les abonnés paient seulement <strong>${creditWithSub?.prices[0]?.amountFormatted || "0,50€"}</strong>/crédit
|
||||
<span class="text-orange-600 font-semibold">(−${discountPercent}%)</span>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center gap-3 mb-4 p-3 bg-slate-50 rounded-xl">
|
||||
<button
|
||||
class="qty-btn w-10 h-10 flex items-center justify-center bg-white border border-slate-300 rounded-lg hover:bg-slate-100 text-lg font-bold transition-colors"
|
||||
data-action="decrease"
|
||||
>−</button>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value="5"
|
||||
class="credit-quantity w-20 px-2 py-2 border border-slate-300 rounded-lg text-center font-semibold text-lg"
|
||||
data-price-id="${price.id}"
|
||||
data-unit-price="${price.amount}"
|
||||
/>
|
||||
<button
|
||||
class="qty-btn w-10 h-10 flex items-center justify-center bg-white border border-slate-300 rounded-lg hover:bg-slate-100 text-lg font-bold transition-colors"
|
||||
data-action="increase"
|
||||
>+</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center mb-4">
|
||||
<span class="text-lg text-slate-600">Total : <strong class="credit-total text-slate-900 text-xl">${((price.amount * 5) / 100).toFixed(2)}€</strong></span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick="checkoutCredits('${price.id}', this)"
|
||||
class="w-full py-3.5 px-4 bg-slate-900 text-white rounded-xl font-semibold hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Acheter des crédits
|
||||
</button>
|
||||
|
||||
<p class="text-xs text-slate-400 text-center mt-4">
|
||||
Les crédits s'ajoutent à votre quota mensuel et ne sont jamais perdus.
|
||||
</p>
|
||||
`;
|
||||
|
||||
// Gestion quantité
|
||||
const input = card.querySelector(".credit-quantity");
|
||||
const totalEl = card.querySelector(".credit-total");
|
||||
const qtyBtns = card.querySelectorAll(".qty-btn");
|
||||
|
||||
const updateTotal = () => {
|
||||
let qty = parseInt(input.value) || 1;
|
||||
// Forcer la quantité minimum à 1
|
||||
if (qty < 1) qty = 1;
|
||||
input.value = qty.toString();
|
||||
const total = (price.amount * qty) / 100;
|
||||
totalEl.textContent = `${total.toFixed(2)}€`;
|
||||
};
|
||||
|
||||
input?.addEventListener("input", updateTotal);
|
||||
input?.addEventListener("blur", updateTotal); // Valider aussi quand on quitte le champ
|
||||
|
||||
qtyBtns.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const action = btn.dataset.action;
|
||||
let qty = parseInt(input.value) || 1;
|
||||
if (action === "increase") qty++;
|
||||
if (action === "decrease" && qty > 1) qty--;
|
||||
input.value = qty.toString();
|
||||
updateTotal();
|
||||
});
|
||||
});
|
||||
|
||||
grid.appendChild(card);
|
||||
}
|
||||
|
||||
async function checkout(priceId) {
|
||||
try {
|
||||
const response = await fetch("/api/stripe/create-checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ priceId }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
} else if (data.error === "Non authentifié") {
|
||||
window.location.href = "/sign-in?redirect=/pricing";
|
||||
} else {
|
||||
alert(data.error || "Erreur lors de la création du paiement");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erreur checkout:", error);
|
||||
alert("Erreur lors de la création du paiement");
|
||||
}
|
||||
}
|
||||
|
||||
async function checkoutCredits(priceId, button) {
|
||||
const card = button.closest(".rounded-2xl");
|
||||
const input = card?.querySelector(".credit-quantity");
|
||||
let quantity = parseInt(input?.value) || 1;
|
||||
|
||||
// Valider la quantité (minimum 1)
|
||||
if (quantity < 1) quantity = 1;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/stripe/create-checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ priceId, quantity }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
} else if (data.error === "Non authentifié") {
|
||||
window.location.href = "/sign-in?redirect=/pricing";
|
||||
} else {
|
||||
alert(data.error || "Erreur lors de la création du paiement");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erreur checkout:", error);
|
||||
alert("Erreur lors de la création du paiement");
|
||||
}
|
||||
}
|
||||
|
||||
// Gestion du bouton "Gérer mon abonnement"
|
||||
document
|
||||
.getElementById("manage-subscription-btn")
|
||||
?.addEventListener("click", async () => {
|
||||
try {
|
||||
const response = await fetch("/api/stripe/create-portal", {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erreur portail:", error);
|
||||
}
|
||||
});
|
||||
|
||||
// Exposer les fonctions au scope global
|
||||
window.checkout = checkout;
|
||||
window.checkoutCredits = checkoutCredits;
|
||||
window.loadProducts = loadProducts;
|
||||
|
||||
// Charger les produits au démarrage
|
||||
document.addEventListener("DOMContentLoaded", loadProducts);
|
||||
</script>
|
||||
525
src/pages/profile.astro
Normal file
@@ -0,0 +1,525 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import { SignedIn, SignedOut, SignOutButton } from "@clerk/astro/components";
|
||||
import { getCreditStats, getUserCredits, type PlanType } from "../lib/db";
|
||||
import { getUserPlan } from "../lib/plans";
|
||||
import { getSubscription, getCreditsBalance } from "../lib/subscriptions";
|
||||
|
||||
const auth = Astro.locals.auth();
|
||||
const userId = auth.userId;
|
||||
|
||||
if (!userId) {
|
||||
return Astro.redirect("/");
|
||||
}
|
||||
|
||||
// Récupérer les infos utilisateur depuis Clerk
|
||||
const user = await Astro.locals.currentUser();
|
||||
|
||||
// Déterminer le plan de l'utilisateur
|
||||
const userPlanInfo = getUserPlan(userId);
|
||||
const isAdmin = userPlanInfo.isAdmin === true;
|
||||
const credits = getUserCredits(userId);
|
||||
|
||||
// Récupérer les infos Stripe (crédits bonus)
|
||||
const subscription = getSubscription(userId);
|
||||
const bonusCredits = getCreditsBalance(userId);
|
||||
const hasStripeSubscription = !!subscription?.stripe_subscription_id;
|
||||
|
||||
// Passer les crédits bonus à getCreditStats
|
||||
const creditStats = getCreditStats(
|
||||
userId,
|
||||
userPlanInfo.planType,
|
||||
isAdmin,
|
||||
bonusCredits
|
||||
);
|
||||
---
|
||||
|
||||
<Layout title="Profil">
|
||||
<SignedOut>
|
||||
<div class="text-center py-20">
|
||||
<p class="text-slate-600">
|
||||
Veuillez vous connecter pour accéder à cette page.
|
||||
</p>
|
||||
</div>
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-8">Mon Profil</h1>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-8">
|
||||
<div class="flex items-center gap-6 mb-8">
|
||||
{
|
||||
user?.imageUrl && (
|
||||
<img
|
||||
src={user.imageUrl}
|
||||
alt="Avatar"
|
||||
class="w-24 h-24 rounded-full border-4 border-indigo-100"
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold">
|
||||
{user?.firstName}
|
||||
{user?.lastName}
|
||||
</h2>
|
||||
<p class="text-slate-600">
|
||||
{user?.emailAddresses?.[0]?.emailAddress}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Badge Admin -->
|
||||
{
|
||||
userPlanInfo.isAdmin && (
|
||||
<div class="bg-gradient-to-r from-purple-600 to-indigo-600 text-white rounded-xl p-4 flex items-center gap-3">
|
||||
<span class="text-2xl">👑</span>
|
||||
<div>
|
||||
<span class="font-semibold">Compte Administrateur</span>
|
||||
<p class="text-sm text-purple-200">Générations illimitées</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- Section Abonnement -->
|
||||
<div class="border-t border-slate-200 pt-4">
|
||||
<h3 class="font-medium text-slate-800 mb-4">💳 Mon abonnement</h3>
|
||||
|
||||
<div
|
||||
class:list={[
|
||||
"rounded-xl p-4 mb-4",
|
||||
userPlanInfo.isPaid
|
||||
? "bg-gradient-to-r from-orange-50 to-amber-50 border border-orange-200"
|
||||
: "bg-slate-50 border border-slate-200",
|
||||
]}
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<span
|
||||
class:list={[
|
||||
"inline-block px-3 py-1 rounded-full text-sm font-semibold",
|
||||
userPlanInfo.isAdmin
|
||||
? "bg-gradient-to-r from-purple-600 to-indigo-600 text-white"
|
||||
: userPlanInfo.planType === "pro"
|
||||
? "bg-slate-800 text-white"
|
||||
: userPlanInfo.planType === "standard"
|
||||
? "bg-orange-500 text-white"
|
||||
: "bg-slate-200 text-slate-700",
|
||||
]}
|
||||
>
|
||||
{userPlanInfo.isAdmin ? "👑 Admin" : creditStats.planName}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href="/pricing"
|
||||
class="text-sm text-orange-600 hover:underline font-medium"
|
||||
>
|
||||
{
|
||||
userPlanInfo.isPaid
|
||||
? "Gérer l'abonnement"
|
||||
: "Passer à un plan payant →"
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Barre de progression des crédits -->
|
||||
{
|
||||
userPlanInfo.isAdmin ? (
|
||||
<div class="mb-2">
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-slate-600">Crédits</span>
|
||||
<span class="font-semibold text-purple-600">
|
||||
∞ Illimité
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-gradient-to-r from-purple-200 to-indigo-200 rounded-full h-3 overflow-hidden">
|
||||
<div class="h-full w-full bg-gradient-to-r from-purple-500 to-indigo-500 rounded-full animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="mb-2">
|
||||
<div class="flex justify-between text-sm mb-1">
|
||||
<span class="text-slate-600">Crédits utilisés</span>
|
||||
<span class="font-semibold text-slate-800">
|
||||
{creditStats.used} / {creditStats.limit}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-white/70 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
class:list={[
|
||||
"h-full rounded-full transition-all",
|
||||
creditStats.percentage >= 100
|
||||
? "bg-red-500"
|
||||
: creditStats.percentage >= 80
|
||||
? "bg-orange-500"
|
||||
: "bg-green-500",
|
||||
]}
|
||||
style={`width: ${creditStats.percentage}%`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
userPlanInfo.isAdmin ? (
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-medium text-purple-600">
|
||||
∞ Générations illimitées
|
||||
</span>
|
||||
<span class="text-slate-500">Admin</span>
|
||||
</div>
|
||||
) : (
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span
|
||||
class:list={[
|
||||
"font-medium",
|
||||
creditStats.remaining === 0
|
||||
? "text-red-600"
|
||||
: creditStats.remaining <= 2
|
||||
? "text-orange-600"
|
||||
: "text-green-600",
|
||||
]}
|
||||
>
|
||||
{creditStats.remaining} génération
|
||||
{creditStats.remaining > 1 ? "s" : ""} restante
|
||||
{creditStats.remaining > 1 ? "s" : ""}
|
||||
</span>
|
||||
<span class="text-slate-500">
|
||||
{userPlanInfo.planType === "free"
|
||||
? "au total"
|
||||
: "ce mois-ci"}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Stats totales -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="bg-slate-50 rounded-lg p-3 text-center">
|
||||
<div class="text-2xl font-bold text-slate-900">
|
||||
{credits.total_generations}
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">Générations totales</div>
|
||||
</div>
|
||||
<div class="bg-slate-50 rounded-lg p-3 text-center">
|
||||
<div class="text-2xl font-bold text-slate-900">
|
||||
{credits.monthly_generations}
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">Ce mois-ci</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Crédits Bonus -->
|
||||
{
|
||||
!userPlanInfo.isAdmin && (
|
||||
<div class="mt-4 p-4 bg-gradient-to-r from-amber-50 to-yellow-50 border border-amber-200 rounded-xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-2xl">💎</span>
|
||||
<div>
|
||||
<p class="font-semibold text-slate-900">
|
||||
Crédits bonus
|
||||
</p>
|
||||
<p class="text-sm text-slate-600">N'expirent jamais</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-2xl font-bold text-amber-600">
|
||||
{bonusCredits}
|
||||
</span>
|
||||
<p class="text-xs text-slate-500">disponible(s)</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/pricing#credits-title"
|
||||
class="mt-3 inline-block text-sm text-amber-700 hover:text-amber-800 font-medium hover:underline"
|
||||
>
|
||||
Acheter des crédits supplémentaires →
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- Bouton Gérer l'abonnement Stripe (discret) -->
|
||||
{
|
||||
hasStripeSubscription && (
|
||||
<div class="mt-4 pt-4 border-t border-slate-100">
|
||||
<details class="group">
|
||||
<summary class="cursor-pointer text-sm text-slate-400 hover:text-slate-600 transition-colors flex items-center gap-2">
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform group-open:rotate-90"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
Paramètres avancés
|
||||
</summary>
|
||||
<div class="mt-3 pl-6 space-y-3">
|
||||
<p class="text-xs text-slate-500">
|
||||
Modifier votre moyen de paiement, télécharger vos
|
||||
factures ou gérer votre abonnement.
|
||||
</p>
|
||||
<button
|
||||
id="manage-stripe-subscription"
|
||||
class="text-xs text-slate-500 hover:text-slate-700 underline transition-colors"
|
||||
>
|
||||
Accéder au portail de facturation →
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- Paramètres avancés - Suppression de compte -->
|
||||
<div class="mt-4 pt-4 border-t border-slate-100">
|
||||
<details class="group">
|
||||
<summary
|
||||
class="cursor-pointer text-sm text-slate-400 hover:text-slate-600 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform group-open:rotate-90"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
Zone de danger
|
||||
</summary>
|
||||
<div class="mt-3 pl-6 space-y-3">
|
||||
<div class="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p class="text-xs text-red-600 mb-2">
|
||||
⚠️ <strong>Attention :</strong> La suppression de votre compte
|
||||
est irréversible. Toutes vos données, générations et références
|
||||
seront définitivement supprimées.
|
||||
</p>
|
||||
<button
|
||||
id="delete-account-btn"
|
||||
class="text-xs text-red-600 hover:text-red-800 underline transition-colors font-medium"
|
||||
>
|
||||
Supprimer définitivement mon compte
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-200 pt-4">
|
||||
<h3 class="font-medium text-slate-800 mb-2">
|
||||
Informations du compte
|
||||
</h3>
|
||||
<dl class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-slate-600">ID Utilisateur</dt>
|
||||
<dd class="font-mono text-sm text-slate-500">
|
||||
{userId?.slice(0, 20)}...
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-slate-600">Membre depuis</dt>
|
||||
<dd class="text-slate-800">
|
||||
{
|
||||
user?.createdAt
|
||||
? new Date(user.createdAt).toLocaleDateString("fr-FR")
|
||||
: "N/A"
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-200 pt-4">
|
||||
<h3 class="font-medium text-slate-800 mb-4">Actions rapides</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<a
|
||||
href="/library"
|
||||
class="flex items-center gap-2 p-4 bg-slate-50 rounded-lg hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<span class="text-2xl">📚</span>
|
||||
<span class="font-medium">Ma bibliothèque</span>
|
||||
</a>
|
||||
<a
|
||||
href="/history"
|
||||
class="flex items-center gap-2 p-4 bg-slate-50 rounded-lg hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<span class="text-2xl">📜</span>
|
||||
<span class="font-medium">Mon historique</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Aide -->
|
||||
<div class="border-t border-slate-200 pt-4">
|
||||
<h3 class="font-medium text-slate-800 mb-4">📖 Aide & Support</h3>
|
||||
<button
|
||||
id="restart-tutorial"
|
||||
class="flex items-center gap-3 p-4 w-full bg-indigo-50 rounded-lg hover:bg-indigo-100 transition-colors text-left"
|
||||
>
|
||||
<span class="text-2xl">🎓</span>
|
||||
<div>
|
||||
<span class="font-medium text-indigo-800"
|
||||
>Revoir le tutoriel</span
|
||||
>
|
||||
<p class="text-sm text-indigo-600">
|
||||
Apprenez à utiliser PrimaDopu étape par étape
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Déconnexion -->
|
||||
<div class="border-t border-slate-200 pt-4">
|
||||
<SignOutButton redirectUrl="/" asChild>
|
||||
<button
|
||||
class="flex items-center gap-3 p-4 w-full bg-red-50 rounded-lg hover:bg-red-100 transition-colors text-left cursor-pointer"
|
||||
>
|
||||
<span class="text-2xl">🚪</span>
|
||||
<div>
|
||||
<span class="font-medium text-red-800">Se déconnecter</span>
|
||||
<p class="text-sm text-red-600">Fermer votre session</p>
|
||||
</div>
|
||||
</button>
|
||||
</SignOutButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SignedIn>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
// Relancer le tutoriel
|
||||
document.getElementById("restart-tutorial")?.addEventListener("click", () => {
|
||||
localStorage.removeItem("primadopu_tutorial_completed");
|
||||
localStorage.setItem("primadopu_show_tutorial", "true");
|
||||
window.location.href = "/generate";
|
||||
});
|
||||
|
||||
// Suppression du compte avec plusieurs validations
|
||||
document
|
||||
.getElementById("delete-account-btn")
|
||||
?.addEventListener("click", async () => {
|
||||
// Première confirmation
|
||||
const confirm1 = window.confirm(
|
||||
"⚠️ ATTENTION - SUPPRESSION DE COMPTE ⚠️\n\n" +
|
||||
"Vous êtes sur le point de supprimer définitivement votre compte.\n\n" +
|
||||
"Cette action est IRRÉVERSIBLE et entraînera :\n" +
|
||||
"• La suppression de toutes vos générations\n" +
|
||||
"• La suppression de votre bibliothèque de références\n" +
|
||||
"• La perte de vos crédits restants\n" +
|
||||
"• L'annulation de votre abonnement\n\n" +
|
||||
"Voulez-vous vraiment continuer ?"
|
||||
);
|
||||
|
||||
if (!confirm1) return;
|
||||
|
||||
// Deuxième confirmation avec saisie
|
||||
const confirmText = window.prompt(
|
||||
"Pour confirmer la suppression, tapez SUPPRIMER en majuscules :"
|
||||
);
|
||||
|
||||
if (confirmText !== "SUPPRIMER") {
|
||||
if (confirmText !== null) {
|
||||
alert("Le texte saisi ne correspond pas. Suppression annulée.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Troisième et dernière confirmation
|
||||
const confirm3 = window.confirm(
|
||||
"🚨 DERNIÈRE CONFIRMATION 🚨\n\n" +
|
||||
"Votre compte sera supprimé DÉFINITIVEMENT.\n\n" +
|
||||
"Êtes-vous absolument certain de vouloir continuer ?"
|
||||
);
|
||||
|
||||
if (!confirm3) return;
|
||||
|
||||
// Procéder à la suppression
|
||||
try {
|
||||
const deleteBtn = document.getElementById("delete-account-btn");
|
||||
if (deleteBtn) {
|
||||
deleteBtn.textContent = "Suppression en cours...";
|
||||
deleteBtn.setAttribute("disabled", "true");
|
||||
}
|
||||
|
||||
const response = await fetch("/api/user/delete-account", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
alert(
|
||||
"Votre compte a été supprimé avec succès. Vous allez être redirigé."
|
||||
);
|
||||
// Nettoyer la session côté client avant la redirection
|
||||
// Supprimer les cookies Clerk manuellement
|
||||
document.cookie.split(";").forEach((c) => {
|
||||
const name = c.split("=")[0].trim();
|
||||
if (name.startsWith("__clerk") || name.startsWith("__session")) {
|
||||
document.cookie =
|
||||
name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/";
|
||||
}
|
||||
});
|
||||
// Forcer un rechargement complet pour nettoyer l'état
|
||||
window.location.replace("/");
|
||||
} else {
|
||||
throw new Error(data.error || "Erreur lors de la suppression");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erreur suppression:", error);
|
||||
alert(
|
||||
"Une erreur est survenue lors de la suppression du compte. Veuillez réessayer."
|
||||
);
|
||||
|
||||
const deleteBtn = document.getElementById("delete-account-btn");
|
||||
if (deleteBtn) {
|
||||
deleteBtn.textContent = "Supprimer définitivement mon compte";
|
||||
deleteBtn.removeAttribute("disabled");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Gérer l'abonnement Stripe avec confirmation
|
||||
document
|
||||
.getElementById("manage-stripe-subscription")
|
||||
?.addEventListener("click", async () => {
|
||||
// Première confirmation
|
||||
const confirm1 = window.confirm(
|
||||
"Vous allez accéder au portail de facturation.\n\n" +
|
||||
"Vous pourrez y modifier votre moyen de paiement et télécharger vos factures.\n\n" +
|
||||
"Continuer ?"
|
||||
);
|
||||
|
||||
if (!confirm1) return;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/stripe/create-portal", {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erreur portail:", error);
|
||||
alert("Erreur lors de l'ouverture du portail de gestion");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
14
src/pages/sign-in/index.astro
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import { SignIn } from "@clerk/astro/components";
|
||||
---
|
||||
|
||||
<Layout title="Connexion">
|
||||
<div class="flex items-center justify-center min-h-[70vh]">
|
||||
<SignIn
|
||||
fallbackRedirectUrl="/"
|
||||
signUpFallbackRedirectUrl="/"
|
||||
signUpUrl="/sign-up"
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
14
src/pages/sign-up/index.astro
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import { SignUp } from "@clerk/astro/components";
|
||||
---
|
||||
|
||||
<Layout title="Inscription">
|
||||
<div class="flex items-center justify-center min-h-[70vh]">
|
||||
<SignUp
|
||||
fallbackRedirectUrl="/"
|
||||
signInFallbackRedirectUrl="/"
|
||||
signInUrl="/sign-in"
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
51
src/styles/global.css
Normal file
@@ -0,0 +1,51 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Hide scrollbar for horizontal tabs */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari and Opera */
|
||||
}
|
||||
|
||||
/* Animation for log entries */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for log console */
|
||||
#logsContent {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #475569 #1e293b;
|
||||
}
|
||||
|
||||
#logsContent::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
#logsContent::-webkit-scrollbar-track {
|
||||
background: #1e293b;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
#logsContent::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
#logsContent::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
14
src/types/heic-convert.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
declare module "heic-convert" {
|
||||
interface ConvertOptions {
|
||||
/** Input HEIC/HEIF buffer */
|
||||
buffer: Buffer;
|
||||
/** Output format: "JPEG" or "PNG" */
|
||||
format: "JPEG" | "PNG";
|
||||
/** Quality from 0 to 1 (for JPEG) */
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
function heicConvert(options: ConvertOptions): Promise<ArrayBuffer>;
|
||||
|
||||
export = heicConvert;
|
||||
}
|
||||
14
tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [
|
||||
".astro/types.d.ts",
|
||||
"**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"dist"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
}
|
||||
}
|
||||