diff --git a/.gitignore b/.gitignore index c192e22..bb14856 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,10 @@ public/vendor # Content # --------------- /public/content + +# Claude settings +# --------------- +.claude +/.claude/* + + diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6e95c05..aa30802 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -56,7 +56,7 @@ deploy_preprod: build_prod: stage: build only: - - main + - prod image: composer:2 script: - apk add --no-cache nodejs npm diff --git a/CLAUDE_PROJECT_OVERVIEW.md b/CLAUDE_PROJECT_OVERVIEW.md new file mode 100644 index 0000000..4657285 --- /dev/null +++ b/CLAUDE_PROJECT_OVERVIEW.md @@ -0,0 +1,311 @@ +# Design to Pack - Vue d'ensemble du projet + +Plateforme de gestion de projets de création de flacons de parfum pour Pochet du Courval. + +## Stack technique + +| Couche | Technologies | +|--------|-------------| +| **Backend** | Kirby CMS 4 (PHP), flat-file database | +| **Frontend** | Vue 3 + Vite 7, Pinia, Vue Router 4, PrimeVue 4.0 | +| **PDF** | @vue-pdf-viewer 2.5 | +| **3D** | Three.js (vue interactive 360) | +| **Déploiement** | GitLab CI/CD, rsync vers serveur | + +--- + +## Structure du projet + +``` +design-to-pack/ +├── src/ # App Vue.js +│ ├── assets/css/ # Styles globaux +│ ├── components/ # Composants réutilisables +│ │ ├── comments/ # Système de commentaires +│ │ ├── design-to-light/ # Feature DTL +│ │ ├── inspirations/ # Galerie inspirations +│ │ ├── notifications/ # Notifications +│ │ └── project/ # Composants projet +│ │ ├── cards/ # Cartes par type d'étape +│ │ ├── brief/ # Brief client (moodboard) +│ │ └── virtual-sample/ # Échantillon virtuel 3D +│ ├── router/ # Vue Router +│ ├── stores/ # Pinia stores +│ ├── utils/ # Utilitaires +│ ├── views/ # Pages principales +│ ├── main.js # Point d'entrée +│ └── App.vue # Composant racine +│ +├── public/ # Kirby CMS +│ ├── content/ # Données (flat-file) +│ │ ├── projects/ # Pages projets +│ │ ├── clients/ # Pages clients +│ │ ├── design-to-light/ # Page DTL +│ │ └── inspirations/ # Galerie inspirations +│ ├── site/ +│ │ ├── blueprints/ # Schémas de données +│ │ │ ├── pages/ # Blueprints des pages +│ │ │ ├── users/ # Blueprints utilisateurs +│ │ │ └── files/ # Blueprints fichiers +│ │ ├── templates/ # Templates PHP + JSON +│ │ ├── controllers/ # Contrôleurs +│ │ ├── models/ # Modèles PHP (Project, Client) +│ │ ├── plugins/ # Plugins custom +│ │ ├── snippets/ # Fragments réutilisables +│ │ └── config/ # Configuration + routes +│ └── media/ # Fichiers uploadés +│ +├── package.json +├── vite.config.js +└── .gitlab-ci.yml +``` + +--- + +## Plugins Kirby custom + +### 1. `classes/` - Classes partagées +Classes de données utilisées par comments et notifications. + +| Classe | Rôle | +|--------|------| +| `Author` | Auteur (name, email, uuid, role) | +| `Position` | Position x/y + pageIndex (marqueurs sur PDF) | +| `Location` | Localisation (page, file, parent) | +| `PageDetails` | Détails de page | +| `FileDetails` | Détails de fichier | +| `ProjectDetails` | Détails de projet | + +### 2. `comments/` - Système de commentaires +Plugin `adrienpayet/kirby4-comments` + +**Classes:** +- `BaseComment` - Classe de base +- `Comment` - Commentaire avec replies +- `Reply` - Réponse à un commentaire + +**Routes:** +| Route | Fichier | Description | +|-------|---------|-------------| +| `POST /create-comment.json` | `routes/create.php` | Créer un commentaire | +| `POST /update-comment.json` | `routes/update.php` | Modifier un commentaire | +| `POST /delete-comment.json` | `routes/delete.php` | Supprimer un commentaire | +| `POST /reply-comment.json` | `routes/reply.php` | Répondre à un commentaire | + +**Stockage:** Les commentaires sont stockés en YAML dans les métadonnées des fichiers. + +### 3. `notifications/` - Système de notifications +Plugin `adrienpayet/pdc-notifications` + +**Classes:** +- `Notification` - Notification (type, location, text, author, date, readby[]) +- `NotificationsPage` - Base pour pages avec notifications (extends Page) + +**Méthodes NotificationsPage:** +- `createNotification($data)` - Créer une notification +- `deleteNotification($id)` - Supprimer une notification +- `readNotification($id)` - Marquer comme lue (ajoute userUuid à readby) +- `readAllNotifications()` - Tout marquer comme lu + +**Routes:** +| Route | Description | +|-------|-------------| +| `POST /read-notification.json` | Marquer une notification comme lue | +| `POST /read-all-notifications.json` | Tout marquer comme lu | + +### 4. `user-projects/` - Projets autorisés par utilisateur +Plugin `adrienpayet/pdc-authorized-projects` + +**User methods:** +- `currentProjects()` - Projets actifs (listed) accessibles à l'utilisateur +- `archivedProjects()` - Projets archivés (unlisted) accessibles + +Logique: Admin = tous les projets, autres = seulement projets assignés. + +### 5. `helpers/` - Fonctions utilitaires + +| Fonction | Description | +|----------|-------------| +| `getFileData($file, $preserveQuality)` | Normalise les données fichier (thumb webp, cover, comments) | +| `getGlobalEvaluation($numberedGrade)` | Convertit note numérique en lettre A-E avec mention | +| `processDTLProposals($page)` | Traite les propositions Design to Light | +| `refreshProjectStepsCache($project, $steps)` | Rafraîchit le cache des étapes | + +### 6. `icons/` - Icônes custom panel +Plugin `adrienpayet/pochet-icons` - Icônes personnalisées pour le panel Kirby. + +### 7. `kql/` - Kirby Query Language +Plugin externe pour requêtes type GraphQL. + +### 8. `refresh-cache-button/` - Bouton refresh cache +Plugin externe ajoutant un bouton de rafraîchissement du cache dans le panel. + +--- + +## Modèles de données + +### Utilisateurs (3 rôles) + +| Rôle | Accès | +|------|-------| +| `admin` | Tous les projets, panel complet | +| `pochet` | Projets assignés uniquement, panel limité | +| `client` | Ses projets uniquement, pas de panel | + +### Projet (ProjectPage) +Hérite de `NotificationsPage`. + +**Champs principaux:** +- `title`, `status` (draft/listed/unlisted) +- `client` - Lien vers ClientPage +- `currentStep` - Étape courante +- `isDTLEnabled` - Design to Light activé + +**Étapes (children):** +1. `client-brief` - Brief client (PDF + moodboard) +2. `proposal` - Proposition commerciale (PDFs) +3. `extended-brief` - Brief étendu +4. `industrial-ideation` - Idéation industrielle (optionnel) +5. `virtual-sample` - Échantillon virtuel (pistes dynamiques + statiques) +6. `physical-sample` - Échantillon physique (médias) + +### Client (ClientPage) +- `logo`, `title` +- `projects()` - Tous les projets +- `projectsListed()` / `projectsUnlisted()` - Filtres par statut + +--- + +## Stores Pinia + +| Store | Fichier | Rôle | +|-------|---------|------| +| `api` | `stores/api.js` | Communication API (fetch, post, comments, notifications) | +| `user` | `stores/user.js` | Utilisateur courant, permissions | +| `page` | `stores/page.js` | Données de la page courante | +| `projects` | `stores/projects.js` | Liste des projets | +| `dialog` | `stores/dialog.js` | État des modales (contenu, fichier, commentaires) | +| `brief` | `stores/brief.js` | Gestion du brief client | +| `designToLight` | `stores/designToLight.js` | Feature DTL | +| `notifications` | `stores/notifications.js` | Notifications non lues | +| `virtualSample` | `stores/virtualSample.js` | État échantillon virtuel | +| `addImagesModal` | `stores/addImagesModal.js` | Modal ajout images | +| `project` | `stores/project.js` | Utilitaires projet | + +--- + +## Routes Vue + +| Path | Vue | Description | +|------|-----|-------------| +| `/` | `Home.vue` | Liste des projets | +| `/login` | `Login.vue` | Authentification | +| `/account` | `Account.vue` | Compte utilisateur | +| `/notifications` | `Notifications.vue` | Centre de notifications | +| `/inspirations` | `Inspirations.vue` | Galerie d'inspirations | +| `/design-to-light` | `DesignToLight.vue` | Feature DTL | +| `/projects/:id` | `Kanban.vue` | Détail projet (kanban) | +| `/projects/:id/client-brief` | `Brief.vue` | Brief client | +| `/projects/:id/extended-brief` | `Brief.vue` | Brief étendu | + +--- + +## API Endpoints + +### Authentification +- `POST /login.json` - Connexion (email, password) +- `GET /logout` - Déconnexion + +### Pages (JSON) +- `GET /{uri}.json` - Données page + user + +### Commentaires +- `POST /create-comment.json` +- `POST /update-comment.json` +- `POST /delete-comment.json` +- `POST /reply-comment.json` + +### Notifications +- `POST /read-notification.json` +- `POST /read-all-notifications.json` + +### Fichiers +- `POST /upload-pdf.json` +- `POST /upload-images.json` +- `POST /remove-file.json` + +### Actions +- `POST /save-page.json` +- `POST /save-file.json` +- `POST /validate-brief.json` +- `POST /toggle-favorite.json` +- `POST /request-project-creation.json` +- `POST /request-optimization-appointment.json` + +--- + +## Design to Light (DTL) + +Système d'évaluation avancée des designs de flacons. + +**Notation:** +- Note globale : A (8-10), B (6-8), C (4-6), D (2-4), E (0-2) +- Indicateurs : Design global, Bague, Épaule, Colonnes & Arêtes, Pied, Fond de Verre +- Position : Complexité, Poids + +**Propositions DTL liées à:** +- Proposition commerciale (PDF) +- Idéation industrielle (PDF) +- Échantillon virtuel - piste dynamique +- Échantillon virtuel - piste statique + +--- + +## Fichiers clés à connaître + +### Frontend +- `src/main.js` - Init app +- `src/router/router.js` - Guard + setup +- `src/router/routes.js` - Définition routes +- `src/stores/api.js` - Toute la comm API +- `src/components/Menu.vue` - Navigation latérale +- `src/components/project/DialogWrapper.vue` - Wrapper modales + +### Backend +- `public/site/config/config.php` - Routes, hooks, config +- `public/site/controllers/site.php` - Contrôleur principal +- `public/site/models/project.php` - Logique projet +- `public/site/plugins/helpers/index.php` - Fonctions utilitaires +- `public/site/blueprints/pages/project.yml` - Structure projet + +--- + +## Développement local + +```bash +# Backend +cd public +composer install +php -S localhost:8888 kirby/router.php + +# Frontend +npm install +npm run dev +``` + +## Build + +```bash +npm run build # Production +npm run build:preprod # Staging (avec sourcemaps) +``` + +--- + +## Notes importantes + +1. **Cache**: Les étapes projet sont cachées. Invalidation automatique via hooks Kirby. +2. **Permissions**: Filtrées côté serveur selon le rôle utilisateur. +3. **Commentaires**: Positionnés en % (x, y) + pageIndex pour les PDFs multi-pages. +4. **Notifications**: Stockées par projet, trackées par user UUID dans `readby[]`. +5. **Virtual Sample**: Pistes dynamiques = pages enfants, pistes statiques = fichiers. diff --git a/public/.user.ini b/public/.user.ini new file mode 100644 index 0000000..082ba05 --- /dev/null +++ b/public/.user.ini @@ -0,0 +1,2 @@ +; Augmentation temporaire de la limite mémoire pour le chargement des notifications +memory_limit = 512M diff --git a/public/site/blueprints/pages/client-brief.yml b/public/site/blueprints/pages/client-brief.yml index 7574be8..d7b2100 100644 --- a/public/site/blueprints/pages/client-brief.yml +++ b/public/site/blueprints/pages/client-brief.yml @@ -24,6 +24,18 @@ tabs: type: hidden isValidated: type: hidden + validatedBy: + type: hidden + validatedByName: + type: hidden + validatedByEmail: + type: hidden + validatedAt: + type: hidden + validationReadby: + type: hidden + validationDialogUri: + type: hidden pdf: label: PDF type: files diff --git a/public/site/blueprints/pages/extended-brief.yml b/public/site/blueprints/pages/extended-brief.yml index b65d99f..5c96ee2 100644 --- a/public/site/blueprints/pages/extended-brief.yml +++ b/public/site/blueprints/pages/extended-brief.yml @@ -22,6 +22,21 @@ tabs: fields: stepName: type: hidden + isValidated: + type: hidden + # Champs pour notification "content" (brief validé) + validatedBy: + type: hidden + validatedByName: + type: hidden + validatedByEmail: + type: hidden + validatedAt: + type: hidden + validationReadby: + type: hidden + validationDialogUri: + type: hidden pdf: label: PDF type: files diff --git a/public/site/blueprints/pages/project.yml b/public/site/blueprints/pages/project.yml index 946e238..6962aa5 100644 --- a/public/site/blueprints/pages/project.yml +++ b/public/site/blueprints/pages/project.yml @@ -21,6 +21,7 @@ tabs: fields: lastCacheUpdate: type: hidden + # Champs pour project-request isClientRequest: type: hidden default: "false" @@ -30,6 +31,32 @@ tabs: disabled: true when: isClientRequest: "true" + requestAuthor: + type: hidden + requestAuthorName: + type: hidden + requestAuthorEmail: + type: hidden + requestDate: + type: hidden + requestReadby: + type: hidden + # Champs pour appointment-request (DTL) + hasOptimizationRequest: + type: hidden + default: "false" + optimizationRequestDetails: + type: hidden + optimizationAuthor: + type: hidden + optimizationAuthorName: + type: hidden + optimizationAuthorEmail: + type: hidden + optimizationDate: + type: hidden + optimizationReadby: + type: hidden currentStep: label: Étape en cours type: radio diff --git a/public/site/config/config.php b/public/site/config/config.php index 72ea0da..37f3a33 100644 --- a/public/site/config/config.php +++ b/public/site/config/config.php @@ -40,6 +40,7 @@ return [ require(__DIR__ . '/routes/validate-brief.php'), require(__DIR__ . '/routes/request-project-creation.php'), require(__DIR__ . '/routes/request-optimization-appointment.php'), + require(__DIR__ . '/routes/migrate-notifications.php'), ], 'hooks' => [ 'page.create:after' => require_once(__DIR__ . '/hooks/create-steps.php'), diff --git a/public/site/config/hooks/file-update--regenerate-project-steps-cache.php b/public/site/config/hooks/file-update--regenerate-project-steps-cache.php index ab09290..c30f93d 100644 --- a/public/site/config/hooks/file-update--regenerate-project-steps-cache.php +++ b/public/site/config/hooks/file-update--regenerate-project-steps-cache.php @@ -3,7 +3,9 @@ // file.update:after return function ($newFile, $oldFile) { $project = $newFile->parent()->template() == 'project' ? $newFile->parent() : $newFile->parent()->parents()->findBy('template', 'project'); - if ($project) { - $steps = $project->rebuildStepsCache(); + if ($project) { + $project->rebuildStepsCache(); + // Invalider aussi le cache des notifications (commentaires sur fichiers, etc.) + $project->invalidateNotificationsCache(); } }; \ No newline at end of file diff --git a/public/site/config/hooks/page-update--regenerate-project-steps-cache.php b/public/site/config/hooks/page-update--regenerate-project-steps-cache.php index bde728d..f8d9675 100644 --- a/public/site/config/hooks/page-update--regenerate-project-steps-cache.php +++ b/public/site/config/hooks/page-update--regenerate-project-steps-cache.php @@ -1,9 +1,11 @@ template() == 'project' ? $newPage : $newPage->parents()->findBy('template', 'project'); if ($project) { - $steps = $project->rebuildStepsCache(); + $project->rebuildStepsCache(); + // Invalider aussi le cache des notifications (briefs validés, etc.) + $project->invalidateNotificationsCache(); } }; \ No newline at end of file diff --git a/public/site/config/routes/migrate-notifications.php b/public/site/config/routes/migrate-notifications.php new file mode 100644 index 0000000..3aa033c --- /dev/null +++ b/public/site/config/routes/migrate-notifications.php @@ -0,0 +1,175 @@ + 'migrate-notifications.json', + 'method' => 'POST', + 'action' => function () { + $user = kirby()->user(); + + // Vérifier que l'utilisateur est admin + if (!$user || $user->role()->id() !== 'admin') { + return [ + 'status' => 'error', + 'message' => 'Cette action nécessite les droits administrateur.' + ]; + } + + $migrated = [ + 'comments' => 0, + 'replies' => 0, + 'project-requests' => 0, + 'appointment-requests' => 0, + 'content' => 0, + 'errors' => [] + ]; + + $projects = page('projects')->children(); + + foreach ($projects as $project) { + // Récupérer les anciennes notifications + $notifications = $project->notifications()->yaml() ?? []; + + if (empty($notifications)) { + continue; + } + + foreach ($notifications as $notification) { + try { + $type = $notification['type'] ?? 'comment'; + $id = $notification['id'] ?? null; + $readby = $notification['readby'] ?? []; + + if (empty($id) || empty($readby)) { + continue; + } + + switch ($type) { + case 'comment': + case 'comment-reply': + $fileUuid = $notification['location']['file']['uuid'] ?? null; + if (!$fileUuid) continue 2; + + $file = kirby()->file($fileUuid); + if (!$file) continue 2; + + $comments = Yaml::decode($file->comments()->value()) ?? []; + $updated = false; + + foreach ($comments as &$comment) { + // Vérifier si c'est le commentaire principal + if ($comment['id'] === $id) { + $existingReadby = $comment['readby'] ?? []; + $comment['readby'] = array_values(array_unique(array_merge($existingReadby, $readby))); + $updated = true; + $migrated['comments']++; + break; + } + + // Vérifier dans les réponses + foreach ($comment['replies'] ?? [] as &$reply) { + if ($reply['id'] === $id) { + $existingReadby = $reply['readby'] ?? []; + $reply['readby'] = array_values(array_unique(array_merge($existingReadby, $readby))); + $updated = true; + $migrated['replies']++; + break 2; + } + } + } + + if ($updated) { + $file->update(['comments' => $comments]); + } + break; + + case 'project-request': + $existingReadby = $project->requestReadby()->yaml() ?? []; + $newReadby = array_values(array_unique(array_merge($existingReadby, $readby))); + + $updateData = ['requestReadby' => $newReadby]; + + // Migrer aussi les métadonnées si elles n'existent pas encore + if ($project->requestAuthor()->isEmpty() && isset($notification['author'])) { + $updateData['requestAuthor'] = $notification['author']['uuid'] ?? ''; + $updateData['requestAuthorName'] = $notification['author']['name'] ?? ''; + $updateData['requestAuthorEmail'] = $notification['author']['email'] ?? ''; + $updateData['requestDate'] = $notification['date'] ?? ''; + } + + $project->update($updateData); + $migrated['project-requests']++; + break; + + case 'appointment-request': + $existingReadby = $project->optimizationReadby()->yaml() ?? []; + $newReadby = array_values(array_unique(array_merge($existingReadby, $readby))); + + $updateData = ['optimizationReadby' => $newReadby]; + + // Migrer aussi les métadonnées si elles n'existent pas encore + if ($project->optimizationAuthor()->isEmpty() && isset($notification['author'])) { + $updateData['optimizationAuthor'] = $notification['author']['uuid'] ?? ''; + $updateData['optimizationAuthorName'] = $notification['author']['name'] ?? ''; + $updateData['optimizationAuthorEmail'] = $notification['author']['email'] ?? ''; + $updateData['optimizationDate'] = $notification['date'] ?? ''; + } + + $project->update($updateData); + $migrated['appointment-requests']++; + break; + + case 'content': + $briefUri = $notification['location']['page']['uri'] ?? null; + if (!$briefUri) continue 2; + + $brief = page($briefUri); + if (!$brief) continue 2; + + $existingReadby = $brief->validationReadby()->yaml() ?? []; + $newReadby = array_values(array_unique(array_merge($existingReadby, $readby))); + + $updateData = ['validationReadby' => $newReadby]; + + // Migrer aussi les métadonnées si elles n'existent pas encore + if ($brief->validatedBy()->isEmpty() && isset($notification['author'])) { + $updateData['validatedBy'] = $notification['author']['uuid'] ?? ''; + $updateData['validatedByName'] = $notification['author']['name'] ?? ''; + $updateData['validatedByEmail'] = $notification['author']['email'] ?? ''; + $updateData['validatedAt'] = $notification['date'] ?? ''; + } + + $brief->update($updateData); + $migrated['content']++; + break; + } + } catch (\Throwable $th) { + $migrated['errors'][] = [ + 'project' => $project->title()->value(), + 'notification_id' => $id ?? 'unknown', + 'type' => $type ?? 'unknown', + 'error' => $th->getMessage() + ]; + } + } + } + + $total = $migrated['comments'] + $migrated['replies'] + + $migrated['project-requests'] + $migrated['appointment-requests'] + + $migrated['content']; + + return [ + 'status' => 'success', + 'message' => "Migration terminée. $total notifications migrées.", + 'details' => $migrated + ]; + } +]; diff --git a/public/site/config/routes/request-optimization-appointment.php b/public/site/config/routes/request-optimization-appointment.php index 0cc94c0..d1e4b84 100644 --- a/public/site/config/routes/request-optimization-appointment.php +++ b/public/site/config/routes/request-optimization-appointment.php @@ -7,37 +7,26 @@ return [ $json = file_get_contents('php://input'); $data = json_decode($json); - $user = kirby()->user(); + $user = kirby()->user(); $project = page($data->projectUri); + $date = new DateTime(); + $formattedDate = $date->format(DateTime::ISO8601); + try { - $newProject = $project->update([ + $project->update([ "hasOptimizationRequest" => "true", - "optimizationRequestDetails" => esc("De la part de " . kirby()->user()->name() . " (" . kirby()->user()->email() . ") : \n\n" . "Objet : " . $data->subject . "\n" . $data->details) + "optimizationRequestDetails" => esc("De la part de " . $user->name() . " (" . $user->email() . ") : \n\n" . "Objet : " . $data->subject . "\n" . $data->details), + // Métadonnées pour le système de notifications dérivées + "optimizationAuthor" => (string) $user->uuid(), + "optimizationAuthorName" => (string) $user->name(), + "optimizationAuthorEmail" => (string) $user->email(), + "optimizationDate" => $formattedDate, + "optimizationReadby" => [], ]); - } catch (\Throwable $th) { - return [ - "status" => "error", - "message" => "Can't update project " . $project->title()->value() . ". " . $th->getMessage() . " in " . $th->getFile() . " line " . $th->getLine() - ]; - } - try { - $date = new DateTime(); - $formattedDate = $date->format(DateTime::ISO8601); - - $notificationData = [ - "location" => [ - "page" => $newProject - ], - "date" => (string) $formattedDate, - "text" => nl2br("Objet : " . $data->subject . "\n" . esc($data->details)), - "author" => $user, - "id" => Str::uuid(), - "type" => "appointment-request", - ]; - - $newProject->createNotification($notificationData); + // Note: Les notifications sont maintenant dérivées. + // Plus besoin d'appeler createNotification(). return [ "status" => "success", @@ -45,7 +34,7 @@ return [ } catch (\Throwable $th) { return [ "status" => "error", - "message" => "Can't create notification. " . $th->getMessage() . " in " . $th->getFile() . " line " . $th->getLine() + "message" => "Can't update project " . $project->title()->value() . ". " . $th->getMessage() . " in " . $th->getFile() . " line " . $th->getLine() ]; } } diff --git a/public/site/config/routes/request-project-creation.php b/public/site/config/routes/request-project-creation.php index 9a474d7..1b9b378 100644 --- a/public/site/config/routes/request-project-creation.php +++ b/public/site/config/routes/request-project-creation.php @@ -11,15 +11,24 @@ return [ $client = kirby()->user()->client()->toPage()->uuid(); + $date = new DateTime(); + $formattedDate = $date->format(DateTime::ISO8601); + $projectData = [ "slug" => esc(Str::slug($data->title)), - "template" => "project", + "template" => "project", "content" => [ "title" => esc($data->title), - "requestDetails" => esc("Demande de " . kirby()->user()->name() . " (" . kirby()->user()->email() . ") : \n\n" . $data->details), + "requestDetails" => esc("Demande de " . $user->name() . " (" . $user->email() . ") : \n\n" . $data->details), "client" => [$client], "isClientRequest" => "true", - "isDTLEnabled" => esc($data->isDTLEnabled) + "isDTLEnabled" => esc($data->isDTLEnabled), + // Métadonnées pour le système de notifications dérivées + "requestAuthor" => (string) $user->uuid(), + "requestAuthorName" => (string) $user->name(), + "requestAuthorEmail" => (string) $user->email(), + "requestDate" => $formattedDate, + "requestReadby" => [], ] ]; @@ -27,21 +36,8 @@ return [ try { $newProject = $projects->createChild($projectData); - $date = new DateTime(); - $formattedDate = $date->format(DateTime::ISO8601); - - $notificationData = [ - "location" => [ - "page" => $newProject - ], - "date" => (string) $formattedDate, - "text" => nl2br(esc($data->details)), - "author" => $user, - "id" => Str::uuid(), - "type" => "project-request", - ]; - - $newProject->createNotification($notificationData); + // Note: Les notifications sont maintenant dérivées. + // Plus besoin d'appeler createNotification(). return [ "status" => "success", diff --git a/public/site/config/routes/validate-brief.php b/public/site/config/routes/validate-brief.php index f8e97f1..5b16c44 100644 --- a/public/site/config/routes/validate-brief.php +++ b/public/site/config/routes/validate-brief.php @@ -9,27 +9,31 @@ return [ $page = page($data->briefUri); $project = $page->parent(); - - try { - $newPage = $page->update([ - 'isValidated' => 'true' - ]); + $user = kirby()->user(); + try { $timezone = new DateTimeZone('Europe/Paris'); $dateTime = new DateTime('now', $timezone); - $notification = [ - 'location' => [ - 'page' => $page, - ], - 'date' => $dateTime->format('Y-m-d\TH:i:sP'), - 'text' => "Nouveau brief", - 'author' => kirby()->user(), - 'id' => Str::uuid(), - 'type' => 'content' + $updateData = [ + 'isValidated' => 'true', + // Métadonnées pour le système de notifications dérivées + 'validatedBy' => (string) $user->uuid(), + 'validatedByName' => (string) $user->name(), + 'validatedByEmail' => (string) $user->email(), + 'validatedAt' => $dateTime->format('Y-m-d\TH:i:sP'), + 'validationReadby' => [], ]; - $project->createNotification($notification); + // Si un dialogUri est fourni (validation depuis PDF), le stocker + if (isset($data->dialogUri) && !empty($data->dialogUri)) { + $updateData['validationDialogUri'] = (string) $data->dialogUri; + } + + $newPage = $page->update($updateData); + + // Note: Les notifications sont maintenant dérivées. + // Plus besoin d'appeler createNotification(). return [ "success" => "'" . $project->title()->value() . "' brief validated." diff --git a/public/site/models/project.php b/public/site/models/project.php index df39ff1..583bfeb 100644 --- a/public/site/models/project.php +++ b/public/site/models/project.php @@ -3,18 +3,62 @@ use adrienpayet\notifications\NotificationsPage; class ProjectPage extends NotificationsPage { - public function getSteps() { + public function getSteps() { $apiCache = kirby()->cache('api'); - $stepsData = $apiCache?->get($this->slug() . '_' . 'steps'); - + $stepsData = $apiCache?->get($this->slug() . '_' . 'steps'); + if ($stepsData === null || count($stepsData) === 0) { $this->rebuildStepsCache(); }; - - $stepsData = $apiCache->get($this->slug() . '_' . 'steps'); + + $stepsData = $apiCache->get($this->slug() . '_' . 'steps'); return $stepsData; } + + /** + * Récupère les notifications pour ce projet (version allégée avec cache). + * Cache par utilisateur pour inclure le isRead. + */ + public function getNotificationsLight($user) { + if (!$user) { + return []; + } + + $apiCache = kirby()->cache('api'); + $cacheKey = $this->slug() . '_notifications_' . $user->uuid(); + $notifications = $apiCache?->get($cacheKey); + + // Si pas en cache, collecter et cacher + if ($notifications === null) { + $collector = kirby()->option('adrienpayet.pdc-notifications.collector'); + if (!$collector) { + return []; + } + + try { + $notifications = $collector->collectLight($this, $user); + $apiCache->set($cacheKey, $notifications); + } catch (\Throwable $e) { + error_log("Error caching notifications for {$this->slug()}: " . $e->getMessage()); + return []; + } + } + + return $notifications; + } + + /** + * Invalide le cache des notifications de ce projet pour tous les utilisateurs. + */ + public function invalidateNotificationsCache() { + $apiCache = kirby()->cache('api'); + // Invalider pour tous les users + foreach (kirby()->users() as $user) { + $cacheKey = $this->slug() . '_notifications_' . $user->uuid(); + $apiCache->remove($cacheKey); + } + } public function rebuildStepsCache() { // Create steps @@ -159,7 +203,7 @@ class ProjectPage extends NotificationsPage { "physicalSample" => "échantillon physique", ]; - return $stepsLabel[$this->currentStep()->value()] ?? "brief"; + return $stepsLabel[$this->currentStep()->value()]; } // public function printManagers() { diff --git a/public/site/plugins/comments/routes/create.php b/public/site/plugins/comments/routes/create.php index 9db31cf..5641383 100644 --- a/public/site/plugins/comments/routes/create.php +++ b/public/site/plugins/comments/routes/create.php @@ -46,6 +46,7 @@ return [ 'author' => kirby()->user(), 'id' => Str::uuid(), 'type' => 'comment', + 'readby' => [], // Pour le système de notifications dérivées ]; if (isset($data->position->pageIndex)) { @@ -62,11 +63,8 @@ return [ echo json_encode(getFileData($newFile)); - try { - $project->createNotification($commentData); - } catch (\Throwable $th) { - throw new Exception($th->getMessage() . '. line ' . $th->getLine() . ' in file ' . $th->getFile(), 1); - } + // Note: Les notifications sont maintenant dérivées des commentaires. + // Plus besoin d'appeler createNotification(). exit; }, diff --git a/public/site/plugins/comments/routes/delete.php b/public/site/plugins/comments/routes/delete.php index 8bc9e07..5f96a96 100644 --- a/public/site/plugins/comments/routes/delete.php +++ b/public/site/plugins/comments/routes/delete.php @@ -39,8 +39,8 @@ return [ echo json_encode(getFileData($newFile)); - $project = $page->parents()->findBy('template', 'project'); - $project->deleteNotification($data->id); + // Note: Les notifications sont maintenant dérivées des commentaires. + // La suppression du commentaire supprime automatiquement la notification. exit; }, diff --git a/public/site/plugins/comments/routes/reply.php b/public/site/plugins/comments/routes/reply.php index 68aeebd..2418dfe 100644 --- a/public/site/plugins/comments/routes/reply.php +++ b/public/site/plugins/comments/routes/reply.php @@ -31,18 +31,19 @@ return [ "author" => kirby()->user(), "id" => Str::uuid(), "type" => "comment-reply", + "readby" => [], // Pour le système de notifications dérivées ]; $newReply = new Reply($replyData); $comment['replies'][] = $newReply->toArray(); } } - + $newFile = $file->update([ 'comments' => $comments ]); - $project = $page->parents()->findBy("template", "project"); - $project->createNotification($replyData); + // Note: Les notifications sont maintenant dérivées des commentaires. + // Plus besoin d'appeler createNotification(). return getFileData($newFile); } diff --git a/public/site/plugins/notifications/index.php b/public/site/plugins/notifications/index.php index 91da6f9..9b46c7e 100644 --- a/public/site/plugins/notifications/index.php +++ b/public/site/plugins/notifications/index.php @@ -1,27 +1,54 @@ "models/ProjectPage.php", -], __DIR__); +use adrienpayet\notifications\NotificationCollector; +use adrienpayet\notifications\providers\CommentProvider; +use adrienpayet\notifications\providers\ReplyProvider; +use adrienpayet\notifications\providers\ProjectRequestProvider; +use adrienpayet\notifications\providers\AppointmentRequestProvider; +use adrienpayet\notifications\providers\ContentProvider; +// Charger les classes F::loadClasses([ - // Own classes - "adrienpayet\\notifications\\Notification" => __DIR__ . "/src/Notification.php", - "adrienpayet\\notifications\\NotificationsPage" => __DIR__ . "/src/NotificationsPage.php", - - // Shared classes - "adrienpayet\\D2P\\data\\Position" => __DIR__ . "/../classes/Position.php", - "adrienpayet\\D2P\data\Author" => __DIR__ . "/../classes/Author.php", - "adrienpayet\\D2P\\data\\location\\Location" => __DIR__ . "/../classes/location/Location.php", - "adrienpayet\\D2P\\data\\location\\PageDetails" => __DIR__ . "/../classes/location/PageDetails.php", - "adrienpayet\\D2P\\data\\location\\ProjectDetails" => __DIR__ . "/../classes/location/ProjectDetails.php", - "adrienpayet\\D2P\\data\\location\\FileDetails" => __DIR__ . "/../classes/location/FileDetails.php", + // Nouvelles classes - Système de providers + "adrienpayet\\notifications\\NotificationProvider" => __DIR__ . "/src/NotificationProvider.php", + "adrienpayet\\notifications\\NotificationCollector" => __DIR__ . "/src/NotificationCollector.php", + "adrienpayet\\notifications\\providers\\CommentProvider" => __DIR__ . "/src/providers/CommentProvider.php", + "adrienpayet\\notifications\\providers\\ReplyProvider" => __DIR__ . "/src/providers/ReplyProvider.php", + "adrienpayet\\notifications\\providers\\ProjectRequestProvider" => __DIR__ . "/src/providers/ProjectRequestProvider.php", + "adrienpayet\\notifications\\providers\\AppointmentRequestProvider" => __DIR__ . "/src/providers/AppointmentRequestProvider.php", + "adrienpayet\\notifications\\providers\\ContentProvider" => __DIR__ . "/src/providers/ContentProvider.php", + + // Anciennes classes - Gardées pour rétro-compatibilité pendant migration + "adrienpayet\\notifications\\Notification" => __DIR__ . "/src/Notification.php", + "adrienpayet\\notifications\\NotificationsPage" => __DIR__ . "/src/NotificationsPage.php", + + // Classes partagées + "adrienpayet\\D2P\\data\\Position" => __DIR__ . "/../classes/Position.php", + "adrienpayet\\D2P\\data\\Author" => __DIR__ . "/../classes/Author.php", + "adrienpayet\\D2P\\data\\location\\Location" => __DIR__ . "/../classes/location/Location.php", + "adrienpayet\\D2P\\data\\location\\PageDetails" => __DIR__ . "/../classes/location/PageDetails.php", + "adrienpayet\\D2P\\data\\location\\ProjectDetails" => __DIR__ . "/../classes/location/ProjectDetails.php", + "adrienpayet\\D2P\\data\\location\\FileDetails" => __DIR__ . "/../classes/location/FileDetails.php", ]); +// Créer et configurer le collector +$collector = new NotificationCollector(); +$collector->register(new CommentProvider()); +$collector->register(new ReplyProvider()); +$collector->register(new ProjectRequestProvider()); +$collector->register(new AppointmentRequestProvider()); +$collector->register(new ContentProvider()); Kirby::plugin("adrienpayet/pdc-notifications", [ - "routes" => [ - require(__DIR__ . "/routes/readAll.php"), - require(__DIR__ . "/routes/read.php") - ], + "options" => [ + "collector" => $collector + ], + "routes" => [ + // Nouvelles routes + require(__DIR__ . "/routes/mark-as-read.php"), + require(__DIR__ . "/routes/mark-all-read.php"), + // Anciennes routes - Gardées pour rétro-compatibilité + require(__DIR__ . "/routes/readAll.php"), + require(__DIR__ . "/routes/read.php"), + ], ]); diff --git a/public/site/plugins/notifications/routes/mark-all-read.php b/public/site/plugins/notifications/routes/mark-all-read.php new file mode 100644 index 0000000..c06cc80 --- /dev/null +++ b/public/site/plugins/notifications/routes/mark-all-read.php @@ -0,0 +1,42 @@ + '(:all)mark-all-notifications-read.json', + 'method' => 'POST', + 'action' => function () { + try { + $user = kirby()->user(); + if (!$user) { + throw new Exception('User not authenticated'); + } + + $collector = kirby()->option('adrienpayet.pdc-notifications.collector'); + if (!$collector) { + throw new Exception('NotificationCollector not initialized'); + } + + // Récupérer les projets selon le rôle + if ($user->role()->name() === 'admin') { + $projects = page('projects')->children()->toArray(); + } else { + $projects = $user->projects()->toPages()->toArray(); + } + + $count = $collector->markAllAsRead($projects, $user); + + return json_encode([ + 'status' => 'success', + 'message' => "$count notifications marked as read" + ]); + } catch (\Throwable $th) { + return json_encode([ + 'status' => 'error', + 'message' => $th->getMessage() + ]); + } + } +]; diff --git a/public/site/plugins/notifications/routes/mark-as-read.php b/public/site/plugins/notifications/routes/mark-as-read.php new file mode 100644 index 0000000..736b39f --- /dev/null +++ b/public/site/plugins/notifications/routes/mark-as-read.php @@ -0,0 +1,46 @@ + '(:all)mark-notification-read.json', + 'method' => 'POST', + 'action' => function () { + $json = file_get_contents('php://input'); + $data = json_decode($json); + + if (!$data || !isset($data->type) || !isset($data->id)) { + return json_encode([ + 'status' => 'error', + 'message' => 'Missing required fields: type, id' + ]); + } + + try { + $collector = kirby()->option('adrienpayet.pdc-notifications.collector'); + + if (!$collector) { + throw new Exception('NotificationCollector not initialized'); + } + + $success = $collector->markAsRead( + $data->type, + $data->id, + (array) $data, + kirby()->user() + ); + + return json_encode([ + 'status' => $success ? 'success' : 'error', + 'message' => $success ? 'Notification marked as read' : 'Failed to mark notification as read' + ]); + } catch (\Throwable $th) { + return json_encode([ + 'status' => 'error', + 'message' => $th->getMessage() + ]); + } + } +]; diff --git a/public/site/plugins/notifications/src/NotificationCollector.php b/public/site/plugins/notifications/src/NotificationCollector.php new file mode 100644 index 0000000..ec1714b --- /dev/null +++ b/public/site/plugins/notifications/src/NotificationCollector.php @@ -0,0 +1,181 @@ +providers[$provider->getType()] = $provider; + } + + /** + * Collecte toutes les notifications de tous les providers pour un projet. + * + * @param Page $project Le projet à scanner + * @param User $user L'utilisateur courant + * @return array Liste triée par date décroissante + */ + public function collect(Page $project, User $user): array + { + $all = []; + + foreach ($this->providers as $provider) { + try { + $notifications = $provider->collect($project, $user); + $all = array_merge($all, $notifications); + } catch (\Throwable $e) { + // Log l'erreur mais continue avec les autres providers + error_log("NotificationCollector: Error in {$provider->getType()}: " . $e->getMessage()); + } + } + + // Trier par date décroissante + usort($all, function ($a, $b) { + $dateA = strtotime($a['date'] ?? '0'); + $dateB = strtotime($b['date'] ?? '0'); + return $dateB - $dateA; + }); + + return $all; + } + + /** + * Collecte uniquement les données minimales des notifications (version allégée pour listing). + * Retourne les champs nécessaires à l'affichage mais sans les détails lourds. + * + * @param Page $project Le projet à scanner + * @param User $user L'utilisateur courant + * @return array Liste triée par date décroissante + */ + public function collectLight(Page $project, User $user): array + { + $all = []; + + foreach ($this->providers as $provider) { + try { + $notifications = $provider->collect($project, $user); + // Garder les champs nécessaires au frontend + foreach ($notifications as $notification) { + $light = [ + 'id' => $notification['id'] ?? null, + 'type' => $notification['type'] ?? null, + 'isRead' => $notification['isRead'] ?? false, + 'date' => $notification['date'] ?? null, + 'text' => $notification['text'] ?? null, + 'author' => $notification['author'] ?? null, + 'location' => $notification['location'] ?? [] + ]; + + // Garder les champs optionnels s'ils existent + if (isset($notification['dialogUri'])) { + $light['dialogUri'] = $notification['dialogUri']; + } + if (isset($notification['_briefUri'])) { + $light['_briefUri'] = $notification['_briefUri']; + } + if (isset($notification['_file'])) { + $light['_file'] = $notification['_file']; + } + if (isset($notification['_projectUri'])) { + $light['_projectUri'] = $notification['_projectUri']; + } + + $all[] = $light; + } + } catch (\Throwable $e) { + error_log("NotificationCollector: Error in {$provider->getType()}: " . $e->getMessage()); + } + } + + // Trier par date décroissante + usort($all, function ($a, $b) { + $dateA = strtotime($a['date'] ?? '0'); + $dateB = strtotime($b['date'] ?? '0'); + return $dateB - $dateA; + }); + + return $all; + } + + /** + * Marque une notification comme lue en déléguant au bon provider. + * + * @param string $type Le type de notification + * @param string $id L'identifiant de la notification + * @param array $location Informations de localisation + * @param User $user L'utilisateur qui marque comme lu + * @return bool True si succès + */ + public function markAsRead(string $type, string $id, array $location, User $user): bool + { + if (!isset($this->providers[$type])) { + error_log("NotificationCollector: Unknown notification type: $type"); + return false; + } + + try { + return $this->providers[$type]->markAsRead($id, $location, $user); + } catch (\Throwable $e) { + error_log("NotificationCollector: Error marking as read: " . $e->getMessage()); + return false; + } + } + + /** + * Marque toutes les notifications comme lues pour un utilisateur. + * + * @param Page[] $projects Liste des projets + * @param User $user L'utilisateur + * @return int Nombre de notifications marquées comme lues + */ + public function markAllAsRead(array $projects, User $user): int + { + $count = 0; + + foreach ($projects as $project) { + $notifications = $this->collect($project, $user); + + foreach ($notifications as $notification) { + if (!($notification['isRead'] ?? false)) { + $success = $this->markAsRead( + $notification['type'], + $notification['id'], + $notification, + $user + ); + if ($success) { + $count++; + } + } + } + } + + return $count; + } + + /** + * Retourne la liste des types de notifications enregistrés. + */ + public function getRegisteredTypes(): array + { + return array_keys($this->providers); + } +} diff --git a/public/site/plugins/notifications/src/NotificationProvider.php b/public/site/plugins/notifications/src/NotificationProvider.php new file mode 100644 index 0000000..cc11906 --- /dev/null +++ b/public/site/plugins/notifications/src/NotificationProvider.php @@ -0,0 +1,42 @@ +hasOptimizationRequest()->isFalse()) { + return []; + } + + // Vérifier que les champs requis existent + if ($project->optimizationAuthor()->isEmpty()) { + return []; + } + + $userUuid = (string) $user->uuid(); + $authorUuid = $project->optimizationAuthor()->value(); + + // Ne pas notifier l'auteur de sa propre demande + if ($authorUuid === $userUuid) { + return []; + } + + $readby = $project->optimizationReadby()->isNotEmpty() + ? Yaml::decode($project->optimizationReadby()->value()) + : []; + + if (!is_array($readby)) { + $readby = []; + } + + return [[ + 'id' => 'appointment-request-' . (string) $project->uuid(), + 'type' => 'appointment-request', + 'text' => $project->optimizationRequestDetails()->value() ?? '', + 'author' => [ + 'uuid' => $authorUuid, + 'name' => $project->optimizationAuthorName()->value() ?? '', + 'email' => $project->optimizationAuthorEmail()->value() ?? '', + 'role' => 'client', + ], + 'date' => $project->optimizationDate()->value() ?? '', + 'location' => [ + 'page' => [ + 'uri' => $project->uri(), + 'title' => (string) $project->title(), + 'template' => 'project', + ], + 'project' => [ + 'uri' => $project->uri(), + 'title' => (string) $project->title(), + ] + ], + 'readby' => $readby, + 'isRead' => in_array($userUuid, $readby), + '_projectUri' => $project->uri(), + ]]; + } + + public function markAsRead(string $id, array $location, User $user): bool + { + $projectUri = $location['_projectUri'] ?? null; + if (!$projectUri) { + return false; + } + + $project = page($projectUri); + if (!$project) { + return false; + } + + $readby = $project->optimizationReadby()->isNotEmpty() + ? Yaml::decode($project->optimizationReadby()->value()) + : []; + + if (!is_array($readby)) { + $readby = []; + } + + $userUuid = (string) $user->uuid(); + + if (in_array($userUuid, $readby)) { + return true; + } + + $readby[] = $userUuid; + + $project->update([ + 'optimizationReadby' => array_unique($readby) + ]); + + return true; + } +} diff --git a/public/site/plugins/notifications/src/providers/CommentProvider.php b/public/site/plugins/notifications/src/providers/CommentProvider.php new file mode 100644 index 0000000..fc92ce1 --- /dev/null +++ b/public/site/plugins/notifications/src/providers/CommentProvider.php @@ -0,0 +1,172 @@ +uuid(); + + // Parcourir toutes les étapes du projet + foreach ($project->children() as $step) { + // Parcourir tous les fichiers de chaque étape + foreach ($step->files() as $file) { + if ($file->comments()->isEmpty()) { + continue; + } + + $comments = Yaml::decode($file->comments()->value()); + if (!is_array($comments)) { + continue; + } + + foreach ($comments as $comment) { + // Ignorer les commentaires de type reply (gérés par ReplyProvider) + if (($comment['type'] ?? 'comment') === 'comment-reply') { + continue; + } + + // Ne pas notifier l'auteur de son propre commentaire + $authorUuid = $comment['author']['uuid'] ?? ''; + if ($authorUuid === $userUuid) { + continue; + } + + $readby = $comment['readby'] ?? []; + + $location = $comment['location'] ?? []; + // Assurer que location.project existe toujours + if (!isset($location['project'])) { + $location['project'] = [ + 'uri' => $project->uri(), + 'title' => (string) $project->title(), + ]; + } + + $notifications[] = [ + 'id' => $comment['id'], + 'type' => 'comment', + 'text' => $comment['text'] ?? '', + 'author' => $comment['author'] ?? [], + 'date' => $comment['date'] ?? '', + 'location' => $location, + 'position' => $comment['position'] ?? [], + 'readby' => $readby, + 'isRead' => in_array($userUuid, $readby), + // Métadonnées pour markAsRead + '_file' => (string) $file->uuid(), + '_stepUri' => $step->uri(), + ]; + } + } + + // Parcourir aussi les sous-pages (ex: tracks dans virtual-sample) + foreach ($step->children() as $subPage) { + foreach ($subPage->files() as $file) { + if ($file->comments()->isEmpty()) { + continue; + } + + $comments = Yaml::decode($file->comments()->value()); + if (!is_array($comments)) { + continue; + } + + foreach ($comments as $comment) { + if (($comment['type'] ?? 'comment') === 'comment-reply') { + continue; + } + + $authorUuid = $comment['author']['uuid'] ?? ''; + if ($authorUuid === $userUuid) { + continue; + } + + $readby = $comment['readby'] ?? []; + + $location = $comment['location'] ?? []; + // Assurer que location.project existe toujours + if (!isset($location['project'])) { + $location['project'] = [ + 'uri' => $project->uri(), + 'title' => (string) $project->title(), + ]; + } + + $notifications[] = [ + 'id' => $comment['id'], + 'type' => 'comment', + 'text' => $comment['text'] ?? '', + 'author' => $comment['author'] ?? [], + 'date' => $comment['date'] ?? '', + 'location' => $location, + 'position' => $comment['position'] ?? [], + 'readby' => $readby, + 'isRead' => in_array($userUuid, $readby), + '_file' => (string) $file->uuid(), + '_stepUri' => $subPage->uri(), + ]; + } + } + } + } + + return $notifications; + } + + public function markAsRead(string $id, array $location, User $user): bool + { + $fileUuid = $location['_file'] ?? null; + if (!$fileUuid) { + return false; + } + + // Trouver le fichier par UUID (peut être avec ou sans préfixe file://) + $fileUri = str_starts_with($fileUuid, 'file://') ? $fileUuid : 'file://' . $fileUuid; + $file = kirby()->file($fileUri); + if (!$file) { + return false; + } + + $comments = Yaml::decode($file->comments()->value()); + if (!is_array($comments)) { + return false; + } + + $userUuid = (string) $user->uuid(); + $updated = false; + + foreach ($comments as &$comment) { + if ($comment['id'] === $id) { + $comment['readby'] = $comment['readby'] ?? []; + if (!in_array($userUuid, $comment['readby'])) { + $comment['readby'][] = $userUuid; + $updated = true; + } + break; + } + } + + if ($updated) { + $file->update(['comments' => $comments]); + } + + return $updated; + } +} diff --git a/public/site/plugins/notifications/src/providers/ContentProvider.php b/public/site/plugins/notifications/src/providers/ContentProvider.php new file mode 100644 index 0000000..94698ec --- /dev/null +++ b/public/site/plugins/notifications/src/providers/ContentProvider.php @@ -0,0 +1,135 @@ +uuid(); + + // Chercher les briefs validés (client-brief et extended-brief) + $briefTemplates = ['client-brief', 'extended-brief']; + + foreach ($project->children() as $step) { + if (!in_array($step->intendedTemplate()->name(), $briefTemplates)) { + continue; + } + + // Pas de notification si le brief n'est pas validé + if ($step->isValidated()->isFalse()) { + continue; + } + + // Vérifier que les champs requis existent + if ($step->validatedBy()->isEmpty()) { + continue; + } + + $authorUuid = $step->validatedBy()->value(); + + // Ne pas notifier l'auteur de sa propre validation + if ($authorUuid === $userUuid) { + continue; + } + + $readby = $step->validationReadby()->isNotEmpty() + ? Yaml::decode($step->validationReadby()->value()) + : []; + + if (!is_array($readby)) { + $readby = []; + } + + $stepLabel = $step->intendedTemplate()->name() === 'client-brief' + ? 'Brief client' + : 'Brief étendu'; + + $notification = [ + 'id' => 'content-' . (string) $step->uuid(), + 'type' => 'content', + 'text' => 'Nouveau ' . strtolower($stepLabel) . ' validé', + 'author' => [ + 'uuid' => $authorUuid, + 'name' => $step->validatedByName()->value() ?? '', + 'email' => $step->validatedByEmail()->value() ?? '', + 'role' => 'client', + ], + 'date' => $step->validatedAt()->value() ?? '', + 'location' => [ + 'page' => [ + 'uri' => $step->uri(), + 'title' => (string) $step->title(), + 'template' => $step->intendedTemplate()->name(), + ], + 'project' => [ + 'uri' => $project->uri(), + 'title' => (string) $project->title(), + ] + ], + 'readby' => $readby, + 'isRead' => in_array($userUuid, $readby), + '_briefUri' => $step->uri(), + ]; + + // Ajouter le dialogUri si présent (validation depuis PDF) + if ($step->validationDialogUri()->isNotEmpty()) { + $notification['dialogUri'] = $step->validationDialogUri()->value(); + } + + $notifications[] = $notification; + } + + return $notifications; + } + + public function markAsRead(string $id, array $location, User $user): bool + { + $briefUri = $location['_briefUri'] ?? null; + if (!$briefUri) { + return false; + } + + $brief = page($briefUri); + if (!$brief) { + return false; + } + + $readby = $brief->validationReadby()->isNotEmpty() + ? Yaml::decode($brief->validationReadby()->value()) + : []; + + if (!is_array($readby)) { + $readby = []; + } + + $userUuid = (string) $user->uuid(); + + if (in_array($userUuid, $readby)) { + return true; + } + + $readby[] = $userUuid; + + $brief->update([ + 'validationReadby' => array_unique($readby) + ]); + + return true; + } +} diff --git a/public/site/plugins/notifications/src/providers/ProjectRequestProvider.php b/public/site/plugins/notifications/src/providers/ProjectRequestProvider.php new file mode 100644 index 0000000..cc3d186 --- /dev/null +++ b/public/site/plugins/notifications/src/providers/ProjectRequestProvider.php @@ -0,0 +1,111 @@ +isClientRequest()->isFalse()) { + return []; + } + + // Vérifier que les champs requis existent + if ($project->requestAuthor()->isEmpty()) { + return []; + } + + $userUuid = (string) $user->uuid(); + $authorUuid = $project->requestAuthor()->value(); + + // Ne pas notifier l'auteur de sa propre demande + if ($authorUuid === $userUuid) { + return []; + } + + $readby = $project->requestReadby()->isNotEmpty() + ? Yaml::decode($project->requestReadby()->value()) + : []; + + if (!is_array($readby)) { + $readby = []; + } + + return [[ + 'id' => 'project-request-' . (string) $project->uuid(), + 'type' => 'project-request', + 'text' => $project->requestDetails()->value() ?? '', + 'author' => [ + 'uuid' => $authorUuid, + 'name' => $project->requestAuthorName()->value() ?? '', + 'email' => $project->requestAuthorEmail()->value() ?? '', + 'role' => 'client', + ], + 'date' => $project->requestDate()->value() ?? '', + 'location' => [ + 'page' => [ + 'uri' => $project->uri(), + 'title' => (string) $project->title(), + 'template' => 'project', + ], + 'project' => [ + 'uri' => $project->uri(), + 'title' => (string) $project->title(), + ] + ], + 'readby' => $readby, + 'isRead' => in_array($userUuid, $readby), + '_projectUri' => $project->uri(), + ]]; + } + + public function markAsRead(string $id, array $location, User $user): bool + { + $projectUri = $location['_projectUri'] ?? null; + if (!$projectUri) { + return false; + } + + $project = page($projectUri); + if (!$project) { + return false; + } + + $readby = $project->requestReadby()->isNotEmpty() + ? Yaml::decode($project->requestReadby()->value()) + : []; + + if (!is_array($readby)) { + $readby = []; + } + + $userUuid = (string) $user->uuid(); + + if (in_array($userUuid, $readby)) { + return true; // Déjà lu + } + + $readby[] = $userUuid; + + $project->update([ + 'requestReadby' => array_unique($readby) + ]); + + return true; + } +} diff --git a/public/site/plugins/notifications/src/providers/ReplyProvider.php b/public/site/plugins/notifications/src/providers/ReplyProvider.php new file mode 100644 index 0000000..467b245 --- /dev/null +++ b/public/site/plugins/notifications/src/providers/ReplyProvider.php @@ -0,0 +1,142 @@ +uuid(); + + // Parcourir toutes les étapes du projet + foreach ($project->children() as $step) { + $this->collectFromPage($step, $project, $userUuid, $notifications); + + // Parcourir aussi les sous-pages (ex: tracks) + foreach ($step->children() as $subPage) { + $this->collectFromPage($subPage, $project, $userUuid, $notifications); + } + } + + return $notifications; + } + + private function collectFromPage(Page $page, Page $project, string $userUuid, array &$notifications): void + { + foreach ($page->files() as $file) { + if ($file->comments()->isEmpty()) { + continue; + } + + $comments = Yaml::decode($file->comments()->value()); + if (!is_array($comments)) { + continue; + } + + foreach ($comments as $comment) { + $replies = $comment['replies'] ?? []; + + foreach ($replies as $reply) { + // Ne pas notifier l'auteur de sa propre réponse + $authorUuid = $reply['author']['uuid'] ?? ''; + if ($authorUuid === $userUuid) { + continue; + } + + $readby = $reply['readby'] ?? []; + + $location = $reply['location'] ?? $comment['location'] ?? []; + // Assurer que location.project existe toujours + if (!isset($location['project'])) { + $location['project'] = [ + 'uri' => $project->uri(), + 'title' => (string) $project->title(), + ]; + } + + $notifications[] = [ + 'id' => $reply['id'], + 'type' => 'comment-reply', + 'text' => $reply['text'] ?? '', + 'author' => $reply['author'] ?? [], + 'date' => $reply['date'] ?? '', + 'location' => $location, + 'position' => $reply['position'] ?? $comment['position'] ?? [], + 'readby' => $readby, + 'isRead' => in_array($userUuid, $readby), + // Métadonnées pour markAsRead + '_file' => (string) $file->uuid(), + '_parentCommentId' => $comment['id'], + '_stepUri' => $page->uri(), + ]; + } + } + } + } + + public function markAsRead(string $id, array $location, User $user): bool + { + $fileUuid = $location['_file'] ?? null; + $parentCommentId = $location['_parentCommentId'] ?? null; + + if (!$fileUuid) { + return false; + } + + // Trouver le fichier par UUID (peut être avec ou sans préfixe file://) + $fileUri = str_starts_with($fileUuid, 'file://') ? $fileUuid : 'file://' . $fileUuid; + $file = kirby()->file($fileUri); + if (!$file) { + return false; + } + + $comments = Yaml::decode($file->comments()->value()); + if (!is_array($comments)) { + return false; + } + + $userUuid = (string) $user->uuid(); + $updated = false; + + foreach ($comments as &$comment) { + // Si on a l'ID du parent, l'utiliser pour cibler + if ($parentCommentId && $comment['id'] !== $parentCommentId) { + continue; + } + + $replies = &$comment['replies'] ?? []; + + foreach ($replies as &$reply) { + if ($reply['id'] === $id) { + $reply['readby'] = $reply['readby'] ?? []; + if (!in_array($userUuid, $reply['readby'])) { + $reply['readby'][] = $userUuid; + $updated = true; + } + break 2; + } + } + } + + if ($updated) { + $file->update(['comments' => $comments]); + } + + return $updated; + } +} diff --git a/public/site/plugins/refresh-cache-button/index.js b/public/site/plugins/refresh-cache-button/index.js index 8fc4d42..4d99982 100644 --- a/public/site/plugins/refresh-cache-button/index.js +++ b/public/site/plugins/refresh-cache-button/index.js @@ -1 +1 @@ -(function(){"use strict";function f(n,e,a,t,r,c,s,u){var o=typeof n=="function"?n.options:n;return e&&(o.render=e,o.staticRenderFns=a,o._compiled=!0),{exports:n,options:o}}const l={__name:"RefreshCacheButton",props:{pageUri:String,pageStatus:String,lastCacheUpdate:String},setup(n){const{pageUri:e,pageStatus:a,lastCacheUpdate:t}=n,r=Vue.ref("Rafraîchir"),c=Vue.ref("refresh"),s=Vue.ref("aqua-icon"),u=Vue.computed(()=>(t==null?void 0:t.length)>0?"Dernière mise à jour : "+t:"Mettre à jour le cache front");async function o(){r.value="En cours…",c.value="loader",s.value="orange-icon";const m={method:"POST","Content-Type":"application/json",body:JSON.stringify({pageUri:e})},i=await(await fetch("/refresh-cache.json",m)).json();i.status==="error"?(console.error(i),r.value="Erreur",c.value="alert",s.value="red-icon"):(console.log(i),r.value="Terminé",c.value="check",s.value="green-icon",setTimeout(()=>{location.href=location.href},1500))}return{__sfc:!0,text:r,icon:c,theme:s,title:u,refreshCache:o}}};var h=function(){var e=this,a=e._self._c,t=e._self._setupProxy;return a("div",{attrs:{id:"refresh-cache-button"}},[e.pageStatus!=="draft"?a("k-button",{attrs:{theme:t.theme,variant:"dimmed",icon:t.icon,title:t.title},on:{click:function(r){return t.refreshCache()}}},[e._v(e._s(t.text))]):e._e()],1)},_=[],p=f(l,h,_);const d=p.exports;window.panel.plugin("adrienpayet/refresh-cache-button",{components:{"refresh-cache-button":d}})})(); +(function(){"use strict";function _(n,e,u,t,r,s,a,l){var c=typeof n=="function"?n.options:n;return e&&(c.render=e,c.staticRenderFns=u,c._compiled=!0),{exports:n,options:c}}const g={__name:"RefreshCacheButton",props:{pageUri:String,pageStatus:String,lastCacheUpdate:String},setup(n){const{pageUri:e,pageStatus:u,lastCacheUpdate:t}=n,r=Vue.ref("Rafraîchir"),s=Vue.ref("refresh"),a=Vue.ref("aqua-icon"),l=Vue.ref(!1),c=Vue.computed(()=>(t==null?void 0:t.length)>0?"Dernière mise à jour : "+t:"Mettre à jour le cache front");async function T(){l.value=!0,s.value="loader",a.value="orange-icon",e==="projects"?await d():await v()}async function d(){let f=0;const h=10;let i=!0,b=0;r.value="En cours 0%";try{for(;i;){const p={method:"POST","Content-Type":"application/json",body:JSON.stringify({pageUri:"projects",offset:f,limit:h})},o=await(await fetch("/refresh-cache.json",p)).json();if(o.status==="error")throw new Error(o.message);b=o.total,i=o.hasMore,f=o.nextOffset;const m=Math.round(o.processed/o.total*100);r.value=`En cours ${m}%`,console.log(`Batch terminé : ${o.processed}/${o.total} projets (${m}%)`)}r.value="Terminé",s.value="check",a.value="green-icon",setTimeout(()=>{location.href=location.href},2e3)}catch(p){console.error(p),r.value="Erreur",s.value="alert",a.value="red-icon",l.value=!1}}async function v(){r.value="En cours…";const f={method:"POST","Content-Type":"application/json",body:JSON.stringify({pageUri:e})};try{const i=await(await fetch("/refresh-cache.json",f)).json();if(i.status==="error")throw new Error(i.message);console.log(i),r.value="Terminé",s.value="check",a.value="green-icon",setTimeout(()=>{location.href=location.href},1500)}catch(h){console.error(h),r.value="Erreur",s.value="alert",a.value="red-icon",l.value=!1}}return{__sfc:!0,text:r,icon:s,theme:a,isProcessing:l,title:c,refreshCache:T,refreshAllProjects:d,refreshSingleProject:v}}};var j=function(){var e=this,u=e._self._c,t=e._self._setupProxy;return u("div",{attrs:{id:"refresh-cache-button"}},[e.pageStatus!=="draft"?u("k-button",{attrs:{theme:t.theme,variant:"dimmed",icon:t.icon,title:t.title,disabled:t.isProcessing},on:{click:function(r){return t.refreshCache()}}},[e._v(e._s(t.text))]):e._e()],1)},y=[],w=_(g,j,y);const S=w.exports;window.panel.plugin("adrienpayet/refresh-cache-button",{components:{"refresh-cache-button":S}})})(); diff --git a/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue b/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue index 5fdb1cf..f6b12f0 100644 --- a/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue +++ b/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue @@ -7,6 +7,7 @@ :icon="icon" :title="title" @click="refreshCache()" + :disabled="isProcessing" >{{ text }} @@ -24,6 +25,8 @@ const { pageUri, pageStatus, lastCacheUpdate } = defineProps({ const text = ref("Rafraîchir"); const icon = ref("refresh"); const theme = ref("aqua-icon"); +const isProcessing = ref(false); + const title = computed(() => { return lastCacheUpdate?.length > 0 ? "Dernière mise à jour : " + lastCacheUpdate @@ -31,25 +34,91 @@ const title = computed(() => { }); async function refreshCache() { - text.value = "En cours…"; + isProcessing.value = true; icon.value = "loader"; theme.value = "orange-icon"; + // Pour les projets multiples (batch processing) + if (pageUri === 'projects') { + await refreshAllProjects(); + } else { + await refreshSingleProject(); + } +} + +async function refreshAllProjects() { + let offset = 0; + const limit = 10; // 10 projets par batch + let hasMore = true; + let total = 0; + + text.value = "En cours 0%"; + + try { + while (hasMore) { + const init = { + method: "POST", + "Content-Type": "application/json", + body: JSON.stringify({ + pageUri: 'projects', + offset, + limit + }), + }; + + const res = await fetch("/refresh-cache.json", init); + const json = await res.json(); + + if (json.status === "error") { + throw new Error(json.message); + } + + total = json.total; + hasMore = json.hasMore; + offset = json.nextOffset; + + // Mise à jour de la progression dans le texte du bouton + const progress = Math.round((json.processed / json.total) * 100); + text.value = `En cours ${progress}%`; + + console.log(`Batch terminé : ${json.processed}/${json.total} projets (${progress}%)`); + } + + // Succès + text.value = "Terminé"; + icon.value = "check"; + theme.value = "green-icon"; + + setTimeout(() => { + location.href = location.href; + }, 2000); + + } catch (error) { + console.error(error); + text.value = "Erreur"; + icon.value = "alert"; + theme.value = "red-icon"; + isProcessing.value = false; + } +} + +async function refreshSingleProject() { + text.value = "En cours…"; + const init = { method: "POST", "Content-Type": "application/json", body: JSON.stringify({ pageUri }), }; - const res = await fetch("/refresh-cache.json", init); - const json = await res.json(); + try { + const res = await fetch("/refresh-cache.json", init); + const json = await res.json(); + + if (json.status === "error") { + throw new Error(json.message); + } - if (json.status === "error") { - console.error(json); - text.value = "Erreur"; - icon.value = "alert"; - theme.value = "red-icon"; - } else { console.log(json); text.value = "Terminé"; icon.value = "check"; @@ -58,6 +127,13 @@ async function refreshCache() { setTimeout(() => { location.href = location.href; }, 1500); + + } catch (error) { + console.error(error); + text.value = "Erreur"; + icon.value = "alert"; + theme.value = "red-icon"; + isProcessing.value = false; } } diff --git a/public/site/plugins/refresh-cache-button/src/routes/refresh-cache.php b/public/site/plugins/refresh-cache-button/src/routes/refresh-cache.php index 207d34e..7f93443 100644 --- a/public/site/plugins/refresh-cache-button/src/routes/refresh-cache.php +++ b/public/site/plugins/refresh-cache-button/src/routes/refresh-cache.php @@ -1,5 +1,5 @@ '/refresh-cache.json', @@ -10,17 +10,42 @@ return [ if ($data->pageUri === 'projects') { $projects = page('projects')->children(); - foreach ($projects as $project) { - $project->rebuildStepsCache(); - $formatter = new IntlDateFormatter('fr_FR', IntlDateFormatter::SHORT, IntlDateFormatter::SHORT, 'Europe/Paris'); - $project->update([ - 'lastCacheUpdate' => $formatter->format(time()) - ]); + // Support du batch processing + $offset = isset($data->offset) ? intval($data->offset) : 0; + $limit = isset($data->limit) ? intval($data->limit) : 10; // 10 projets par batch par défaut + $total = $projects->count(); + + // Slice pour ne traiter qu'un batch + $batch = $projects->slice($offset, $limit); + $processed = 0; + + foreach ($batch as $project) { + try { + $project->rebuildStepsCache(); + $project->invalidateNotificationsCache(); + + $formatter = new IntlDateFormatter('fr_FR', IntlDateFormatter::SHORT, IntlDateFormatter::SHORT, 'Europe/Paris'); + $project->update([ + 'lastCacheUpdate' => $formatter->format(time()) + ]); + $processed++; + } catch (\Throwable $e) { + error_log("Error refreshing cache for project {$project->slug()}: " . $e->getMessage()); + } } + + $remaining = max(0, $total - ($offset + $processed)); + $hasMore = $remaining > 0; + return [ - 'satus' => 'success', - 'message' => 'Données des pages projets rafraîchies avec succès.' + 'status' => 'success', + 'message' => "Batch terminé : $processed projets traités.", + 'processed' => $offset + $processed, + 'total' => $total, + 'remaining' => $remaining, + 'hasMore' => $hasMore, + 'nextOffset' => $hasMore ? $offset + $limit : null ]; } else { try { @@ -41,7 +66,7 @@ return [ if (!$project) { return [ - 'satus' => 'error', + 'status' => 'error', 'message' => 'Impossible de rafraîchir les données de la page ' . $data->pageUri . '. Aucun projet correspondant.' ]; } @@ -55,7 +80,7 @@ return [ return [ - 'satus' => 'success', + 'status' => 'success', 'message' => 'Données de la page ' . $data->pageUri . ' rafraîchie avec succès.' ]; } diff --git a/public/site/templates/project.json.php b/public/site/templates/project.json.php index a099586..3a31cd1 100644 --- a/public/site/templates/project.json.php +++ b/public/site/templates/project.json.php @@ -1,5 +1,18 @@ option('adrienpayet.pdc-notifications.collector'); +$notifications = []; + +if ($notificationCollector && $kirby->user()) { + try { + $notifications = $notificationCollector->collect($page, $kirby->user()); + } catch (\Throwable $e) { + error_log("Error collecting notifications for project {$page->uri()}: " . $e->getMessage()); + $notifications = []; + } +} + $project = [ 'title' => $page->title()->value(), 'url' => $page->url(), @@ -11,7 +24,7 @@ $project = [ 'steps' => $page->getSteps(), 'designToLight' => $page->isDTLEnabled()->isTrue() ? processDTLProposals($page) : null, 'hasOptimizationRequest' => $page->hasOptimizationRequest()->isTrue(), - 'notifications' => $page->notifications()->yaml(), + 'notifications' => $notifications, ]; $pageData = array_merge($genericData, $project); diff --git a/public/site/templates/projects.json.php b/public/site/templates/projects.json.php index ba74b69..2b2eaad 100644 --- a/public/site/templates/projects.json.php +++ b/public/site/templates/projects.json.php @@ -7,24 +7,32 @@ if (!$kirby->user()) { ]); } -function getProjectData($project) -{ +function getProjectData($project, $user) +{ + // Utiliser getNotificationsLight() avec cache pour optimiser les performances + $notifications = []; + try { + $notifications = $project->getNotificationsLight($user); + } catch (\Throwable $e) { + error_log("Error getting notifications for project {$project->uri()}: " . $e->getMessage()); + $notifications = []; + } $data = [ - 'title' => $project->title()->value(), - 'url' => $project->url(), - 'uri' => '/' . $project->uri(), - 'modified' => $project->modified('Y-MM-d'), - 'currentStep' => $project->currentStep()->value(), - 'status' => $project->status(), - 'logo' => $project->client()->toPage() ? $project->client()->toPage()->logo()->toFile()->url() : '', - 'steps' => $project->getSteps(), - 'notifications' => Yaml::decode($project->notifications()->value), - 'uuid' => (string) $project->uuid(), - 'slug' => (string) $project->slug(), - 'isDTLEnabled' => $project->isDTLEnabled()->isTrue(), - 'hasOptimizationRequest' => $project->hasOptimizationRequest()->isTrue(), - ]; + 'title' => $project->title()->value(), + 'url' => $project->url(), + 'uri' => '/' . $project->uri(), + 'modified' => $project->modified('Y-MM-d'), + 'currentStep' => $project->currentStep()->value(), + 'status' => $project->status(), + 'logo' => $project->client()->toPage() ? $project->client()->toPage()->logo()->toFile()->url() : '', + 'steps' => $project->getSteps(), + 'notifications' => $notifications, + 'uuid' => (string) $project->uuid(), + 'slug' => (string) $project->slug(), + 'isDTLEnabled' => $project->isDTLEnabled()->isTrue(), + 'hasOptimizationRequest' => $project->hasOptimizationRequest()->isTrue(), + ]; if ($project->isDTLEnabled()) { $data['designToLight'] = processDTLProposals($project); @@ -33,8 +41,12 @@ function getProjectData($project) return $data; } +$currentUser = $kirby->user(); + try { - $children = $kirby->user()->role() == 'admin' ? $page->childrenAndDrafts()->map(fn($project) => getProjectData($project))->values() : $kirby->user()->projects()->toPages()->map(fn($project) => getProjectData($project))->values(); + $children = $currentUser->role() == 'admin' + ? $page->childrenAndDrafts()->map(fn($project) => getProjectData($project, $currentUser))->values() + : $currentUser->projects()->toPages()->map(fn($project) => getProjectData($project, $currentUser))->values(); } catch (\Throwable $th) { throw new Exception($th->getMessage() . ' line ' . $th->getLine() . ' in file ' . $th->getFile(), 1); $children = []; diff --git a/src/components/Selector.vue b/src/components/Selector.vue index 32521cb..c95299d 100644 --- a/src/components/Selector.vue +++ b/src/components/Selector.vue @@ -76,112 +76,149 @@ const { items, label, isCompareModeEnabled, index } = defineProps({ // Local state const currentValue = ref(null); -const syncing = ref(false); // empêche les réactions pendant les mises à jour programmatiques +const syncing = ref(false); -// Store const { activeTracks } = storeToRefs(useDialogStore()); -// Utils -function isSame(a, b) { - if (!a || !b) return false; - if (a.slug && b.slug) return a.slug === b.slug; - return a.title === b.title; +function normalizeSlug(slug) { + return slug.replace(/_/g, '-'); } -function toVariation(v) { - if (!v) return null; - return Array.isArray(v) ? v[v.length - 1] || null : v; +function areVariationsEqual(variationA, variationB) { + if (!variationA || !variationB) return false; + + if (variationA.slug && variationB.slug) { + return normalizeSlug(variationA.slug) === normalizeSlug(variationB.slug); + } + + return variationA.title === variationB.title; } -// Initialisation : remplir le 1er select localement ET initialiser le store -onBeforeMount(() => { +function extractVariation(value) { + if (!value) return null; + return Array.isArray(value) ? value[value.length - 1] || null : value; +} + +function convertValueForCompareMode(value, shouldBeArray) { + if (shouldBeArray) { + return value && !Array.isArray(value) ? [value] : value; + } else { + return Array.isArray(value) ? value[0] || null : value; + } +} + +function findMatchingVariationsInStore(storeVariations) { + return storeVariations.filter((storeVar) => + items.some((item) => areVariationsEqual(item, storeVar)) + ); +} + +function syncCurrentValueFromStore(storeVariations) { syncing.value = true; - if (index === 0) { - currentValue.value = items[0] || null; - // si le store est vide, initialiser avec la variation du premier sélecteur - if (!activeTracks.value || activeTracks.value.length === 0) { - const v = toVariation(items[0]); - if (v) activeTracks.value = [v]; - } + const matchedVariations = findMatchingVariationsInStore(storeVariations); + + if (isCompareModeEnabled) { + currentValue.value = matchedVariations.length ? [...matchedVariations] : []; } else { - // les autres ne forcent pas le store ; leur currentValue restera à null - currentValue.value = null; + currentValue.value = matchedVariations[0] || null; } nextTick(() => (syncing.value = false)); -}); +} + +function detectVariationChanges(newValues, oldValues) { + const newList = Array.isArray(newValues) + ? newValues + : newValues + ? [newValues] + : []; + const oldList = Array.isArray(oldValues) + ? oldValues + : oldValues + ? [oldValues] + : []; + + const addedVariation = newList.find( + (n) => !oldList.some((o) => areVariationsEqual(o, n)) + ); + const removedVariation = oldList.find( + (o) => !newList.some((n) => areVariationsEqual(n, o)) + ); + + return { addedVariation, removedVariation }; +} + +function handleVariationChange(newValue, oldValue) { + if (syncing.value) return; + + const { addedVariation, removedVariation } = detectVariationChanges( + newValue, + oldValue + ); + + if ( + addedVariation && + items.some((item) => areVariationsEqual(item, addedVariation)) + ) { + updateActiveTracks(addedVariation, 'add'); + } else if ( + removedVariation && + items.some((item) => areVariationsEqual(item, removedVariation)) + ) { + updateActiveTracks(removedVariation, 'remove'); + } +} -// Quand on bascule compare mode (objet <-> tableau) watch( () => isCompareModeEnabled, - (flag) => { + (shouldBeArray) => { syncing.value = true; - if (flag) { - if (currentValue.value && !Array.isArray(currentValue.value)) { - currentValue.value = [currentValue.value]; - } - } else { - if (Array.isArray(currentValue.value)) { - currentValue.value = currentValue.value[0] || null; - } - } + currentValue.value = convertValueForCompareMode( + currentValue.value, + shouldBeArray + ); nextTick(() => (syncing.value = false)); } ); -// Détection ajout / suppression dans le MultiSelect (côté composant) -// On n'agit que si l'ajout/suppression concerne une variation appartenant à `items` -watch( - currentValue, - (newVal, oldVal) => { - if (syncing.value) return; +watch(currentValue, handleVariationChange, { deep: true }); - const newItems = Array.isArray(newVal) ? newVal : newVal ? [newVal] : []; - const oldItems = Array.isArray(oldVal) ? oldVal : oldVal ? [oldVal] : []; - - const added = newItems.find((n) => !oldItems.some((o) => isSame(o, n))); - const removed = oldItems.find((o) => !newItems.some((n) => isSame(n, o))); - - if (added && items.some((it) => isSame(it, added))) { - selectTrack(added, 'add'); - } else if (removed && items.some((it) => isSame(it, removed))) { - selectTrack(removed, 'remove'); - } - }, - { deep: true } -); - -// Quand activeTracks change elsewhere -> synchroniser l'affichage local -// Mais n'adopter que les variations qui appartiennent à ce Selector (`items`) watch( activeTracks, - (newVal) => { - syncing.value = true; - - const storeList = Array.isArray(newVal) ? newVal : []; - // ne garder que les variations du store qui sont dans `items` - const matched = storeList.filter((av) => - items.some((it) => isSame(it, av)) - ); - - if (isCompareModeEnabled) { - currentValue.value = matched.length ? [...matched] : []; - } else { - currentValue.value = matched[0] || null; - } - - nextTick(() => (syncing.value = false)); + (storeVariations) => { + const variationsList = Array.isArray(storeVariations) + ? storeVariations + : []; + syncCurrentValueFromStore(variationsList); }, - { deep: true } + { deep: true, immediate: true } ); -// Logique centrale de sélection (ajout / suppression) -// Règles : -// - mode normal -> activeTracks = [variation] -// - mode comparaison -> conserver activeTracks[0] si possible; second élément ajouté/remplacé; suppression gère le cas de la suppression de la première -function selectTrack(track, action = 'add') { - const variation = toVariation(track); +function removeVariationFromActiveTracks(variation) { + activeTracks.value = activeTracks.value.filter( + (track) => !areVariationsEqual(track, variation) + ); +} + +function addVariationToActiveTracks(variation) { + const isAlreadyPresent = activeTracks.value.some((track) => + areVariationsEqual(track, variation) + ); + + if (isAlreadyPresent) return; + + if (activeTracks.value.length === 0) { + activeTracks.value = [variation]; + } else if (activeTracks.value.length === 1) { + activeTracks.value = [activeTracks.value[0], variation]; + } else { + activeTracks.value = [activeTracks.value[0], variation]; + } +} + +function updateActiveTracks(track, action = 'add') { + const variation = extractVariation(track); if (!variation) return; if (!isCompareModeEnabled) { @@ -190,34 +227,12 @@ function selectTrack(track, action = 'add') { } if (action === 'remove') { - const wasFirst = - activeTracks.value.length && isSame(activeTracks.value[0], variation); - activeTracks.value = activeTracks.value.filter( - (t) => !isSame(t, variation) - ); - - // si on a retiré la première et qu'il reste une piste, elle devient naturellement index 0 - // pas d'action supplémentaire nécessaire ici (déjà assuré par le filter) - return; - } - - // action === 'add' - if (activeTracks.value.some((t) => isSame(t, variation))) { - // déjà présent -> ignore - return; - } - - if (activeTracks.value.length === 0) { - activeTracks.value = [variation]; - } else if (activeTracks.value.length === 1) { - activeTracks.value = [activeTracks.value[0], variation]; + removeVariationFromActiveTracks(variation); } else { - // remplacer le 2e - activeTracks.value = [activeTracks.value[0], variation]; + addVariationToActiveTracks(variation); } } -// Helpers pour affichage (inchangés) function getFrontViewUrl(item) { if (!item) return ''; if (Array.isArray(item)) { @@ -231,8 +246,8 @@ function getFrontViewUrl(item) { } function setImage() { - return getFrontViewUrl(currentValue.value) - ? '--image: url(\'' + getFrontViewUrl(currentValue.value) + '\')' + return getFrontViewUrl(currentValue.value) + ? "--image: url('" + getFrontViewUrl(currentValue.value) + "')" : undefined; } @@ -250,7 +265,8 @@ function setImage() { padding: var(--space-8) var(--space-48) var(--space-8) var(--space-16); } .selector-dropdown.has-image, -.selector-dropdown.has-image :is(#selector-select, #selector-multiselect, [role='combobox']) { +.selector-dropdown.has-image + :is(#selector-select, #selector-multiselect, [role='combobox']) { padding-left: var(--space-64); } .selector-dropdown.has-image:before { @@ -290,7 +306,9 @@ function setImage() { cursor: pointer; } [role='combobox'] p, -.selector-dropdown [data-pc-section="labelcontainer"] > [data-pc-section='label'] { +.selector-dropdown + [data-pc-section='labelcontainer'] + > [data-pc-section='label'] { max-height: 1lh; overflow: hidden; text-overflow: ellipsis; diff --git a/src/components/project/brief/Intro.vue b/src/components/project/brief/Intro.vue deleted file mode 100644 index 7bd9155..0000000 --- a/src/components/project/brief/Intro.vue +++ /dev/null @@ -1,23 +0,0 @@ - - diff --git a/src/components/project/brief/ModeSelection.vue b/src/components/project/brief/ModeSelection.vue deleted file mode 100644 index 317ca3c..0000000 --- a/src/components/project/brief/ModeSelection.vue +++ /dev/null @@ -1,116 +0,0 @@ - - - - - diff --git a/src/components/project/cards/ClientBrief.vue b/src/components/project/cards/ClientBrief.vue index ca74589..27258f6 100644 --- a/src/components/project/cards/ClientBrief.vue +++ b/src/components/project/cards/ClientBrief.vue @@ -49,6 +49,6 @@ const pdf = computed(() => { }); function goToImagesBrief() { - router.push(location.pathname + "/client-brief?step=images"); + router.push(location.pathname + "/client-brief"); } diff --git a/src/components/project/cards/Images.vue b/src/components/project/cards/Images.vue index 463bfcc..c79fec9 100644 --- a/src/components/project/cards/Images.vue +++ b/src/components/project/cards/Images.vue @@ -56,8 +56,11 @@ const commentsCount = computed(() => { let count = 0; if (Array.isArray(step.files)) { + // Ne compter que les commentaires des images, pas des documents (PDFs) for (const file of step.files) { - count += file?.comments?.length || 0; + if (file.type === 'image') { + count += file?.comments?.length || 0; + } } } else { if (step.files?.dynamic) { diff --git a/src/components/project/virtual-sample/DynamicView.vue b/src/components/project/virtual-sample/DynamicView.vue index f89552b..60bcd8e 100644 --- a/src/components/project/virtual-sample/DynamicView.vue +++ b/src/components/project/virtual-sample/DynamicView.vue @@ -61,13 +61,14 @@ import { storeToRefs } from 'pinia'; import { usePageStore } from '../../../stores/page'; import { useDialogStore } from '../../../stores/dialog'; import { useVirtualSampleStore } from '../../../stores/virtualSample'; -import { useRoute } from 'vue-router'; +import { useRoute, useRouter } from 'vue-router'; import Interactive360 from './Interactive360.vue'; import SingleImage from './SingleImage.vue'; import Selector from '../../Selector.vue'; import slugify from 'slugify'; const route = useRoute(); +const router = useRouter(); const { page } = storeToRefs(usePageStore()); const { isCommentsOpen, isCommentPanelEnabled, activeTracks, openedFile } = @@ -92,41 +93,74 @@ const tracks = computed(() => { return list; }); -// ---------- INITIALISATION ---------- -// onBeforeMount : on initialise toujours activeTracks avec une VARIATION (jamais un track) -onBeforeMount(() => { - // essayer la hash en priorité - let initialVariation = null; +function normalizeSlug(slug) { + return slug.replace(/_/g, '-'); +} +function getVariationSlug(variation) { + return variation.slug || (variation.title ? slugify(variation.title) : null); +} + +function findVariationByHash(hashValue) { + const allVariations = tracks.value.flatMap((track) => track.variations || []); + const normalizedHash = normalizeSlug(hashValue); + + return allVariations.find((variation) => { + const variationSlug = getVariationSlug(variation); + if (!variationSlug) return false; + + const normalizedVariationSlug = normalizeSlug(variationSlug); + return normalizedVariationSlug === normalizedHash; + }); +} + +function getInitialVariation() { if (route?.hash && route.hash.length > 0) { - const variations = tracks.value.flatMap((t) => t.variations || []); - initialVariation = - variations.find((v) => v.slug === route.hash.substring(1)) || null; + const hashValue = route.hash.substring(1); + const variationFromHash = findVariationByHash(hashValue); + if (variationFromHash) return variationFromHash; } - // fallback : première variation du premier track - if (!initialVariation) { - initialVariation = tracks.value[0]?.variations?.[0] || null; - } + return tracks.value[0]?.variations?.[0] || null; +} - if (initialVariation) { - activeTracks.value = [initialVariation]; - } else { - activeTracks.value = []; // aucun contenu disponible - } -}); +function initializeActiveTracks() { + const initialVariation = getInitialVariation(); + activeTracks.value = initialVariation ? [initialVariation] : []; +} -// scroll si hash présent -onMounted(() => { - if (route.query?.comments) isCommentsOpen.value = true; +function normalizeUrlHash() { + if (route?.hash && route.hash.includes('_')) { + const normalizedHash = normalizeSlug(route.hash); + router.replace({ ...route, hash: normalizedHash }); + } +} + +function openCommentsIfRequested() { + if (route.query?.comments) { + isCommentsOpen.value = true; + } +} + +function scrollToHashTarget() { if (!route?.hash || route.hash.length === 0) return; - const selector = route.hash.replace('#', '#track--'); - const targetBtn = document.querySelector(selector); - if (targetBtn) targetBtn.scrollIntoView(); + const selectorId = route.hash.replace('#', '#track--'); + const targetButton = document.querySelector(selectorId); + if (targetButton) { + targetButton.scrollIntoView(); + } +} + +onBeforeMount(() => { + initializeActiveTracks(); }); -// ---------- COMPUTED / WATCH ---------- +onMounted(() => { + openCommentsIfRequested(); + normalizeUrlHash(); + scrollToHashTarget(); +}); const isSingleImage = computed(() => { return ( @@ -139,38 +173,52 @@ const singleFile = computed(() => { return isSingleImage.value ? activeTracks.value[0].files[0] : null; }); -watch( - singleFile, - (newValue) => { - if (newValue) openedFile.value = newValue; - }, - { immediate: true } -); - -// gestion du mode comparaison : fermer les commentaires, etc. -watch(isCompareModeEnabled, (newValue) => { - if (newValue) { - isCommentsOpen.value = false; - isCommentPanelEnabled.value = false; - } else { - isCommentPanelEnabled.value = true; +function updateOpenedFile(file) { + if (file) { + openedFile.value = file; } +} - // quand on quitte le mode comparaison on retire l'élément secondaire si nécessaire - if (!newValue && activeTracks.value.length === 2) { +function enableCompareModeUI() { + isCommentsOpen.value = false; + isCommentPanelEnabled.value = false; +} + +function disableCompareModeUI() { + isCommentPanelEnabled.value = true; + + if (activeTracks.value.length === 2) { activeTracks.value.pop(); } +} + +function updateUrlHash(firstTrack) { + const trackSlug = getVariationSlug(firstTrack); + if (!trackSlug) return; + + const currentHash = route.hash ? route.hash.substring(1) : ''; + const normalizedTrackSlug = normalizeSlug(trackSlug); + + if (currentHash !== normalizedTrackSlug) { + router.replace({ ...route, hash: '#' + normalizedTrackSlug }); + } +} + +watch(singleFile, updateOpenedFile, { immediate: true }); + +watch(isCompareModeEnabled, (isEnabled) => { + isEnabled ? enableCompareModeUI() : disableCompareModeUI(); }); -// ---------- UTIL / helper ---------- -function getCommentsCount(track) { - if (!track || !Array.isArray(track.files)) return undefined; - let count = 0; - for (const file of track.files) { - count += file?.comments?.length || 0; - } - return count > 0 ? count : undefined; -} +watch( + activeTracks, + (tracks) => { + if (tracks && tracks.length > 0) { + updateUrlHash(tracks[0]); + } + }, + { deep: true } +);