Compare commits

..

7 commits

Author SHA1 Message Date
isUnknown
c9aefe7ecf #179 2025-10-15 16:17:30 +02:00
isUnknown
d590c9ac45 fix CI 2025-10-08 15:58:25 +02:00
isUnknown
186f7b80ba fix CI 2025-10-08 15:56:15 +02:00
isUnknown
1dad073ea1 #178 2025-10-08 15:54:17 +02:00
isUnknown
729d72e18a fix CI 2025-10-08 15:48:35 +02:00
isUnknown
95bdf4615d fix CI 2025-10-08 15:47:45 +02:00
isUnknown
5e78e67fc8 remove cache busting 2025-10-08 15:34:53 +02:00
44 changed files with 499 additions and 2224 deletions

7
.gitignore vendored
View file

@ -91,10 +91,3 @@ public/vendor
# Content
# ---------------
/public/content
# Claude settings
# ---------------
.claude
/.claude/*

View file

@ -56,7 +56,7 @@ deploy_preprod:
build_prod:
stage: build
only:
- prod
- main
image: composer:2
script:
- apk add --no-cache nodejs npm

View file

@ -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.

View file

@ -1,2 +0,0 @@
; Augmentation temporaire de la limite mémoire pour le chargement des notifications
memory_limit = 512M

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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'),

View file

@ -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();
}
};

View file

@ -1,11 +1,9 @@
<?php
// page.update:after && page.changeStatus:after
return function($newPage, $oldPage) {
return function($newPage, $oldPage) {
$project = $newPage->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();
}
};

View file

@ -1,175 +0,0 @@
<?php
/**
* Script de migration unique pour le système de notifications dérivées.
*
* Ce script copie les `readby[]` des anciennes notifications vers les sources de données.
* À exécuter une seule fois après le déploiement, puis à supprimer.
*
* Usage: POST /migrate-notifications.json
*/
return [
'pattern' => '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
];
}
];

View file

@ -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()
];
}
}

View file

@ -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",

View file

@ -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."

View file

@ -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() {

View file

@ -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;
},

View file

@ -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;
},

View file

@ -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);
}

View file

@ -1,54 +1,27 @@
<?php
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;
load([
"ProjectPage" => "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")
],
]);

View file

@ -1,42 +0,0 @@
<?php
/**
* Route pour marquer toutes les notifications comme lues.
* Parcourt tous les projets accessibles à l'utilisateur.
*/
return [
'pattern' => '(: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()
]);
}
}
];

View file

@ -1,46 +0,0 @@
<?php
/**
* Route pour marquer une notification comme lue.
* Délègue au bon provider selon le type de notification.
*/
return [
'pattern' => '(: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()
]);
}
}
];

View file

@ -1,181 +0,0 @@
<?php
namespace adrienpayet\notifications;
use Kirby\Cms\Page;
use Kirby\Cms\User;
/**
* Collecteur de notifications qui agrège tous les providers.
*
* Permet de :
* - Enregistrer des providers de notifications
* - Collecter toutes les notifications de tous les providers
* - Déléguer le markAsRead au bon provider
*/
class NotificationCollector
{
/** @var NotificationProvider[] */
private array $providers = [];
/**
* Enregistre un nouveau provider.
*/
public function register(NotificationProvider $provider): void
{
$this->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);
}
}

View file

@ -1,42 +0,0 @@
<?php
namespace adrienpayet\notifications;
use Kirby\Cms\Page;
use Kirby\Cms\User;
/**
* Interface pour les providers de notifications.
*
* Chaque type de notification (comment, project-request, etc.)
* a son propre provider qui sait :
* - Collecter les notifications depuis la source de données
* - Marquer une notification comme lue sur la source
*/
interface NotificationProvider
{
/**
* Retourne le type de notification géré par ce provider.
* Ex: 'comment', 'comment-reply', 'project-request'
*/
public function getType(): string;
/**
* Collecte toutes les notifications de ce type pour un projet et un utilisateur.
*
* @param Page $project Le projet à scanner
* @param User $user L'utilisateur courant (pour filtrer ses propres actions)
* @return array Liste des notifications au format standard
*/
public function collect(Page $project, User $user): array;
/**
* Marque une notification comme lue.
*
* @param string $id L'identifiant de la notification
* @param array $location Informations de localisation (ex: _file, _projectUri)
* @param User $user L'utilisateur qui marque comme lu
* @return bool True si succès, false sinon
*/
public function markAsRead(string $id, array $location, User $user): bool;
}

