Compare commits

...
Sign in to create a new pull request.

19 commits

Author SHA1 Message Date
isUnknown
6b80e242b8 Fix virtual sample routing and refactor for clarity
Virtual sample variations now display correctly when loading from URL hash.
Old URLs with underscores are normalized to hyphens on load. URL hash
updates automatically when navigating between variations.

Refactored both DynamicView and Selector components with explicit function
names, removed unnecessary comments, and improved code organization.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 13:54:36 +01:00
isUnknown
dfb8d1038b Fix routing vers une piste spécifique avec hash
Problème : L'URL avec hash (#serumwc_lasertone_empty) n'ouvrait pas la bonne
piste/variation mais toujours la première.

Cause : Incohérence entre les underscores du hash et les tirets du slug backend.
slugify convertit les underscores en tirets, mais les slugs Kirby peuvent
varier.

Solution : Comparer le hash de 3 façons :
1. Comparaison directe
2. Hash avec underscores → tirets
3. Slug avec tirets → underscores

Cela gère tous les cas de figure.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 12:29:49 +01:00
isUnknown
95a8bf99cb build plugin refresh cache 2026-01-15 12:19:35 +01:00
isUnknown
378af9ac96 Fix : affichage progression dans le texte du bouton
La div en dessous ne s'affichait pas dans le panel Kirby.
La progression s'affiche maintenant directement dans le bouton :
"En cours 0%" → "En cours 20%" → "En cours 100%" → "Terminé"

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 12:18:33 +01:00
isUnknown
4669f03f16 Amélioration affichage progression du refresh cache
Ajout d'une ligne de texte sous le bouton pour afficher la progression :
- "Traitement : 10/50 projets (20%)" pendant le traitement
- "50 projets mis à jour avec succès" à la fin
- Tooltip aussi mis à jour avec la progression

Le bouton affiche "En cours…" et la progression détaillée est en dessous.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 12:13:26 +01:00
isUnknown
a57b0c203a Optimisation du refresh cache avec batch processing
Problème : Le refresh cache de tous les projets timeout côté serveur à cause
du trop grand nombre de projets à traiter en une seule requête.

Solution : Batch processing avec indicateur de progression
- Backend : traite 10 projets par batch avec offset/limit
- Frontend : fait plusieurs requêtes successives et affiche la progression
- Timeout réduit à 60s par batch au lieu de illimité
- Bouton désactivé pendant le traitement
- Ajout invalidateNotificationsCache() pour vider aussi ce cache

Affichage : "15/50 (30%)" pendant le traitement, puis "Terminé (50)"

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 12:08:13 +01:00
isUnknown
86db1f5a0c Fix collectLight() : inclure author, text, location pour l'affichage
Problème : collectLight() ne retournait que id/type/isRead/date, causant
notification.author undefined dans le frontend.

Solution : Inclure tous les champs nécessaires à l'affichage (author, text,
location) mais toujours alléger en excluant les gros détails inutiles.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 11:55:17 +01:00
isUnknown
2791bc4462 Ajout invalidation cache notifications dans hook file-update
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 11:42:40 +01:00
isUnknown
bb71da081b Ajout du système de cache pour les notifications
Problème : Les notifications étaient collectées à chaque requête sur
projects.json, causant des problèmes de performance et de mémoire.

Solution : Mise en cache des notifications par projet et par utilisateur
- Nouvelle méthode getNotificationsLight() dans ProjectPage avec cache
- Cache invalidé automatiquement via les hooks existants (page/file update)
- Cache par utilisateur pour inclure le isRead spécifique

Performance : Les notifications sont calculées une fois puis servies depuis
le cache jusqu'à ce qu'un changement survienne (commentaire, brief, etc.)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 11:42:20 +01:00
isUnknown
e73e25b1da Ajout .user.ini : augmentation limite mémoire PHP à 512M
Temporaire pour gérer le chargement des notifications de tous les projets.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 11:19:14 +01:00
isUnknown
0a980603a4 Ajout de collectLight() pour optimiser le chargement du listing des projets
Problème : projects.json.php causait un dépassement mémoire en collectant
toutes les notifications complètes (avec author, location, text, etc.) pour
tous les projets.

Solution : Nouvelle méthode collectLight() qui ne retourne que les données
minimales nécessaires au frontend pour afficher les indicateurs :
- id, type, isRead, date
- location.project.uri (pour le filtrage)

Les détails complets sont toujours chargés dans project.json.php individuel.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 11:18:59 +01:00
isUnknown
0250dc1487 Fix : problème de mémoire lors du chargement des projets
Problème : projects.json.php collectait les notifications dérivées pour TOUS
les projets d'un coup, ce qui causait un dépassement de mémoire (HTTP 500).

Solution :
- projects.json.php : Ne collecte plus les notifications (retourne [])
- project.json.php : Collecte les notifications uniquement pour le projet affiché

Les notifications seront chargées à la demande quand on ouvre un projet,
pas lors du listing initial.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 11:16:17 +01:00
isUnknown
f614884da0 update project overview 2026-01-15 11:03:50 +01:00
isUnknown
9d12ccb209 Fix : ne compter que les commentaires des images, pas ceux du PDF
Problème : Dans le kanban, la carte du brief client custom (Images) affichait
aussi le nombre de commentaires du PDF, alors qu'il n'y a pas de système de
commentaires pour les images du brief custom.

Solution : Filtrer pour ne compter que les commentaires des fichiers de type
'image', et non tous les fichiers du step.

Bonus : Suppression du paramètre obsolète ?step=images dans ClientBrief.vue

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 10:58:41 +01:00
isUnknown
cfd679bc15 Suppression des composants obsolètes Intro et ModeSelection
Ces composants faisaient partie de l'ancien système de steps du Brief
qui a été supprimé. Ils ne sont plus utilisés.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 10:53:50 +01:00
isUnknown
04d8da39fd Simplification : Brief.vue affiche toujours Images directement
Suppression du système de steps obsolète (Intro → ModeSelection → Images).
/client-brief affiche maintenant toujours le composant Images, sans conditions
ni paramètres d'URL (?step=images).

Les briefs avec PDF sont gérés via les dialogues uniquement.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 10:52:12 +01:00
isUnknown
6ff59e9b07 Fix : URL correcte pour notifications de brief validé depuis PDF + redirect briefs vides
Problème 1 : Les notifications de brief validé depuis un PDF renvoyaient vers
/projects/xxx/client-brief au lieu de l'URL complète avec dialog et fileIndex.

Problème 2 : Les URL /projects/xxx/client-brief pour des briefs non créés
affichaient une page vide au lieu de rediriger vers le kanban.

Solutions :
- Stocker validationDialogUri lors de la validation du brief
- Utiliser ce dialogUri dans ContentProvider et Notifications.vue
- Rediriger vers le projet parent si brief vide et non validé

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-15 10:44:30 +01:00
isUnknown
a7d315942a Refonte du système de notifications : passage aux notifications dérivées
Remplace le système de notifications stockées par un système de providers
qui dérivent les notifications des données existantes (commentaires, réponses,
demandes de projet, demandes de rendez-vous, validations de brief).

- Ajout du NotificationCollector et de l'interface NotificationProvider
- Création de 5 providers : Comment, Reply, ProjectRequest, AppointmentRequest, Content
- Métadonnées de notifications stockées directement sur les entités source
- Nouvelles routes mark-as-read et mark-all-read
- Mise à jour du frontend pour le nouveau système
- Route de migration pour les données existantes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 10:31:31 +01:00
isUnknown
c68b51f639 git : ignore claude settings 2026-01-14 14:55:12 +01:00
43 changed files with 2224 additions and 499 deletions

7
.gitignore vendored
View file

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

311
CLAUDE_PROJECT_OVERVIEW.md Normal file
View file

