diff --git a/.gitignore b/.gitignore index bb14856..c192e22 100644 --- a/.gitignore +++ b/.gitignore @@ -91,10 +91,3 @@ public/vendor # Content # --------------- /public/content - -# Claude settings -# --------------- -.claude -/.claude/* - - diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index aa30802..6e95c05 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -56,7 +56,7 @@ deploy_preprod: build_prod: stage: build only: - - prod + - main image: composer:2 script: - apk add --no-cache nodejs npm diff --git a/CLAUDE_PROJECT_OVERVIEW.md b/CLAUDE_PROJECT_OVERVIEW.md deleted file mode 100644 index 4657285..0000000 --- a/CLAUDE_PROJECT_OVERVIEW.md +++ /dev/null @@ -1,311 +0,0 @@ -# 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 deleted file mode 100644 index 082ba05..0000000 --- a/public/.user.ini +++ /dev/null @@ -1,2 +0,0 @@ -; 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 d7b2100..7574be8 100644 --- a/public/site/blueprints/pages/client-brief.yml +++ b/public/site/blueprints/pages/client-brief.yml @@ -24,18 +24,6 @@ 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 5c96ee2..b65d99f 100644 --- a/public/site/blueprints/pages/extended-brief.yml +++ b/public/site/blueprints/pages/extended-brief.yml @@ -22,21 +22,6 @@ 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 6962aa5..946e238 100644 --- a/public/site/blueprints/pages/project.yml +++ b/public/site/blueprints/pages/project.yml @@ -21,7 +21,6 @@ tabs: fields: lastCacheUpdate: type: hidden - # Champs pour project-request isClientRequest: type: hidden default: "false" @@ -31,32 +30,6 @@ 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 37f3a33..72ea0da 100644 --- a/public/site/config/config.php +++ b/public/site/config/config.php @@ -40,7 +40,6 @@ 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 c30f93d..ab09290 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,9 +3,7 @@ // file.update:after return function ($newFile, $oldFile) { $project = $newFile->parent()->template() == 'project' ? $newFile->parent() : $newFile->parent()->parents()->findBy('template', 'project'); - if ($project) { - $project->rebuildStepsCache(); - // Invalider aussi le cache des notifications (commentaires sur fichiers, etc.) - $project->invalidateNotificationsCache(); + if ($project) { + $steps = $project->rebuildStepsCache(); } }; \ 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 f8d9675..bde728d 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,11 +1,9 @@ template() == 'project' ? $newPage : $newPage->parents()->findBy('template', 'project'); if ($project) { - $project->rebuildStepsCache(); - // Invalider aussi le cache des notifications (briefs validés, etc.) - $project->invalidateNotificationsCache(); + $steps = $project->rebuildStepsCache(); } }; \ No newline at end of file diff --git a/public/site/config/routes/migrate-notifications.php b/public/site/config/routes/migrate-notifications.php deleted file mode 100644 index 3aa033c..0000000 --- a/public/site/config/routes/migrate-notifications.php +++ /dev/null @@ -1,175 +0,0 @@ - '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 d1e4b84..0cc94c0 100644 --- a/public/site/config/routes/request-optimization-appointment.php +++ b/public/site/config/routes/request-optimization-appointment.php @@ -7,26 +7,37 @@ 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([ + "hasOptimizationRequest" => "true", + "optimizationRequestDetails" => esc("De la part de " . kirby()->user()->name() . " (" . kirby()->user()->email() . ") : \n\n" . "Objet : " . $data->subject . "\n" . $data->details) + ]); + } catch (\Throwable $th) { + return [ + "status" => "error", + "message" => "Can't update project " . $project->title()->value() . ". " . $th->getMessage() . " in " . $th->getFile() . " line " . $th->getLine() + ]; + } try { - $project->update([ - "hasOptimizationRequest" => "true", - "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" => [], - ]); + $date = new DateTime(); + $formattedDate = $date->format(DateTime::ISO8601); - // Note: Les notifications sont maintenant dérivées. - // Plus besoin d'appeler createNotification(). + $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); return [ "status" => "success", @@ -34,7 +45,7 @@ return [ } catch (\Throwable $th) { return [ "status" => "error", - "message" => "Can't update project " . $project->title()->value() . ". " . $th->getMessage() . " in " . $th->getFile() . " line " . $th->getLine() + "message" => "Can't create notification. " . $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 1b9b378..9a474d7 100644 --- a/public/site/config/routes/request-project-creation.php +++ b/public/site/config/routes/request-project-creation.php @@ -11,24 +11,15 @@ 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 " . $user->name() . " (" . $user->email() . ") : \n\n" . $data->details), + "requestDetails" => esc("Demande de " . kirby()->user()->name() . " (" . kirby()->user()->email() . ") : \n\n" . $data->details), "client" => [$client], "isClientRequest" => "true", - "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" => [], + "isDTLEnabled" => esc($data->isDTLEnabled) ] ]; @@ -36,8 +27,21 @@ return [ try { $newProject = $projects->createChild($projectData); - // Note: Les notifications sont maintenant dérivées. - // Plus besoin d'appeler createNotification(). + $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); return [ "status" => "success", diff --git a/public/site/config/routes/validate-brief.php b/public/site/config/routes/validate-brief.php index 5b16c44..f8e97f1 100644 --- a/public/site/config/routes/validate-brief.php +++ b/public/site/config/routes/validate-brief.php @@ -9,31 +9,27 @@ return [ $page = page($data->briefUri); $project = $page->parent(); - $user = kirby()->user(); - + try { + $newPage = $page->update([ + 'isValidated' => 'true' + ]); + $timezone = new DateTimeZone('Europe/Paris'); $dateTime = new DateTime('now', $timezone); - $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' => [], + $notification = [ + 'location' => [ + 'page' => $page, + ], + 'date' => $dateTime->format('Y-m-d\TH:i:sP'), + 'text' => "Nouveau brief", + 'author' => kirby()->user(), + 'id' => Str::uuid(), + 'type' => 'content' ]; - // 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(). + $project->createNotification($notification); return [ "success" => "'" . $project->title()->value() . "' brief validated." diff --git a/public/site/models/project.php b/public/site/models/project.php index 583bfeb..df39ff1 100644 --- a/public/site/models/project.php +++ b/public/site/models/project.php @@ -3,62 +3,18 @@ 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 @@ -203,7 +159,7 @@ class ProjectPage extends NotificationsPage { "physicalSample" => "échantillon physique", ]; - return $stepsLabel[$this->currentStep()->value()]; + return $stepsLabel[$this->currentStep()->value()] ?? "brief"; } // public function printManagers() { diff --git a/public/site/plugins/comments/routes/create.php b/public/site/plugins/comments/routes/create.php index 5641383..9db31cf 100644 --- a/public/site/plugins/comments/routes/create.php +++ b/public/site/plugins/comments/routes/create.php @@ -46,7 +46,6 @@ return [ 'author' => kirby()->user(), 'id' => Str::uuid(), 'type' => 'comment', - 'readby' => [], // Pour le système de notifications dérivées ]; if (isset($data->position->pageIndex)) { @@ -63,8 +62,11 @@ return [ echo json_encode(getFileData($newFile)); - // Note: Les notifications sont maintenant dérivées des commentaires. - // Plus besoin d'appeler createNotification(). + try { + $project->createNotification($commentData); + } catch (\Throwable $th) { + throw new Exception($th->getMessage() . '. line ' . $th->getLine() . ' in file ' . $th->getFile(), 1); + } exit; }, diff --git a/public/site/plugins/comments/routes/delete.php b/public/site/plugins/comments/routes/delete.php index 5f96a96..8bc9e07 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)); - // Note: Les notifications sont maintenant dérivées des commentaires. - // La suppression du commentaire supprime automatiquement la notification. + $project = $page->parents()->findBy('template', 'project'); + $project->deleteNotification($data->id); exit; }, diff --git a/public/site/plugins/comments/routes/reply.php b/public/site/plugins/comments/routes/reply.php index 2418dfe..68aeebd 100644 --- a/public/site/plugins/comments/routes/reply.php +++ b/public/site/plugins/comments/routes/reply.php @@ -31,19 +31,18 @@ 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 ]); - // Note: Les notifications sont maintenant dérivées des commentaires. - // Plus besoin d'appeler createNotification(). + $project = $page->parents()->findBy("template", "project"); + $project->createNotification($replyData); return getFileData($newFile); } diff --git a/public/site/plugins/notifications/index.php b/public/site/plugins/notifications/index.php index 9b46c7e..91da6f9 100644 --- a/public/site/plugins/notifications/index.php +++ b/public/site/plugins/notifications/index.php @@ -1,54 +1,27 @@ "models/ProjectPage.php", +], __DIR__); -// Charger les classes F::loadClasses([ - // 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", + // 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", ]); -// 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", [ - "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"), - ], + "routes" => [ + 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 deleted file mode 100644 index c06cc80..0000000 --- a/public/site/plugins/notifications/routes/mark-all-read.php +++ /dev/null @@ -1,42 +0,0 @@ - '(: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 deleted file mode 100644 index 736b39f..0000000 --- a/public/site/plugins/notifications/routes/mark-as-read.php +++ /dev/null @@ -1,46 +0,0 @@ - '(: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 deleted file mode 100644 index ec1714b..0000000 --- a/public/site/plugins/notifications/src/NotificationCollector.php +++ /dev/null @@ -1,181 +0,0 @@ -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 deleted file mode 100644 index cc11906..0000000 --- a/public/site/plugins/notifications/src/NotificationProvider.php +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index fc92ce1..0000000 --- a/public/site/plugins/notifications/src/providers/CommentProvider.php +++ /dev/null @@ -1,172 +0,0 @@ -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 deleted file mode 100644 index 94698ec..0000000 --- a/public/site/plugins/notifications/src/providers/ContentProvider.php +++ /dev/null @@ -1,135 +0,0 @@ -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 deleted file mode 100644 index cc3d186..0000000 --- a/public/site/plugins/notifications/src/providers/ProjectRequestProvider.php +++ /dev/null @@ -1,111 +0,0 @@ -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 deleted file mode 100644 index 467b245..0000000 --- a/public/site/plugins/notifications/src/providers/ReplyProvider.php +++ /dev/null @@ -1,142 +0,0 @@ -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 4d99982..8fc4d42 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 _(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}})})(); +(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}})})(); 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 f6b12f0..5fdb1cf 100644 --- a/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue +++ b/public/site/plugins/refresh-cache-button/src/components/RefreshCacheButton.vue @@ -7,7 +7,6 @@ :icon="icon" :title="title" @click="refreshCache()" - :disabled="isProcessing" >{{ text }} @@ -25,8 +24,6 @@ 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 @@ -34,91 +31,25 @@ const title = computed(() => { }); async function refreshCache() { - isProcessing.value = true; + text.value = "En cours…"; 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 }), }; - try { - const res = await fetch("/refresh-cache.json", init); - const json = await res.json(); - - if (json.status === "error") { - throw new Error(json.message); - } + const res = await fetch("/refresh-cache.json", init); + const json = await res.json(); + 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"; @@ -127,13 +58,6 @@ async function refreshSingleProject() { 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 7f93443..207d34e 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,42 +10,17 @@ return [ if ($data->pageUri === 'projects') { $projects = page('projects')->children(); + foreach ($projects as $project) { + $project->rebuildStepsCache(); - // 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()); - } + $formatter = new IntlDateFormatter('fr_FR', IntlDateFormatter::SHORT, IntlDateFormatter::SHORT, 'Europe/Paris'); + $project->update([ + 'lastCacheUpdate' => $formatter->format(time()) + ]); } - - $remaining = max(0, $total - ($offset + $processed)); - $hasMore = $remaining > 0; - return [ - 'status' => 'success', - 'message' => "Batch terminé : $processed projets traités.", - 'processed' => $offset + $processed, - 'total' => $total, - 'remaining' => $remaining, - 'hasMore' => $hasMore, - 'nextOffset' => $hasMore ? $offset + $limit : null + 'satus' => 'success', + 'message' => 'Données des pages projets rafraîchies avec succès.' ]; } else { try { @@ -66,7 +41,7 @@ return [ if (!$project) { return [ - 'status' => 'error', + 'satus' => 'error', 'message' => 'Impossible de rafraîchir les données de la page ' . $data->pageUri . '. Aucun projet correspondant.' ]; } @@ -80,7 +55,7 @@ return [ return [ - 'status' => 'success', + 'satus' => '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 3a31cd1..a099586 100644 --- a/public/site/templates/project.json.php +++ b/public/site/templates/project.json.php @@ -1,18 +1,5 @@ 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(), @@ -24,7 +11,7 @@ $project = [ 'steps' => $page->getSteps(), 'designToLight' => $page->isDTLEnabled()->isTrue() ? processDTLProposals($page) : null, 'hasOptimizationRequest' => $page->hasOptimizationRequest()->isTrue(), - 'notifications' => $notifications, + 'notifications' => $page->notifications()->yaml(), ]; $pageData = array_merge($genericData, $project); diff --git a/public/site/templates/projects.json.php b/public/site/templates/projects.json.php index 2b2eaad..ba74b69 100644 --- a/public/site/templates/projects.json.php +++ b/public/site/templates/projects.json.php @@ -7,32 +7,24 @@ if (!$kirby->user()) { ]); } -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 = []; - } +function getProjectData($project) +{ $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' => $notifications, - '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' => Yaml::decode($project->notifications()->value), + 'uuid' => (string) $project->uuid(), + 'slug' => (string) $project->slug(), + 'isDTLEnabled' => $project->isDTLEnabled()->isTrue(), + 'hasOptimizationRequest' => $project->hasOptimizationRequest()->isTrue(), + ]; if ($project->isDTLEnabled()) { $data['designToLight'] = processDTLProposals($project); @@ -41,12 +33,8 @@ function getProjectData($project, $user) return $data; } -$currentUser = $kirby->user(); - try { - $children = $currentUser->role() == 'admin' - ? $page->childrenAndDrafts()->map(fn($project) => getProjectData($project, $currentUser))->values() - : $currentUser->projects()->toPages()->map(fn($project) => getProjectData($project, $currentUser))->values(); + $children = $kirby->user()->role() == 'admin' ? $page->childrenAndDrafts()->map(fn($project) => getProjectData($project))->values() : $kirby->user()->projects()->toPages()->map(fn($project) => getProjectData($project))->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 c95299d..32521cb 100644 --- a/src/components/Selector.vue +++ b/src/components/Selector.vue @@ -76,149 +76,112 @@ const { items, label, isCompareModeEnabled, index } = defineProps({ // Local state const currentValue = ref(null); -const syncing = ref(false); +const syncing = ref(false); // empêche les réactions pendant les mises à jour programmatiques +// Store const { activeTracks } = storeToRefs(useDialogStore()); -function normalizeSlug(slug) { - return slug.replace(/_/g, '-'); +// 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 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; +function toVariation(v) { + if (!v) return null; + return Array.isArray(v) ? v[v.length - 1] || null : v; } -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) { +// Initialisation : remplir le 1er select localement ET initialiser le store +onBeforeMount(() => { syncing.value = true; - const matchedVariations = findMatchingVariationsInStore(storeVariations); - - if (isCompareModeEnabled) { - currentValue.value = matchedVariations.length ? [...matchedVariations] : []; + 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]; + } } else { - currentValue.value = matchedVariations[0] || null; + // les autres ne forcent pas le store ; leur currentValue restera à null + currentValue.value = 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, - (shouldBeArray) => { + (flag) => { syncing.value = true; - currentValue.value = convertValueForCompareMode( - currentValue.value, - shouldBeArray - ); + 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; + } + } nextTick(() => (syncing.value = false)); } ); -watch(currentValue, handleVariationChange, { deep: true }); - +// Détection ajout / suppression dans le MultiSelect (côté composant) +// On n'agit que si l'ajout/suppression concerne une variation appartenant à `items` watch( - activeTracks, - (storeVariations) => { - const variationsList = Array.isArray(storeVariations) - ? storeVariations - : []; - syncCurrentValueFromStore(variationsList); + currentValue, + (newVal, oldVal) => { + if (syncing.value) return; + + 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, immediate: true } + { deep: true } ); -function removeVariationFromActiveTracks(variation) { - activeTracks.value = activeTracks.value.filter( - (track) => !areVariationsEqual(track, variation) - ); -} +// 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; -function addVariationToActiveTracks(variation) { - const isAlreadyPresent = activeTracks.value.some((track) => - areVariationsEqual(track, variation) - ); + 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 (isAlreadyPresent) return; + if (isCompareModeEnabled) { + currentValue.value = matched.length ? [...matched] : []; + } else { + currentValue.value = matched[0] || null; + } - 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]; - } -} + nextTick(() => (syncing.value = false)); + }, + { deep: true } +); -function updateActiveTracks(track, action = 'add') { - const variation = extractVariation(track); +// 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); if (!variation) return; if (!isCompareModeEnabled) { @@ -227,12 +190,34 @@ function updateActiveTracks(track, action = 'add') { } if (action === 'remove') { - removeVariationFromActiveTracks(variation); + 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]; } else { - addVariationToActiveTracks(variation); + // remplacer le 2e + activeTracks.value = [activeTracks.value[0], variation]; } } +// Helpers pour affichage (inchangés) function getFrontViewUrl(item) { if (!item) return ''; if (Array.isArray(item)) { @@ -246,8 +231,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; } @@ -265,8 +250,7 @@ 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 { @@ -306,9 +290,7 @@ 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 new file mode 100644 index 0000000..7bd9155 --- /dev/null +++ b/src/components/project/brief/Intro.vue @@ -0,0 +1,23 @@ + + diff --git a/src/components/project/brief/ModeSelection.vue b/src/components/project/brief/ModeSelection.vue new file mode 100644 index 0000000..317ca3c --- /dev/null +++ b/src/components/project/brief/ModeSelection.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/src/components/project/cards/ClientBrief.vue b/src/components/project/cards/ClientBrief.vue index 27258f6..ca74589 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"); + router.push(location.pathname + "/client-brief?step=images"); } diff --git a/src/components/project/cards/Images.vue b/src/components/project/cards/Images.vue index c79fec9..463bfcc 100644 --- a/src/components/project/cards/Images.vue +++ b/src/components/project/cards/Images.vue @@ -56,11 +56,8 @@ 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) { - if (file.type === 'image') { - count += file?.comments?.length || 0; - } + 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 60bcd8e..f89552b 100644 --- a/src/components/project/virtual-sample/DynamicView.vue +++ b/src/components/project/virtual-sample/DynamicView.vue @@ -61,14 +61,13 @@ import { storeToRefs } from 'pinia'; import { usePageStore } from '../../../stores/page'; import { useDialogStore } from '../../../stores/dialog'; import { useVirtualSampleStore } from '../../../stores/virtualSample'; -import { useRoute, useRouter } from 'vue-router'; +import { useRoute } 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 } = @@ -93,74 +92,41 @@ const tracks = computed(() => { return list; }); -function normalizeSlug(slug) { - return slug.replace(/_/g, '-'); -} +// ---------- INITIALISATION ---------- +// onBeforeMount : on initialise toujours activeTracks avec une VARIATION (jamais un track) +onBeforeMount(() => { + // essayer la hash en priorité + let initialVariation = null; -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 hashValue = route.hash.substring(1); - const variationFromHash = findVariationByHash(hashValue); - if (variationFromHash) return variationFromHash; + const variations = tracks.value.flatMap((t) => t.variations || []); + initialVariation = + variations.find((v) => v.slug === route.hash.substring(1)) || null; } - return tracks.value[0]?.variations?.[0] || null; -} - -function initializeActiveTracks() { - const initialVariation = getInitialVariation(); - activeTracks.value = initialVariation ? [initialVariation] : []; -} - -function normalizeUrlHash() { - if (route?.hash && route.hash.includes('_')) { - const normalizedHash = normalizeSlug(route.hash); - router.replace({ ...route, hash: normalizedHash }); + // fallback : première variation du premier track + if (!initialVariation) { + initialVariation = tracks.value[0]?.variations?.[0] || null; } -} -function openCommentsIfRequested() { - if (route.query?.comments) { - isCommentsOpen.value = true; + if (initialVariation) { + activeTracks.value = [initialVariation]; + } else { + activeTracks.value = []; // aucun contenu disponible } -} +}); -function scrollToHashTarget() { +// scroll si hash présent +onMounted(() => { + if (route.query?.comments) isCommentsOpen.value = true; if (!route?.hash || route.hash.length === 0) return; - const selectorId = route.hash.replace('#', '#track--'); - const targetButton = document.querySelector(selectorId); - if (targetButton) { - targetButton.scrollIntoView(); - } -} - -onBeforeMount(() => { - initializeActiveTracks(); + const selector = route.hash.replace('#', '#track--'); + const targetBtn = document.querySelector(selector); + if (targetBtn) targetBtn.scrollIntoView(); }); -onMounted(() => { - openCommentsIfRequested(); - normalizeUrlHash(); - scrollToHashTarget(); -}); +// ---------- COMPUTED / WATCH ---------- const isSingleImage = computed(() => { return ( @@ -173,52 +139,38 @@ const singleFile = computed(() => { return isSingleImage.value ? activeTracks.value[0].files[0] : null; }); -function updateOpenedFile(file) { - if (file) { - openedFile.value = file; +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 enableCompareModeUI() { - isCommentsOpen.value = false; - isCommentPanelEnabled.value = false; -} - -function disableCompareModeUI() { - isCommentPanelEnabled.value = true; - - if (activeTracks.value.length === 2) { + // quand on quitte le mode comparaison on retire l'élément secondaire si nécessaire + if (!newValue && 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(); }); -watch( - activeTracks, - (tracks) => { - if (tracks && tracks.length > 0) { - updateUrlHash(tracks[0]); - } - }, - { deep: true } -); +// ---------- 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; +}