View file

@ -1,111 +0,0 @@
<?php
namespace adrienpayet\notifications\providers;
use adrienpayet\notifications\NotificationProvider;
use Kirby\Cms\Page;
use Kirby\Cms\User;
use Kirby\Data\Yaml;
/**
* Provider pour les notifications de type "appointment-request".
* Dérivé depuis les champs du projet quand hasOptimizationRequest est true.
*/
class AppointmentRequestProvider implements NotificationProvider
{
public function getType(): string
{
return 'appointment-request';
}
public function collect(Page $project, User $user): array
{
// Pas de notification si pas de demande d'optimisation
if ($project->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;
}
}

View file

@ -1,172 +0,0 @@
<?php
namespace adrienpayet\notifications\providers;
use adrienpayet\notifications\NotificationProvider;
use Kirby\Cms\Page;
use Kirby\Cms\User;
use Kirby\Data\Yaml;
/**
* Provider pour les notifications de type "comment".
* Collecte les commentaires depuis les fichiers des étapes du projet.
*/
class CommentProvider implements NotificationProvider
{
public function getType(): string
{
return 'comment';
}
public function collect(Page $project, User $user): array
{
$notifications = [];
$userUuid = (string) $user->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;
}
}

View file

@ -1,135 +0,0 @@
<?php
namespace adrienpayet\notifications\providers;
use adrienpayet\notifications\NotificationProvider;
use Kirby\Cms\Page;
use Kirby\Cms\User;
use Kirby\Data\Yaml;
/**
* Provider pour les notifications de type "content".
* Dérivé depuis les briefs validés (isValidated = true).
*/
class ContentProvider implements NotificationProvider
{
public function getType(): string
{
return 'content';
}
public function collect(Page $project, User $user): array
{
$notifications = [];
$userUuid = (string) $user->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;
}
}

View file

@ -1,111 +0,0 @@
<?php
namespace adrienpayet\notifications\providers;
use adrienpayet\notifications\NotificationProvider;
use Kirby\Cms\Page;
use Kirby\Cms\User;
use Kirby\Data\Yaml;
/**
* Provider pour les notifications de type "project-request".
* Dérivé depuis les champs du projet quand isClientRequest est true.
*/
class ProjectRequestProvider implements NotificationProvider
{
public function getType(): string
{
return 'project-request';
}
public function collect(Page $project, User $user): array
{
// Pas de notification si ce n'est pas une demande client
if ($project->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;
}
}

View file

@ -1,142 +0,0 @@
<?php
namespace adrienpayet\notifications\providers;
use adrienpayet\notifications\NotificationProvider;
use Kirby\Cms\Page;
use Kirby\Cms\User;
use Kirby\Data\Yaml;
/**
* Provider pour les notifications de type "comment-reply".
* Collecte les réponses aux commentaires depuis les fichiers.
*/
class ReplyProvider implements NotificationProvider
{
public function getType(): string
{
return 'comment-reply';
}
public function collect(Page $project, User $user): array
{
$notifications = [];
$userUuid = (string) $user->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;
}
}

View file

@ -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}})})();

View file

@ -7,7 +7,6 @@
:icon="icon"
:title="title"
@click="refreshCache()"
:disabled="isProcessing"
>{{ text }}</k-button
>
</div>
@ -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;
}
}
</script>

View file

@ -1,5 +1,5 @@
<?php
set_time_limit(60);
set_time_limit(0);
return [
'pattern' => '/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.'
];
}

View file

@ -1,18 +1,5 @@
<?php
// Récupérer le collector de notifications pour ce projet individuel
$notificationCollector = $kirby->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);

View file

@ -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 = [];

View file

@ -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;
}
</script>
@ -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;

View file