@ -0,0 +1,311 @@
# Design to Pack - Vue d'ensemble du projet
Plateforme de gestion de projets de création de flacons de parfum pour Pochet du Courval.
## Stack technique
| Couche | Technologies |
|--------|-------------|
| **Backend** | Kirby CMS 4 (PHP), flat-file database |
| **Frontend** | Vue 3 + Vite 7, Pinia, Vue Router 4, PrimeVue 4.0 |
| **PDF** | @vue-pdf-viewer 2.5 |
| **3D** | Three.js (vue interactive 360) |
| **Déploiement** | GitLab CI/CD, rsync vers serveur |
---
## Structure du projet
```
design-to-pack/
├── src/ # App Vue.js
│ ├── assets/css/ # Styles globaux
│ ├── components/ # Composants réutilisables
│ │ ├── comments/ # Système de commentaires
│ │ ├── design-to-light/ # Feature DTL
│ │ ├── inspirations/ # Galerie inspirations
│ │ ├── notifications/ # Notifications
│ │ └── project/ # Composants projet
│ │ ├── cards/ # Cartes par type d'étape
│ │ ├── brief/ # Brief client (moodboard)
│ │ └── virtual-sample/ # Échantillon virtuel 3D
│ ├── router/ # Vue Router
│ ├── stores/ # Pinia stores
│ ├── utils/ # Utilitaires
│ ├── views/ # Pages principales
│ ├── main.js # Point d'entrée
│ └── App.vue # Composant racine
├── public/ # Kirby CMS
│ ├── content/ # Données (flat-file)
│ │ ├── projects/ # Pages projets
│ │ ├── clients/ # Pages clients
│ │ ├── design-to-light/ # Page DTL
│ │ └── inspirations/ # Galerie inspirations
│ ├── site/
│ │ ├── blueprints/ # Schémas de données
│ │ │ ├── pages/ # Blueprints des pages
│ │ │ ├── users/ # Blueprints utilisateurs
│ │ │ └── files/ # Blueprints fichiers
│ │ ├── templates/ # Templates PHP + JSON
│ │ ├── controllers/ # Contrôleurs
│ │ ├── models/ # Modèles PHP (Project, Client)
│ │ ├── plugins/ # Plugins custom
│ │ ├── snippets/ # Fragments réutilisables
│ │ └── config/ # Configuration + routes
│ └── media/ # Fichiers uploadés
├── package.json
├── vite.config.js
└── .gitlab-ci.yml
```
---
## Plugins Kirby custom
### 1. `classes/` - Classes partagées
Classes de données utilisées par comments et notifications.
| Classe | Rôle |
|--------|------|
| `Author` | Auteur (name, email, uuid, role) |
| `Position` | Position x/y + pageIndex (marqueurs sur PDF) |
| `Location` | Localisation (page, file, parent) |
| `PageDetails` | Détails de page |
| `FileDetails` | Détails de fichier |
| `ProjectDetails` | Détails de projet |
### 2. `comments/` - Système de commentaires
Plugin `adrienpayet/kirby4-comments`
**Classes:**
- `BaseComment` - Classe de base
- `Comment` - Commentaire avec replies
- `Reply` - Réponse à un commentaire
**Routes:**
| Route | Fichier | Description |
|-------|---------|-------------|
| `POST /create-comment.json` | `routes/create.php` | Créer un commentaire |
| `POST /update-comment.json` | `routes/update.php` | Modifier un commentaire |
| `POST /delete-comment.json` | `routes/delete.php` | Supprimer un commentaire |
| `POST /reply-comment.json` | `routes/reply.php` | Répondre à un commentaire |
**Stockage:** Les commentaires sont stockés en YAML dans les métadonnées des fichiers.
### 3. `notifications/` - Système de notifications
Plugin `adrienpayet/pdc-notifications`
**Classes:**
- `Notification` - Notification (type, location, text, author, date, readby[])
- `NotificationsPage` - Base pour pages avec notifications (extends Page)
**Méthodes NotificationsPage:**
- `createNotification($data)` - Créer une notification
- `deleteNotification($id)` - Supprimer une notification
- `readNotification($id)` - Marquer comme lue (ajoute userUuid à readby)
- `readAllNotifications()` - Tout marquer comme lu
**Routes:**
| Route | Description |
|-------|-------------|
| `POST /read-notification.json` | Marquer une notification comme lue |
| `POST /read-all-notifications.json` | Tout marquer comme lu |
### 4. `user-projects/` - Projets autorisés par utilisateur
Plugin `adrienpayet/pdc-authorized-projects`
**User methods:**
- `currentProjects()` - Projets actifs (listed) accessibles à l'utilisateur
- `archivedProjects()` - Projets archivés (unlisted) accessibles
Logique: Admin = tous les projets, autres = seulement projets assignés.
### 5. `helpers/` - Fonctions utilitaires
| Fonction | Description |
|----------|-------------|
| `getFileData($file, $preserveQuality)` | Normalise les données fichier (thumb webp, cover, comments) |
| `getGlobalEvaluation($numberedGrade)` | Convertit note numérique en lettre A-E avec mention |
| `processDTLProposals($page)` | Traite les propositions Design to Light |
| `refreshProjectStepsCache($project, $steps)` | Rafraîchit le cache des étapes |
### 6. `icons/` - Icônes custom panel
Plugin `adrienpayet/pochet-icons` - Icônes personnalisées pour le panel Kirby.
### 7. `kql/` - Kirby Query Language
Plugin externe pour requêtes type GraphQL.
### 8. `refresh-cache-button/` - Bouton refresh cache
Plugin externe ajoutant un bouton de rafraîchissement du cache dans le panel.
---
## Modèles de données
### Utilisateurs (3 rôles)
| Rôle | Accès |
|------|-------|
| `admin` | Tous les projets, panel complet |
| `pochet` | Projets assignés uniquement, panel limité |
| `client` | Ses projets uniquement, pas de panel |
### Projet (ProjectPage)
Hérite de `NotificationsPage`.
**Champs principaux:**
- `title`, `status` (draft/listed/unlisted)
- `client` - Lien vers ClientPage
- `currentStep` - Étape courante
- `isDTLEnabled` - Design to Light activé
**Étapes (children):**
1. `client-brief` - Brief client (PDF + moodboard)
2. `proposal` - Proposition commerciale (PDFs)
3. `extended-brief` - Brief étendu
4. `industrial-ideation` - Idéation industrielle (optionnel)
5. `virtual-sample` - Échantillon virtuel (pistes dynamiques + statiques)
6. `physical-sample` - Échantillon physique (médias)
### Client (ClientPage)
- `logo`, `title`
- `projects()` - Tous les projets
- `projectsListed()` / `projectsUnlisted()` - Filtres par statut
---
## Stores Pinia
| Store | Fichier | Rôle |
|-------|---------|------|
| `api` | `stores/api.js` | Communication API (fetch, post, comments, notifications) |
| `user` | `stores/user.js` | Utilisateur courant, permissions |
| `page` | `stores/page.js` | Données de la page courante |
| `projects` | `stores/projects.js` | Liste des projets |
| `dialog` | `stores/dialog.js` | État des modales (contenu, fichier, commentaires) |
| `brief` | `stores/brief.js` | Gestion du brief client |
| `designToLight` | `stores/designToLight.js` | Feature DTL |
| `notifications` | `stores/notifications.js` | Notifications non lues |
| `virtualSample` | `stores/virtualSample.js` | État échantillon virtuel |
| `addImagesModal` | `stores/addImagesModal.js` | Modal ajout images |
| `project` | `stores/project.js` | Utilitaires projet |
---
## Routes Vue
| Path | Vue | Description |
|------|-----|-------------|
| `/` | `Home.vue` | Liste des projets |
| `/login` | `Login.vue` | Authentification |
| `/account` | `Account.vue` | Compte utilisateur |
| `/notifications` | `Notifications.vue` | Centre de notifications |
| `/inspirations` | `Inspirations.vue` | Galerie d'inspirations |
| `/design-to-light` | `DesignToLight.vue` | Feature DTL |
| `/projects/:id` | `Kanban.vue` | Détail projet (kanban) |
| `/projects/:id/client-brief` | `Brief.vue` | Brief client |
| `/projects/:id/extended-brief` | `Brief.vue` | Brief étendu |
---
## API Endpoints
### Authentification
- `POST /login.json` - Connexion (email, password)
- `GET /logout` - Déconnexion
### Pages (JSON)
- `GET /{uri}.json` - Données page + user
### Commentaires
- `POST /create-comment.json`
- `POST /update-comment.json`
- `POST /delete-comment.json`
- `POST /reply-comment.json`
### Notifications
- `POST /read-notification.json`
- `POST /read-all-notifications.json`
### Fichiers
- `POST /upload-pdf.json`
- `POST /upload-images.json`
- `POST /remove-file.json`
### Actions
- `POST /save-page.json`
- `POST /save-file.json`
- `POST /validate-brief.json`
- `POST /toggle-favorite.json`
- `POST /request-project-creation.json`
- `POST /request-optimization-appointment.json`
---
## Design to Light (DTL)
Système d'évaluation avancée des designs de flacons.
**Notation:**
- Note globale : A (8-10), B (6-8), C (4-6), D (2-4), E (0-2)
- Indicateurs : Design global, Bague, Épaule, Colonnes & Arêtes, Pied, Fond de Verre
- Position : Complexité, Poids
**Propositions DTL liées à:**
- Proposition commerciale (PDF)
- Idéation industrielle (PDF)
- Échantillon virtuel - piste dynamique
- Échantillon virtuel - piste statique
---
## Fichiers clés à connaître
### Frontend
- `src/main.js` - Init app
- `src/router/router.js` - Guard + setup
- `src/router/routes.js` - Définition routes
- `src/stores/api.js` - Toute la comm API
- `src/components/Menu.vue` - Navigation latérale
- `src/components/project/DialogWrapper.vue` - Wrapper modales
### Backend
- `public/site/config/config.php` - Routes, hooks, config
- `public/site/controllers/site.php` - Contrôleur principal
- `public/site/models/project.php` - Logique projet
- `public/site/plugins/helpers/index.php` - Fonctions utilitaires
- `public/site/blueprints/pages/project.yml` - Structure projet
---
## Développement local
```bash
# Backend
cd public
composer install
php -S localhost:8888 kirby/router.php
# Frontend
npm install
npm run dev
```
## Build
```bash
npm run build # Production
npm run build:preprod # Staging (avec sourcemaps)
```
---
## Notes importantes
1. **Cache**: Les étapes projet sont cachées. Invalidation automatique via hooks Kirby.
2. **Permissions**: Filtrées côté serveur selon le rôle utilisateur.
3. **Commentaires**: Positionnés en % (x, y) + pageIndex pour les PDFs multi-pages.
4. **Notifications**: Stockées par projet, trackées par user UUID dans `readby[]`.
5. **Virtual Sample**: Pistes dynamiques = pages enfants, pistes statiques = fichiers.

2
public/.user.ini Normal file
View file

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

View file

@ -24,6 +24,18 @@ tabs:
type: hidden
isValidated:
type: hidden
validatedBy:
type: hidden
validatedByName:
type: hidden
validatedByEmail:
type: hidden
validatedAt:
type: hidden
validationReadby:
type: hidden
validationDialogUri:
type: hidden
pdf:
label: PDF
type: files

View file

@ -22,6 +22,21 @@ tabs:
fields:
stepName:
type: hidden
isValidated:
type: hidden
# Champs pour notification "content" (brief validé)
validatedBy:
type: hidden
validatedByName:
type: hidden
validatedByEmail:
type: hidden
validatedAt:
type: hidden
validationReadby:
type: hidden
validationDialogUri:
type: hidden
pdf:
label: PDF
type: files

View file

@ -21,6 +21,7 @@ tabs:
fields:
lastCacheUpdate:
type: hidden
# Champs pour project-request
isClientRequest:
type: hidden
default: "false"
@ -30,6 +31,32 @@ tabs:
disabled: true
when:
isClientRequest: "true"
requestAuthor:
type: hidden
requestAuthorName:
type: hidden
requestAuthorEmail:
type: hidden
requestDate:
type: hidden
requestReadby:
type: hidden
# Champs pour appointment-request (DTL)
hasOptimizationRequest:
type: hidden
default: "false"
optimizationRequestDetails:
type: hidden
optimizationAuthor:
type: hidden
optimizationAuthorName:
type: hidden
optimizationAuthorEmail:
type: hidden
optimizationDate:
type: hidden
optimizationReadby:
type: hidden
currentStep:
label: Étape en cours
type: radio

View file

@ -40,6 +40,7 @@ return [
require(__DIR__ . '/routes/validate-brief.php'),
require(__DIR__ . '/routes/request-project-creation.php'),
require(__DIR__ . '/routes/request-optimization-appointment.php'),
require(__DIR__ . '/routes/migrate-notifications.php'),
],
'hooks' => [
'page.create:after' => require_once(__DIR__ . '/hooks/create-steps.php'),

View file

@ -3,7 +3,9 @@
// file.update:after
return function ($newFile, $oldFile) {
$project = $newFile->parent()->template() == 'project' ? $newFile->parent() : $newFile->parent()->parents()->findBy('template', 'project');
if ($project) {
$steps = $project->rebuildStepsCache();
if ($project) {
$project->rebuildStepsCache();
// Invalider aussi le cache des notifications (commentaires sur fichiers, etc.)
$project->invalidateNotificationsCache();
}
};

View file

@ -1,9 +1,11 @@
<?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) {
$steps = $project->rebuildStepsCache();
$project->rebuildStepsCache();
// Invalider aussi le cache des notifications (briefs validés, etc.)
$project->invalidateNotificationsCache();
}
};

View file