@ -0,0 +1,23 @@
<template>
<section class="h-full | grid-center">
<div
class="card | items-center | text-center | w-full max-w"
style="--row-gap: var(--space-32); --max-w: 27.5rem"
>
<h2 class="font-serif text-lg">Créez votre premier brief de projet !</h2>
<p class="text-grey-700">
Bienvenu à votre nouvel espace de projet. <br />Commencez par consulter
les inspirations <br />et partagez vos intentions !
</p>
<button
class="btn | w-full"
@click="emit('update:step', 'ModeSelection')"
>
Commencer
</button>
</div>
</section>
</template>
<script setup>
const emit = defineEmits(["update:step"]);
</script>

View file

@ -0,0 +1,116 @@
<template>
<section
class="h-full | flex flex-col justify-center items-center | mx-auto | max-w"
style="--max-w: 42rem; --row-gap: var(--space-32)"
>
<div class="flex items-baseline">
<div
@click="emit('update:step', 'Images')"
class="card card--cta | flex-1 | h-full"
style="--padding: var(--space-32); --row-gap: var(--space-32)"
>
<svg
aria-hidden="true"
width="100"
height="100"
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
stroke-width="5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M92.8571 46.4292H64.2857C62.3133 46.4292 60.7143 48.0282 60.7143 50.0006V92.8577C60.7143 94.8302 62.3133 96.4292 64.2857 96.4292H92.8571C94.8296 96.4292 96.4286 94.8302 96.4286 92.8577V50.0006C96.4286 48.0282 94.8296 46.4292 92.8571 46.4292Z"
/>
<path
d="M92.8571 3.57202H64.2857C62.3133 3.57202 60.7143 5.171 60.7143 7.14345V21.5006C60.7143 23.473 62.3133 25.072 64.2857 25.072H92.8571C94.8296 25.072 96.4286 23.473 96.4286 21.5006V7.14345C96.4286 5.171 94.8296 3.57202 92.8571 3.57202Z"
/>
<path
d="M35.7143 3.57202H7.14284C5.17039 3.57202 3.57141 5.171 3.57141 7.14345V50.0006C3.57141 51.973 5.17039 53.572 7.14284 53.572H35.7143C37.6867 53.572 39.2857 51.973 39.2857 50.0006V7.14345C39.2857 5.171 37.6867 3.57202 35.7143 3.57202Z"
/>
<path
d="M35.7143 74.9291H7.14284C5.17039 74.9291 3.57141 76.5281 3.57141 78.5005V92.8577C3.57141 94.8301 5.17039 96.4291 7.14284 96.4291H35.7143C37.6867 96.4291 39.2857 94.8301 39.2857 92.8577V78.5005C39.2857 76.5281 37.6867 74.9291 35.7143 74.9291Z"
/>
</svg>
<h2 class="font-serif text-lg">Créer via la plateforme</h2>
<p class="text-sm text-grey-700">
Ajouter différents éléments tels que des images et du texte sur la
plateforme afin de créer votre brief.
</p>
</div>
<div
class="card card--cta | flex-1 | h-full"
style="--padding: var(--space-32); --row-gap: var(--space-32)"
>
<svg
aria-hidden="true"
width="100"
height="100"
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
stroke-width="5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M3.57153 75.0001V82.143C3.57153 85.9318 5.07663 89.5654 7.75572 92.2445C10.4348 94.9236 14.0684 96.4287 17.8572 96.4287H82.143C85.9318 96.4287 89.5654 94.9236 92.2445 92.2445C94.9236 89.5654 96.4287 85.9318 96.4287 82.143V75.0001M28.5715 28.5715L50.0001 3.57153M50.0001 3.57153L71.4287 28.5715M50.0001 3.57153V67.8573"
/>
</svg>
<label class="font-serif text-lg" for="upload-pdf">
Ajouter un PDF
<input
id="upload-pdf"
type="file"
@change="addPdf($event, page.uri, true)"
accept="application/pdf"
ref="pdfInput"
hidden
/>
</label>
<p class="text-sm text-grey-700">
Vous avez déjà constitué votre brief en amont et souhaitez directement
limporter.
</p>
</div>
</div>
<div
class="card | bg-grey-200 | items-center | text-center | w-full"
style="--padding: var(--space-32); --row-gap: var(--space-16)"
>
<h2 class="font-serif text-lg">Quest ce que le brief ?</h2>
<p class="text-sm text-grey-700">
Le brief est un outil créatif qui permet de définir les perspectives
esthétiques de votre projet.
</p>
</div>
</section>
</template>
<script setup>
import { ref } from "vue";
import { usePageStore } from "../../../stores/page";
import { storeToRefs } from "pinia";
import { useBriefStore } from "../../../stores/brief";
const emit = defineEmits("update:step");
const { page } = storeToRefs(usePageStore());
const { addPdf } = useBriefStore();
const pdfInput = ref(null);
</script>
<style scoped>
label[for="upload-pdf"]::after {
content: "";
display: block;
position: absolute;
inset: 0;
cursor: pointer;
}
</style>

View file

@ -49,6 +49,6 @@ const pdf = computed(() => {
});
function goToImagesBrief() {
router.push(location.pathname + "/client-brief");
router.push(location.pathname + "/client-brief?step=images");
}
</script>

View file

@ -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) {

View file

@ -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;
}
</script>
<style>

View file

@ -163,34 +163,6 @@ export const useApiStore = defineStore("api", () => {
}
}
/**
* Marque une notification comme lue.
* @param {Object} notification - L'objet notification complet (avec type, id, _file, _projectUri, etc.)
*/
async function markNotificationRead(notification) {
const headers = {
method: "POST",
body: JSON.stringify(notification),
};
try {
const response = await fetch("/mark-notification-read.json", headers);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.status === "error") {
throw new Error(data.message);
}
// Mettre à jour le store local
userStore.markNotificationRead(notification.id, notification.project?.uri || notification._projectUri);
return data;
} catch (error) {
console.error("Erreur lors du marquage de la notification:", error);
throw error;
}
}
// Ancienne fonction gardée pour rétro-compatibilité
async function readNotification(notificationId, projectId) {
const headers = {
method: "POST",
@ -243,31 +215,6 @@ export const useApiStore = defineStore("api", () => {
}
}
/**
* Marque toutes les notifications comme lues (nouveau système).
*/
async function markAllNotificationsRead() {
try {
const response = await fetch("/mark-all-notifications-read.json", {
method: "POST",
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.status === "error") {
throw new Error(data.message);
}
userStore.markAllNotificationsRead();
console.log("Toutes les notifications ont été marquées comme lues.");
return data;
} catch (error) {
console.error("Erreur lors du marquage de toutes les notifications:", error);
throw error;
}
}
// Ancienne fonction gardée pour rétro-compatibilité
async function readAllNotifications() {
try {
const response = await fetch("/read-all-notifications.json");
@ -296,10 +243,6 @@ export const useApiStore = defineStore("api", () => {
updateComment,
deleteComment,
replyComment,
// Nouvelles fonctions
markNotificationRead,
markAllNotificationsRead,
// Anciennes fonctions (rétro-compatibilité)
readNotification,
readAllNotifications,
validateBrief,

View file

@ -11,76 +11,48 @@ export const useUserStore = defineStore('user', () => {
const { projects } = storeToRefs(useProjectsStore());
/**
* Liste des notifications agrégées depuis tous les projets.
* Les notifications sont maintenant dérivées côté backend avec isRead pré-calculé.
*/
const notifications = computed(() => {
if (!projects.value || !user.value) return [];
return projects.value.flatMap((project) => {
return projects.value?.flatMap((project) => {
if (!project.notifications) return [];
return project.notifications.map((notification) => ({
...notification,
project: project,
// isRead est maintenant fourni par le backend
}));
return project.notifications
.filter((notification) => notification.author.uuid !== user.value.uuid)
.map((notification) => ({
...notification,
project: project,
isRead: notification.readby?.includes(user.value.uuid),
}));
});
});
/**
* Marque une notification comme lue dans le store local.
* @param {string} notificationId - L'ID de la notification
* @param {string} projectUri - L'URI du projet (optionnel, pour retrouver le projet)
*/
function markNotificationRead(notificationId, projectUri = null) {
if (!user.value?.uuid) return;
projects.value = projects.value.map((project) => {
// Si projectUri fourni, cibler le bon projet
if (projectUri && project.uri !== projectUri && `/${project.uri}` !== projectUri) {
return project;
}
return {
...project,
notifications: (project.notifications || []).map((notification) =>
notification.id === notificationId
? {
...notification,
isRead: true,
readby: [...new Set([...(notification.readby || []), user.value.uuid])],
}
: notification
),
};
});
}
/**
* Marque toutes les notifications comme lues dans le store local.
*/
function markAllNotificationsRead() {
if (!user.value?.uuid) return;
function readNotification(notificationId, projectId) {
console.log('Read notification', notificationId, projectId);
projects.value = projects.value.map((project) => ({
...project,
notifications: (project.notifications || []).map((notification) => ({
...notification,
isRead: true,
readby: [...new Set([...(notification.readby || []), user.value.uuid])],
})),
notifications:
project.uuid === projectId || project.uri === projectId
? project.notifications.map((notification) =>
notification.id === notificationId
? {
...notification,
readby: [
...new Set([...notification.readby, user.value.uuid]),
],
}
: notification
)
: project.notifications,
}));
}
// Anciennes fonctions gardées pour rétro-compatibilité
function readNotification(notificationId, projectId) {
markNotificationRead(notificationId, projectId);
}
function readAllNotifications() {
markAllNotificationsRead();
projects.value = projects.value.map((project) => ({
...project,
notifications: project.notifications.map((notification) => ({
...notification,
readby: [...new Set([...notification.readby, user.value.uuid])],
})),
}));
}
function canEditComment(comment) {
@ -91,10 +63,6 @@ export const useUserStore = defineStore('user', () => {
user,
isLogged,
notifications,
// Nouvelles fonctions
markNotificationRead,
markAllNotificationsRead,
// Anciennes fonctions (rétro-compatibilité)
readNotification,
readAllNotifications,
canEditComment,

View file

@ -25,18 +25,49 @@
Valider et envoyer le brief
</button>
</header>
<Images />
<component :is="stepsComponents[currentStep]" @update:step="changeStep" />
</main>
</template>
<script setup>
import { ref } from 'vue';
import Intro from '../components/project/brief/Intro.vue';
import ModeSelection from '../components/project/brief/ModeSelection.vue';
import Images from '../components/project/brief/Images.vue';
import TitledPdfWrapper from '../components/project/TitledPdfWrapper.vue';
import { usePageStore } from '../stores/page';
import { storeToRefs } from 'pinia';
import { useApiStore } from '../stores/api';
import { useRoute } from 'vue-router';
const stepsComponents = {
Intro,
ModeSelection,
Images,
TitledPdfWrapper,
};
const { page } = storeToRefs(usePageStore());
const api = useApiStore();
const currentStep = ref(setInitialStep());
function changeStep(stepName) {
currentStep.value = stepName;
}
function setInitialStep() {
if (useRoute().query.step === 'images') {
return 'Images';
}
const hasPDF = page.value?.content?.pdf?.length !== 0;
const hasImages =
page.value.content?.moodboard.length !== 0 ||
page.value.content.description.length !== 0;
const isEmpty = !hasPDF && !hasImages;
if (isEmpty) return 'Intro';
if (hasImages) return 'Images';
}
function validate() {
api.validateBrief(page.value.uri).then((res) => {
location.href = '/' + page.value.parent;

View file

@ -119,24 +119,14 @@ function changeTab(newValue) {
function readAll() {
try {
api.markAllNotificationsRead();
api.readAllNotifications();
} catch (error) {
console.log('Could not read all notifications : ', error);
}
}
// Functions
async function handleNotificationClick(notification) {
// Marquer la notification comme lue
if (!notification.isRead) {
try {
await api.markNotificationRead(notification);
} catch (error) {
console.log('Could not mark notification as read:', error);
}
}
// Naviguer vers la cible
function handleNotificationClick(notification) {
const href =
notification.type === 'appointment-request'
? getHref(notification) + '?tab=designToLight'
@ -151,11 +141,6 @@ async function handleNotificationClick(notification) {
function getHref(notification) {
const uri = notification.location.page.uri;
// Pour les notifications de type "content" (brief validé), utiliser dialogUri si présent
if (notification.type === 'content' && notification.dialogUri) {
return notification.dialogUri;
}
const isDocumentBrief =
notification.location.page.template === 'client-brief' &&
notification.location?.file?.type === 'document';

View file

@ -14,10 +14,9 @@ export default defineConfig(({ mode }) => {
},
},
sourcemap: mode === 'staging',
minify: mode === 'production' ? 'esbuild' : false,
minify: mode === 'production',
},
server: {
cors: true,
watch: {
ignored: [
'**/node_modules/**',