@ -0,0 +1,175 @@
<?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,37 +7,26 @@ return [
$json = file_get_contents('php://input');
$data = json_decode($json);
$user = kirby()->user();
$user = kirby()->user();
$project = page($data->projectUri);
$date = new DateTime();
$formattedDate = $date->format(DateTime::ISO8601);
try {
$newProject = $project->update([
$project->update([
"hasOptimizationRequest" => "true",
"optimizationRequestDetails" => esc("De la part de " . kirby()->user()->name() . " (" . kirby()->user()->email() . ") : \n\n" . "Objet : " . $data->subject . "\n" . $data->details)
"optimizationRequestDetails" => esc("De la part de " . $user->name() . " (" . $user->email() . ") : \n\n" . "Objet : " . $data->subject . "\n" . $data->details),
// Métadonnées pour le système de notifications dérivées
"optimizationAuthor" => (string) $user->uuid(),
"optimizationAuthorName" => (string) $user->name(),
"optimizationAuthorEmail" => (string) $user->email(),
"optimizationDate" => $formattedDate,
"optimizationReadby" => [],
]);
} catch (\Throwable $th) {
return [
"status" => "error",
"message" => "Can't update project " . $project->title()->value() . ". " . $th->getMessage() . " in " . $th->getFile() . " line " . $th->getLine()
];
}
try {
$date = new DateTime();
$formattedDate = $date->format(DateTime::ISO8601);
$notificationData = [
"location" => [
"page" => $newProject
],
"date" => (string) $formattedDate,
"text" => nl2br("Objet : " . $data->subject . "\n" . esc($data->details)),
"author" => $user,
"id" => Str::uuid(),
"type" => "appointment-request",
];
$newProject->createNotification($notificationData);
// Note: Les notifications sont maintenant dérivées.
// Plus besoin d'appeler createNotification().
return [
"status" => "success",
@ -45,7 +34,7 @@ return [
} catch (\Throwable $th) {
return [
"status" => "error",
"message" => "Can't create notification. " . $th->getMessage() . " in " . $th->getFile() . " line " . $th->getLine()
"message" => "Can't update project " . $project->title()->value() . ". " . $th->getMessage() . " in " . $th->getFile() . " line " . $th->getLine()
];
}
}

View file

@ -11,15 +11,24 @@ return [
$client = kirby()->user()->client()->toPage()->uuid();
$date = new DateTime();
$formattedDate = $date->format(DateTime::ISO8601);
$projectData = [
"slug" => esc(Str::slug($data->title)),
"template" => "project",
"template" => "project",
"content" => [
"title" => esc($data->title),
"requestDetails" => esc("Demande de " . kirby()->user()->name() . " (" . kirby()->user()->email() . ") : \n\n" . $data->details),
"requestDetails" => esc("Demande de " . $user->name() . " (" . $user->email() . ") : \n\n" . $data->details),
"client" => [$client],
"isClientRequest" => "true",
"isDTLEnabled" => esc($data->isDTLEnabled)
"isDTLEnabled" => esc($data->isDTLEnabled),
// Métadonnées pour le système de notifications dérivées
"requestAuthor" => (string) $user->uuid(),
"requestAuthorName" => (string) $user->name(),
"requestAuthorEmail" => (string) $user->email(),
"requestDate" => $formattedDate,
"requestReadby" => [],
]
];
@ -27,21 +36,8 @@ return [
try {
$newProject = $projects->createChild($projectData);
$date = new DateTime();
$formattedDate = $date->format(DateTime::ISO8601);
$notificationData = [
"location" => [
"page" => $newProject
],
"date" => (string) $formattedDate,
"text" => nl2br(esc($data->details)),
"author" => $user,
"id" => Str::uuid(),
"type" => "project-request",
];
$newProject->createNotification($notificationData);
// Note: Les notifications sont maintenant dérivées.
// Plus besoin d'appeler createNotification().
return [
"status" => "success",

View file

@ -9,27 +9,31 @@ return [
$page = page($data->briefUri);
$project = $page->parent();
try {
$newPage = $page->update([
'isValidated' => 'true'
]);
$user = kirby()->user();
try {
$timezone = new DateTimeZone('Europe/Paris');
$dateTime = new DateTime('now', $timezone);
$notification = [
'location' => [
'page' => $page,
],
'date' => $dateTime->format('Y-m-d\TH:i:sP'),
'text' => "Nouveau brief",
'author' => kirby()->user(),
'id' => Str::uuid(),
'type' => 'content'
$updateData = [
'isValidated' => 'true',
// Métadonnées pour le système de notifications dérivées
'validatedBy' => (string) $user->uuid(),
'validatedByName' => (string) $user->name(),
'validatedByEmail' => (string) $user->email(),
'validatedAt' => $dateTime->format('Y-m-d\TH:i:sP'),
'validationReadby' => [],
];
$project->createNotification($notification);
// Si un dialogUri est fourni (validation depuis PDF), le stocker
if (isset($data->dialogUri) && !empty($data->dialogUri)) {
$updateData['validationDialogUri'] = (string) $data->dialogUri;
}
$newPage = $page->update($updateData);
// Note: Les notifications sont maintenant dérivées.
// Plus besoin d'appeler createNotification().
return [
"success" => "'" . $project->title()->value() . "' brief validated."

View file

@ -3,18 +3,62 @@
use adrienpayet\notifications\NotificationsPage;
class ProjectPage extends NotificationsPage {
public function getSteps() {
public function getSteps() {
$apiCache = kirby()->cache('api');
$stepsData = $apiCache?->get($this->slug() . '_' . 'steps');
$stepsData = $apiCache?->get($this->slug() . '_' . 'steps');
if ($stepsData === null || count($stepsData) === 0) {
$this->rebuildStepsCache();
};
$stepsData = $apiCache->get($this->slug() . '_' . 'steps');
$stepsData = $apiCache->get($this->slug() . '_' . 'steps');
return $stepsData;
}
/**
* Récupère les notifications pour ce projet (version allégée avec cache).
* Cache par utilisateur pour inclure le isRead.
*/
public function getNotificationsLight($user) {
if (!$user) {
return [];
}
$apiCache = kirby()->cache('api');
$cacheKey = $this->slug() . '_notifications_' . $user->uuid();
$notifications = $apiCache?->get($cacheKey);
// Si pas en cache, collecter et cacher
if ($notifications === null) {
$collector = kirby()->option('adrienpayet.pdc-notifications.collector');
if (!$collector) {
return [];
}
try {
$notifications = $collector->collectLight($this, $user);
$apiCache->set($cacheKey, $notifications);
} catch (\Throwable $e) {
error_log("Error caching notifications for {$this->slug()}: " . $e->getMessage());
return [];
}
}
return $notifications;
}
/**
* Invalide le cache des notifications de ce projet pour tous les utilisateurs.
*/
public function invalidateNotificationsCache() {
$apiCache = kirby()->cache('api');
// Invalider pour tous les users
foreach (kirby()->users() as $user) {
$cacheKey = $this->slug() . '_notifications_' . $user->uuid();
$apiCache->remove($cacheKey);
}
}
public function rebuildStepsCache() {
// Create steps

View file

@ -46,6 +46,7 @@ return [
'author' => kirby()->user(),
'id' => Str::uuid(),
'type' => 'comment',
'readby' => [], // Pour le système de notifications dérivées
];
if (isset($data->position->pageIndex)) {
@ -62,11 +63,8 @@ return [
echo json_encode(getFileData($newFile));
try {
$project->createNotification($commentData);
} catch (\Throwable $th) {
throw new Exception($th->getMessage() . '. line ' . $th->getLine() . ' in file ' . $th->getFile(), 1);
}
// Note: Les notifications sont maintenant dérivées des commentaires.
// Plus besoin d'appeler createNotification().
exit;
},

View file

@ -39,8 +39,8 @@ return [
echo json_encode(getFileData($newFile));
$project = $page->parents()->findBy('template', 'project');
$project->deleteNotification($data->id);
// Note: Les notifications sont maintenant dérivées des commentaires.
// La suppression du commentaire supprime automatiquement la notification.
exit;
},

View file

@ -31,18 +31,19 @@ return [
"author" => kirby()->user(),
"id" => Str::uuid(),
"type" => "comment-reply",
"readby" => [], // Pour le système de notifications dérivées
];
$newReply = new Reply($replyData);
$comment['replies'][] = $newReply->toArray();
}
}
$newFile = $file->update([
'comments' => $comments
]);
$project = $page->parents()->findBy("template", "project");
$project->createNotification($replyData);
// Note: Les notifications sont maintenant dérivées des commentaires.
// Plus besoin d'appeler createNotification().
return getFileData($newFile);
}

View file

@ -1,27 +1,54 @@
<?php
load([
"ProjectPage" => "models/ProjectPage.php",
], __DIR__);
use adrienpayet\notifications\NotificationCollector;
use adrienpayet\notifications\providers\CommentProvider;
use adrienpayet\notifications\providers\ReplyProvider;
use adrienpayet\notifications\providers\ProjectRequestProvider;
use adrienpayet\notifications\providers\AppointmentRequestProvider;
use adrienpayet\notifications\providers\ContentProvider;
// Charger les classes
F::loadClasses([
// Own classes
"adrienpayet\\notifications\\Notification" => __DIR__ . "/src/Notification.php",
"adrienpayet\\notifications\\NotificationsPage" => __DIR__ . "/src/NotificationsPage.php",
// Shared classes
"adrienpayet\\D2P\\data\\Position" => __DIR__ . "/../classes/Position.php",
"adrienpayet\\D2P\data\Author" => __DIR__ . "/../classes/Author.php",
"adrienpayet\\D2P\\data\\location\\Location" => __DIR__ . "/../classes/location/Location.php",
"adrienpayet\\D2P\\data\\location\\PageDetails" => __DIR__ . "/../classes/location/PageDetails.php",
"adrienpayet\\D2P\\data\\location\\ProjectDetails" => __DIR__ . "/../classes/location/ProjectDetails.php",
"adrienpayet\\D2P\\data\\location\\FileDetails" => __DIR__ . "/../classes/location/FileDetails.php",
// Nouvelles classes - Système de providers
"adrienpayet\\notifications\\NotificationProvider" => __DIR__ . "/src/NotificationProvider.php",
"adrienpayet\\notifications\\NotificationCollector" => __DIR__ . "/src/NotificationCollector.php",
"adrienpayet\\notifications\\providers\\CommentProvider" => __DIR__ . "/src/providers/CommentProvider.php",
"adrienpayet\\notifications\\providers\\ReplyProvider" => __DIR__ . "/src/providers/ReplyProvider.php",
"adrienpayet\\notifications\\providers\\ProjectRequestProvider" => __DIR__ . "/src/providers/ProjectRequestProvider.php",
"adrienpayet\\notifications\\providers\\AppointmentRequestProvider" => __DIR__ . "/src/providers/AppointmentRequestProvider.php",
"adrienpayet\\notifications\\providers\\ContentProvider" => __DIR__ . "/src/providers/ContentProvider.php",
// Anciennes classes - Gardées pour rétro-compatibilité pendant migration
"adrienpayet\\notifications\\Notification" => __DIR__ . "/src/Notification.php",
"adrienpayet\\notifications\\NotificationsPage" => __DIR__ . "/src/NotificationsPage.php",
// Classes partagées
"adrienpayet\\D2P\\data\\Position" => __DIR__ . "/../classes/Position.php",
"adrienpayet\\D2P\\data\\Author" => __DIR__ . "/../classes/Author.php",
"adrienpayet\\D2P\\data\\location\\Location" => __DIR__ . "/../classes/location/Location.php",
"adrienpayet\\D2P\\data\\location\\PageDetails" => __DIR__ . "/../classes/location/PageDetails.php",
"adrienpayet\\D2P\\data\\location\\ProjectDetails" => __DIR__ . "/../classes/location/ProjectDetails.php",
"adrienpayet\\D2P\\data\\location\\FileDetails" => __DIR__ . "/../classes/location/FileDetails.php",
]);
// Créer et configurer le collector
$collector = new NotificationCollector();
$collector->register(new CommentProvider());
$collector->register(new ReplyProvider());
$collector->register(new ProjectRequestProvider());
$collector->register(new AppointmentRequestProvider());
$collector->register(new ContentProvider());
Kirby::plugin("adrienpayet/pdc-notifications", [
"routes" => [
require(__DIR__ . "/routes/readAll.php"),
require(__DIR__ . "/routes/read.php")
],
"options" => [
"collector" => $collector
],
"routes" => [
// Nouvelles routes
require(__DIR__ . "/routes/mark-as-read.php"),
require(__DIR__ . "/routes/mark-all-read.php"),
// Anciennes routes - Gardées pour rétro-compatibilité
require(__DIR__ . "/routes/readAll.php"),
require(__DIR__ . "/routes/read.php"),
],
]);

View file

@ -0,0 +1,42 @@
<?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

@ -0,0 +1,46 @@
<?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

@ -0,0 +1,181 @@
<?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

@ -0,0 +1,42 @@
<?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

@ -0,0 +1,111 @@
<?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

@ -0,0 +1,172 @@
<?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

@ -0,0 +1,135 @@
<?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

@ -0,0 +1,111 @@
<?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

@ -0,0 +1,142 @@
<?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 f(n,e,a,t,r,c,s,u){var o=typeof n=="function"?n.options:n;return e&&(o.render=e,o.staticRenderFns=a,o._compiled=!0),{exports:n,options:o}}const l={__name:"RefreshCacheButton",props:{pageUri:String,pageStatus:String,lastCacheUpdate:String},setup(n){const{pageUri:e,pageStatus:a,lastCacheUpdate:t}=n,r=Vue.ref("Rafraîchir"),c=Vue.ref("refresh"),s=Vue.ref("aqua-icon"),u=Vue.computed(()=>(t==null?void 0:t.length)>0?"Dernière mise à jour : "+t:"Mettre à jour le cache front");async function o(){r.value="En cours…",c.value="loader",s.value="orange-icon";const m={method:"POST","Content-Type":"application/json",body:JSON.stringify({pageUri:e})},i=await(await fetch("/refresh-cache.json",m)).json();i.status==="error"?(console.error(i),r.value="Erreur",c.value="alert",s.value="red-icon"):(console.log(i),r.value="Terminé",c.value="check",s.value="green-icon",setTimeout(()=>{location.href=location.href},1500))}return{__sfc:!0,text:r,icon:c,theme:s,title:u,refreshCache:o}}};var h=function(){var e=this,a=e._self._c,t=e._self._setupProxy;return a("div",{attrs:{id:"refresh-cache-button"}},[e.pageStatus!=="draft"?a("k-button",{attrs:{theme:t.theme,variant:"dimmed",icon:t.icon,title:t.title},on:{click:function(r){return t.refreshCache()}}},[e._v(e._s(t.text))]):e._e()],1)},_=[],p=f(l,h,_);const d=p.exports;window.panel.plugin("adrienpayet/refresh-cache-button",{components:{"refresh-cache-button":d}})})();
(function(){"use strict";function _(n,e,u,t,r,s,a,l){var c=typeof n=="function"?n.options:n;return e&&(c.render=e,c.staticRenderFns=u,c._compiled=!0),{exports:n,options:c}}const g={__name:"RefreshCacheButton",props:{pageUri:String,pageStatus:String,lastCacheUpdate:String},setup(n){const{pageUri:e,pageStatus:u,lastCacheUpdate:t}=n,r=Vue.ref("Rafraîchir"),s=Vue.ref("refresh"),a=Vue.ref("aqua-icon"),l=Vue.ref(!1),c=Vue.computed(()=>(t==null?void 0:t.length)>0?"Dernière mise à jour : "+t:"Mettre à jour le cache front");async function T(){l.value=!0,s.value="loader",a.value="orange-icon",e==="projects"?await d():await v()}async function d(){let f=0;const h=10;let i=!0,b=0;r.value="En cours 0%";try{for(;i;){const p={method:"POST","Content-Type":"application/json",body:JSON.stringify({pageUri:"projects",offset:f,limit:h})},o=await(await fetch("/refresh-cache.json",p)).json();if(o.status==="error")throw new Error(o.message);b=o.total,i=o.hasMore,f=o.nextOffset;const m=Math.round(o.processed/o.total*100);r.value=`En cours ${m}%`,console.log(`Batch terminé : ${o.processed}/${o.total} projets (${m}%)`)}r.value="Terminé",s.value="check",a.value="green-icon",setTimeout(()=>{location.href=location.href},2e3)}catch(p){console.error(p),r.value="Erreur",s.value="alert",a.value="red-icon",l.value=!1}}async function v(){r.value="En cours…";const f={method:"POST","Content-Type":"application/json",body:JSON.stringify({pageUri:e})};try{const i=await(await fetch("/refresh-cache.json",f)).json();if(i.status==="error")throw new Error(i.message);console.log(i),r.value="Terminé",s.value="check",a.value="green-icon",setTimeout(()=>{location.href=location.href},1500)}catch(h){console.error(h),r.value="Erreur",s.value="alert",a.value="red-icon",l.value=!1}}return{__sfc:!0,text:r,icon:s,theme:a,isProcessing:l,title:c,refreshCache:T,refreshAllProjects:d,refreshSingleProject:v}}};var j=function(){var e=this,u=e._self._c,t=e._self._setupProxy;return u("div",{attrs:{id:"refresh-cache-button"}},[e.pageStatus!=="draft"?u("k-button",{attrs:{theme:t.theme,variant:"dimmed",icon:t.icon,title:t.title,disabled:t.isProcessing},on:{click:function(r){return t.refreshCache()}}},[e._v(e._s(t.text))]):e._e()],1)},y=[],w=_(g,j,y);const S=w.exports;window.panel.plugin("adrienpayet/refresh-cache-button",{components:{"refresh-cache-button":S}})})();

View file

@ -7,6 +7,7 @@
:icon="icon"
:title="title"
@click="refreshCache()"
:disabled="isProcessing"
>{{ text }}</k-button
>
</div>
@ -24,6 +25,8 @@ const { pageUri, pageStatus, lastCacheUpdate } = defineProps({
const text = ref("Rafraîchir");
const icon = ref("refresh");
const theme = ref("aqua-icon");
const isProcessing = ref(false);
const title = computed(() => {
return lastCacheUpdate?.length > 0
? "Dernière mise à jour : " + lastCacheUpdate
@ -31,25 +34,91 @@ const title = computed(() => {
});
async function refreshCache() {
text.value = "En cours…";
isProcessing.value = true;
icon.value = "loader";
theme.value = "orange-icon";
// Pour les projets multiples (batch processing)
if (pageUri === 'projects') {
await refreshAllProjects();
} else {
await refreshSingleProject();
}
}
async function refreshAllProjects() {
let offset = 0;
const limit = 10; // 10 projets par batch
let hasMore = true;
let total = 0;
text.value = "En cours 0%";
try {
while (hasMore) {
const init = {
method: "POST",
"Content-Type": "application/json",
body: JSON.stringify({
pageUri: 'projects',
offset,
limit
}),
};
const res = await fetch("/refresh-cache.json", init);
const json = await res.json();
if (json.status === "error") {
throw new Error(json.message);
}
total = json.total;
hasMore = json.hasMore;
offset = json.nextOffset;
// Mise à jour de la progression dans le texte du bouton
const progress = Math.round((json.processed / json.total) * 100);
text.value = `En cours ${progress}%`;
console.log(`Batch terminé : ${json.processed}/${json.total} projets (${progress}%)`);
}
// Succès
text.value = "Terminé";
icon.value = "check";
theme.value = "green-icon";
setTimeout(() => {
location.href = location.href;
}, 2000);
} catch (error) {
console.error(error);
text.value = "Erreur";
icon.value = "alert";
theme.value = "red-icon";
isProcessing.value = false;
}
}
async function refreshSingleProject() {
text.value = "En cours…";
const init = {
method: "POST",
"Content-Type": "application/json",
body: JSON.stringify({ pageUri }),
};
const res = await fetch("/refresh-cache.json", init);
const json = await res.json();
try {
const res = await fetch("/refresh-cache.json", init);
const json = await res.json();
if (json.status === "error") {
throw new Error(json.message);
}
if (json.status === "error") {
console.error(json);
text.value = "Erreur";
icon.value = "alert";
theme.value = "red-icon";
} else {
console.log(json);
text.value = "Terminé";
icon.value = "check";
@ -58,6 +127,13 @@ async function refreshCache() {
setTimeout(() => {
location.href = location.href;
}, 1500);
} catch (error) {
console.error(error);
text.value = "Erreur";
icon.value = "alert";
theme.value = "red-icon";
isProcessing.value = false;
}
}
</script>

View file

@ -1,5 +1,5 @@
<?php
set_time_limit(0);
set_time_limit(60);
return [
'pattern' => '/refresh-cache.json',
@ -10,17 +10,42 @@ return [
if ($data->pageUri === 'projects') {
$projects = page('projects')->children();
foreach ($projects as $project) {
$project->rebuildStepsCache();
$formatter = new IntlDateFormatter('fr_FR', IntlDateFormatter::SHORT, IntlDateFormatter::SHORT, 'Europe/Paris');
$project->update([
'lastCacheUpdate' => $formatter->format(time())
]);
// Support du batch processing
$offset = isset($data->offset) ? intval($data->offset) : 0;
$limit = isset($data->limit) ? intval($data->limit) : 10; // 10 projets par batch par défaut
$total = $projects->count();
// Slice pour ne traiter qu'un batch
$batch = $projects->slice($offset, $limit);
$processed = 0;
foreach ($batch as $project) {
try {
$project->rebuildStepsCache();
$project->invalidateNotificationsCache();
$formatter = new IntlDateFormatter('fr_FR', IntlDateFormatter::SHORT, IntlDateFormatter::SHORT, 'Europe/Paris');
$project->update([
'lastCacheUpdate' => $formatter->format(time())
]);
$processed++;
} catch (\Throwable $e) {
error_log("Error refreshing cache for project {$project->slug()}: " . $e->getMessage());
}
}
$remaining = max(0, $total - ($offset + $processed));
$hasMore = $remaining > 0;
return [
'satus' => 'success',
'message' => 'Données des pages projets rafraîchies avec succès.'
'status' => 'success',
'message' => "Batch terminé : $processed projets traités.",
'processed' => $offset + $processed,
'total' => $total,
'remaining' => $remaining,
'hasMore' => $hasMore,
'nextOffset' => $hasMore ? $offset + $limit : null
];
} else {
try {
@ -41,7 +66,7 @@ return [
if (!$project) {
return [
'satus' => 'error',
'status' => 'error',
'message' => 'Impossible de rafraîchir les données de la page ' . $data->pageUri . '. Aucun projet correspondant.'
];
}
@ -55,7 +80,7 @@ return [
return [
'satus' => 'success',
'status' => 'success',
'message' => 'Données de la page ' . $data->pageUri . ' rafraîchie avec succès.'
];
}

View file

@ -1,5 +1,18 @@
<?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(),
@ -11,7 +24,7 @@ $project = [
'steps' => $page->getSteps(),
'designToLight' => $page->isDTLEnabled()->isTrue() ? processDTLProposals($page) : null,
'hasOptimizationRequest' => $page->hasOptimizationRequest()->isTrue(),
'notifications' => $page->notifications()->yaml(),
'notifications' => $notifications,
];
$pageData = array_merge($genericData, $project);

View file

@ -7,24 +7,32 @@ if (!$kirby->user()) {
]);
}
function getProjectData($project)
{
function getProjectData($project, $user)
{
// Utiliser getNotificationsLight() avec cache pour optimiser les performances
$notifications = [];
try {
$notifications = $project->getNotificationsLight($user);
} catch (\Throwable $e) {
error_log("Error getting notifications for project {$project->uri()}: " . $e->getMessage());
$notifications = [];
}
$data = [
'title' => $project->title()->value(),
'url' => $project->url(),
'uri' => '/' . $project->uri(),
'modified' => $project->modified('Y-MM-d'),
'currentStep' => $project->currentStep()->value(),
'status' => $project->status(),
'logo' => $project->client()->toPage() ? $project->client()->toPage()->logo()->toFile()->url() : '',
'steps' => $project->getSteps(),
'notifications' => Yaml::decode($project->notifications()->value),
'uuid' => (string) $project->uuid(),
'slug' => (string) $project->slug(),
'isDTLEnabled' => $project->isDTLEnabled()->isTrue(),
'hasOptimizationRequest' => $project->hasOptimizationRequest()->isTrue(),
];
'title' => $project->title()->value(),
'url' => $project->url(),
'uri' => '/' . $project->uri(),
'modified' => $project->modified('Y-MM-d'),
'currentStep' => $project->currentStep()->value(),
'status' => $project->status(),
'logo' => $project->client()->toPage() ? $project->client()->toPage()->logo()->toFile()->url() : '',
'steps' => $project->getSteps(),
'notifications' => $notifications,
'uuid' => (string) $project->uuid(),
'slug' => (string) $project->slug(),
'isDTLEnabled' => $project->isDTLEnabled()->isTrue(),
'hasOptimizationRequest' => $project->hasOptimizationRequest()->isTrue(),
];
if ($project->isDTLEnabled()) {
$data['designToLight'] = processDTLProposals($project);
@ -33,8 +41,12 @@ function getProjectData($project)
return $data;
}
$currentUser = $kirby->user();
try {
$children = $kirby->user()->role() == 'admin' ? $page->childrenAndDrafts()->map(fn($project) => getProjectData($project))->values() : $kirby->user()->projects()->toPages()->map(fn($project) => getProjectData($project))->values();
$children = $currentUser->role() == 'admin'
? $page->childrenAndDrafts()->map(fn($project) => getProjectData($project, $currentUser))->values()
: $currentUser->projects()->toPages()->map(fn($project) => getProjectData($project, $currentUser))->values();
} catch (\Throwable $th) {
throw new Exception($th->getMessage() . ' line ' . $th->getLine() . ' in file ' . $th->getFile(), 1);
$children = [];

View file

@ -76,112 +76,149 @@ const { items, label, isCompareModeEnabled, index } = defineProps({
// Local state
const currentValue = ref(null);
const syncing = ref(false); // empêche les réactions pendant les mises à jour programmatiques
const syncing = ref(false);
// Store
const { activeTracks } = storeToRefs(useDialogStore());
// Utils
function isSame(a, b) {
if (!a || !b) return false;
if (a.slug && b.slug) return a.slug === b.slug;
return a.title === b.title;
function normalizeSlug(slug) {
return slug.replace(/_/g, '-');
}
function toVariation(v) {
if (!v) return null;
return Array.isArray(v) ? v[v.length - 1] || null : v;
function areVariationsEqual(variationA, variationB) {
if (!variationA || !variationB) return false;
if (variationA.slug && variationB.slug) {
return normalizeSlug(variationA.slug) === normalizeSlug(variationB.slug);
}
return variationA.title === variationB.title;
}
// Initialisation : remplir le 1er select localement ET initialiser le store
onBeforeMount(() => {
function extractVariation(value) {
if (!value) return null;
return Array.isArray(value) ? value[value.length - 1] || null : value;
}
function convertValueForCompareMode(value, shouldBeArray) {
if (shouldBeArray) {
return value && !Array.isArray(value) ? [value] : value;
} else {
return Array.isArray(value) ? value[0] || null : value;
}
}
function findMatchingVariationsInStore(storeVariations) {
return storeVariations.filter((storeVar) =>
items.some((item) => areVariationsEqual(item, storeVar))
);
}
function syncCurrentValueFromStore(storeVariations) {
syncing.value = true;
if (index === 0) {
currentValue.value = items[0] || null;
// si le store est vide, initialiser avec la variation du premier sélecteur
if (!activeTracks.value || activeTracks.value.length === 0) {
const v = toVariation(items[0]);
if (v) activeTracks.value = [v];
}
const matchedVariations = findMatchingVariationsInStore(storeVariations);
if (isCompareModeEnabled) {
currentValue.value = matchedVariations.length ? [...matchedVariations] : [];
} else {
// les autres ne forcent pas le store ; leur currentValue restera à null
currentValue.value = null;
currentValue.value = matchedVariations[0] || null;
}
nextTick(() => (syncing.value = false));
});
}
function detectVariationChanges(newValues, oldValues) {
const newList = Array.isArray(newValues)
? newValues
: newValues
? [newValues]
: [];
const oldList = Array.isArray(oldValues)
? oldValues
: oldValues
? [oldValues]
: [];
const addedVariation = newList.find(
(n) => !oldList.some((o) => areVariationsEqual(o, n))
);
const removedVariation = oldList.find(
(o) => !newList.some((n) => areVariationsEqual(n, o))
);
return { addedVariation, removedVariation };
}
function handleVariationChange(newValue, oldValue) {
if (syncing.value) return;
const { addedVariation, removedVariation } = detectVariationChanges(
newValue,
oldValue
);
if (
addedVariation &&
items.some((item) => areVariationsEqual(item, addedVariation))
) {
updateActiveTracks(addedVariation, 'add');
} else if (
removedVariation &&
items.some((item) => areVariationsEqual(item, removedVariation))
) {
updateActiveTracks(removedVariation, 'remove');
}
}
// Quand on bascule compare mode (objet <-> tableau)
watch(
() => isCompareModeEnabled,
(flag) => {
(shouldBeArray) => {
syncing.value = true;
if (flag) {
if (currentValue.value && !Array.isArray(currentValue.value)) {
currentValue.value = [currentValue.value];
}
} else {
if (Array.isArray(currentValue.value)) {
currentValue.value = currentValue.value[0] || null;
}
}
currentValue.value = convertValueForCompareMode(
currentValue.value,
shouldBeArray
);
nextTick(() => (syncing.value = false));
}
);
// Détection ajout / suppression dans le MultiSelect (côté composant)
// On n'agit que si l'ajout/suppression concerne une variation appartenant à `items`
watch(
currentValue,
(newVal, oldVal) => {
if (syncing.value) return;
watch(currentValue, handleVariationChange, { deep: true });
const newItems = Array.isArray(newVal) ? newVal : newVal ? [newVal] : [];
const oldItems = Array.isArray(oldVal) ? oldVal : oldVal ? [oldVal] : [];
const added = newItems.find((n) => !oldItems.some((o) => isSame(o, n)));
const removed = oldItems.find((o) => !newItems.some((n) => isSame(n, o)));
if (added && items.some((it) => isSame(it, added))) {
selectTrack(added, 'add');
} else if (removed && items.some((it) => isSame(it, removed))) {
selectTrack(removed, 'remove');
}
},
{ deep: true }
);
// Quand activeTracks change elsewhere -> synchroniser l'affichage local
// Mais n'adopter que les variations qui appartiennent à ce Selector (`items`)
watch(
activeTracks,
(newVal) => {
syncing.value = true;
const storeList = Array.isArray(newVal) ? newVal : [];
// ne garder que les variations du store qui sont dans `items`
const matched = storeList.filter((av) =>
items.some((it) => isSame(it, av))
);
if (isCompareModeEnabled) {
currentValue.value = matched.length ? [...matched] : [];
} else {
currentValue.value = matched[0] || null;
}
nextTick(() => (syncing.value = false));
(storeVariations) => {
const variationsList = Array.isArray(storeVariations)
? storeVariations
: [];
syncCurrentValueFromStore(variationsList);
},
{ deep: true }
{ deep: true, immediate: true }
);
// Logique centrale de sélection (ajout / suppression)
// Règles :
// - mode normal -> activeTracks = [variation]
// - mode comparaison -> conserver activeTracks[0] si possible; second élément ajouté/remplacé; suppression gère le cas de la suppression de la première
function selectTrack(track, action = 'add') {
const variation = toVariation(track);
function removeVariationFromActiveTracks(variation) {
activeTracks.value = activeTracks.value.filter(
(track) => !areVariationsEqual(track, variation)
);
}
function addVariationToActiveTracks(variation) {
const isAlreadyPresent = activeTracks.value.some((track) =>
areVariationsEqual(track, variation)
);
if (isAlreadyPresent) return;
if (activeTracks.value.length === 0) {
activeTracks.value = [variation];
} else if (activeTracks.value.length === 1) {
activeTracks.value = [activeTracks.value[0], variation];
} else {
activeTracks.value = [activeTracks.value[0], variation];
}
}
function updateActiveTracks(track, action = 'add') {
const variation = extractVariation(track);
if (!variation) return;
if (!isCompareModeEnabled) {
@ -190,34 +227,12 @@ function selectTrack(track, action = 'add') {
}
if (action === 'remove') {
const wasFirst =
activeTracks.value.length && isSame(activeTracks.value[0], variation);
activeTracks.value = activeTracks.value.filter(
(t) => !isSame(t, variation)
);
// si on a retiré la première et qu'il reste une piste, elle devient naturellement index 0
// pas d'action supplémentaire nécessaire ici (déjà assuré par le filter)
return;
}
// action === 'add'
if (activeTracks.value.some((t) => isSame(t, variation))) {
// déjà présent -> ignore
return;
}
if (activeTracks.value.length === 0) {
activeTracks.value = [variation];
} else if (activeTracks.value.length === 1) {
activeTracks.value = [activeTracks.value[0], variation];
removeVariationFromActiveTracks(variation);
} else {
// remplacer le 2e
activeTracks.value = [activeTracks.value[0], variation];
addVariationToActiveTracks(variation);
}
}
// Helpers pour affichage (inchangés)
function getFrontViewUrl(item) {
if (!item) return '';
if (Array.isArray(item)) {
@ -231,8 +246,8 @@ function getFrontViewUrl(item) {
}
function setImage() {
return getFrontViewUrl(currentValue.value)
? '--image: url(\'' + getFrontViewUrl(currentValue.value) + '\')'
return getFrontViewUrl(currentValue.value)
? "--image: url('" + getFrontViewUrl(currentValue.value) + "')"
: undefined;
}
</script>
@ -250,7 +265,8 @@ function setImage() {
padding: var(--space-8) var(--space-48) var(--space-8) var(--space-16);
}
.selector-dropdown.has-image,
.selector-dropdown.has-image :is(#selector-select, #selector-multiselect, [role='combobox']) {
.selector-dropdown.has-image
:is(#selector-select, #selector-multiselect, [role='combobox']) {
padding-left: var(--space-64);
}
.selector-dropdown.has-image:before {
@ -290,7 +306,9 @@ function setImage() {
cursor: pointer;
}
[role='combobox'] p,
.selector-dropdown [data-pc-section="labelcontainer"] > [data-pc-section='label'] {
.selector-dropdown
[data-pc-section='labelcontainer']
> [data-pc-section='label'] {
max-height: 1lh;
overflow: hidden;
text-overflow: ellipsis;

View file

@ -1,23 +0,0 @@
<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

@ -1,116 +0,0 @@
<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?step=images");
router.push(location.pathname + "/client-brief");
}
</script>

View file

@ -56,8 +56,11 @@ const commentsCount = computed(() => {
let count = 0;
if (Array.isArray(step.files)) {
// Ne compter que les commentaires des images, pas des documents (PDFs)
for (const file of step.files) {
count += file?.comments?.length || 0;
if (file.type === 'image') {
count += file?.comments?.length || 0;
}
}
} else {
if (step.files?.dynamic) {

View file

@ -61,13 +61,14 @@ import { storeToRefs } from 'pinia';
import { usePageStore } from '../../../stores/page';
import { useDialogStore } from '../../../stores/dialog';
import { useVirtualSampleStore } from '../../../stores/virtualSample';
import { useRoute } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import Interactive360 from './Interactive360.vue';
import SingleImage from './SingleImage.vue';
import Selector from '../../Selector.vue';
import slugify from 'slugify';
const route = useRoute();
const router = useRouter();
const { page } = storeToRefs(usePageStore());
const { isCommentsOpen, isCommentPanelEnabled, activeTracks, openedFile } =
@ -92,41 +93,74 @@ const tracks = computed(() => {
return list;
});
// ---------- INITIALISATION ----------
// onBeforeMount : on initialise toujours activeTracks avec une VARIATION (jamais un track)
onBeforeMount(() => {
// essayer la hash en priorité
let initialVariation = null;
function normalizeSlug(slug) {
return slug.replace(/_/g, '-');
}
function getVariationSlug(variation) {
return variation.slug || (variation.title ? slugify(variation.title) : null);
}
function findVariationByHash(hashValue) {
const allVariations = tracks.value.flatMap((track) => track.variations || []);
const normalizedHash = normalizeSlug(hashValue);
return allVariations.find((variation) => {
const variationSlug = getVariationSlug(variation);
if (!variationSlug) return false;
const normalizedVariationSlug = normalizeSlug(variationSlug);
return normalizedVariationSlug === normalizedHash;
});
}
function getInitialVariation() {
if (route?.hash && route.hash.length > 0) {
const variations = tracks.value.flatMap((t) => t.variations || []);
initialVariation =
variations.find((v) => v.slug === route.hash.substring(1)) || null;
const hashValue = route.hash.substring(1);
const variationFromHash = findVariationByHash(hashValue);
if (variationFromHash) return variationFromHash;
}
// fallback : première variation du premier track
if (!initialVariation) {
initialVariation = tracks.value[0]?.variations?.[0] || null;
}
return tracks.value[0]?.variations?.[0] || null;
}
if (initialVariation) {
activeTracks.value = [initialVariation];
} else {
activeTracks.value = []; // aucun contenu disponible
}
});
function initializeActiveTracks() {
const initialVariation = getInitialVariation();
activeTracks.value = initialVariation ? [initialVariation] : [];
}
// scroll si hash présent
onMounted(() => {
if (route.query?.comments) isCommentsOpen.value = true;
function normalizeUrlHash() {
if (route?.hash && route.hash.includes('_')) {
const normalizedHash = normalizeSlug(route.hash);
router.replace({ ...route, hash: normalizedHash });
}
}
function openCommentsIfRequested() {
if (route.query?.comments) {
isCommentsOpen.value = true;
}
}
function scrollToHashTarget() {
if (!route?.hash || route.hash.length === 0) return;
const selector = route.hash.replace('#', '#track--');
const targetBtn = document.querySelector(selector);
if (targetBtn) targetBtn.scrollIntoView();
const selectorId = route.hash.replace('#', '#track--');
const targetButton = document.querySelector(selectorId);
if (targetButton) {
targetButton.scrollIntoView();
}
}
onBeforeMount(() => {
initializeActiveTracks();
});
// ---------- COMPUTED / WATCH ----------
onMounted(() => {
openCommentsIfRequested();
normalizeUrlHash();
scrollToHashTarget();
});
const isSingleImage = computed(() => {
return (
@ -139,38 +173,52 @@ const singleFile = computed(() => {
return isSingleImage.value ? activeTracks.value[0].files[0] : null;
});
watch(
singleFile,
(newValue) => {
if (newValue) openedFile.value = newValue;
},
{ immediate: true }
);
// gestion du mode comparaison : fermer les commentaires, etc.
watch(isCompareModeEnabled, (newValue) => {
if (newValue) {
isCommentsOpen.value = false;
isCommentPanelEnabled.value = false;
} else {
isCommentPanelEnabled.value = true;
function updateOpenedFile(file) {
if (file) {
openedFile.value = file;
}
}
// quand on quitte le mode comparaison on retire l'élément secondaire si nécessaire
if (!newValue && activeTracks.value.length === 2) {
function enableCompareModeUI() {
isCommentsOpen.value = false;
isCommentPanelEnabled.value = false;
}
function disableCompareModeUI() {
isCommentPanelEnabled.value = true;
if (activeTracks.value.length === 2) {
activeTracks.value.pop();
}
}
function updateUrlHash(firstTrack) {
const trackSlug = getVariationSlug(firstTrack);
if (!trackSlug) return;
const currentHash = route.hash ? route.hash.substring(1) : '';
const normalizedTrackSlug = normalizeSlug(trackSlug);
if (currentHash !== normalizedTrackSlug) {
router.replace({ ...route, hash: '#' + normalizedTrackSlug });
}
}
watch(singleFile, updateOpenedFile, { immediate: true });
watch(isCompareModeEnabled, (isEnabled) => {
isEnabled ? enableCompareModeUI() : disableCompareModeUI();
});
// ---------- UTIL / helper ----------
function getCommentsCount(track) {
if (!track || !Array.isArray(track.files)) return undefined;
let count = 0;
for (const file of track.files) {
count += file?.comments?.length || 0;
}
return count > 0 ? count : undefined;
}
watch(
activeTracks,
(tracks) => {
if (tracks && tracks.length > 0) {
updateUrlHash(tracks[0]);
}
},
{ deep: true }
);
</script>
<style>

View file

@ -163,6 +163,34 @@ 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",
@ -215,6 +243,31 @@ 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");
@ -243,6 +296,10 @@ export const useApiStore = defineStore("api", () => {
updateComment,
deleteComment,
replyComment,
// Nouvelles fonctions
markNotificationRead,
markAllNotificationsRead,
// Anciennes fonctions (rétro-compatibilité)
readNotification,
readAllNotifications,
validateBrief,

View file

@ -11,48 +11,76 @@ 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(() => {
return projects.value?.flatMap((project) => {
if (!projects.value || !user.value) return [];
return projects.value.flatMap((project) => {
if (!project.notifications) return [];
return project.notifications
.filter((notification) => notification.author.uuid !== user.value.uuid)
.map((notification) => ({
...notification,
project: project,
isRead: notification.readby?.includes(user.value.uuid),
}));
return project.notifications.map((notification) => ({
...notification,
project: project,
// isRead est maintenant fourni par le backend
}));
});
});
function readNotification(notificationId, projectId) {
console.log('Read notification', notificationId, projectId);
/**
* 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;
projects.value = projects.value.map((project) => ({
...project,
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,
notifications: (project.notifications || []).map((notification) => ({
...notification,
isRead: true,
readby: [...new Set([...(notification.readby || []), user.value.uuid])],
})),
}));
}
// Anciennes fonctions gardées pour rétro-compatibilité
function readNotification(notificationId, projectId) {
markNotificationRead(notificationId, projectId);
}
function readAllNotifications() {
projects.value = projects.value.map((project) => ({
...project,
notifications: project.notifications.map((notification) => ({
...notification,
readby: [...new Set([...notification.readby, user.value.uuid])],
})),
}));
markAllNotificationsRead();
}
function canEditComment(comment) {
@ -63,6 +91,10 @@ export const useUserStore = defineStore('user', () => {
user,
isLogged,
notifications,
// Nouvelles fonctions
markNotificationRead,
markAllNotificationsRead,
// Anciennes fonctions (rétro-compatibilité)
readNotification,
readAllNotifications,
canEditComment,

View file

@ -25,49 +25,18 @@
Valider et envoyer le brief
</button>
</header>
<component :is="stepsComponents[currentStep]" @update:step="changeStep" />
<Images />
</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,14 +119,24 @@ function changeTab(newValue) {
function readAll() {
try {
api.readAllNotifications();
api.markAllNotificationsRead();
} catch (error) {
console.log('Could not read all notifications : ', error);
}
}
// Functions
function handleNotificationClick(notification) {
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
const href =
notification.type === 'appointment-request'
? getHref(notification) + '?tab=designToLight'
@ -141,6 +151,11 @@ 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

@ -17,6 +17,7 @@ export default defineConfig(({ mode }) => {
minify: mode === 'production' ? 'esbuild' : false,
},
server: {
cors: true,
watch: {
ignored: [
'**/node_modules/**',