merge preprod
All checks were successful
Deploy Production / Build and Deploy to Production (push) Successful in 28s
All checks were successful
Deploy Production / Build and Deploy to Production (push) Successful in 28s
This commit is contained in:
commit
01b4a374cf
116 changed files with 16915 additions and 842 deletions
53
.forgejo/workflows/deploy-demo.yml
Normal file
53
.forgejo/workflows/deploy-demo.yml
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
name: Deploy Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
name: Build and Deploy to Production
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: forgejo-ci-node:latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
run: |
|
||||||
|
git clone --depth 1 --branch main https://forge.studio-variable.com/${{ github.repository }}.git .
|
||||||
|
ls -la
|
||||||
|
|
||||||
|
- name: Install npm dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build frontend (production)
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Install composer dependencies
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
composer install --no-dev --optimize-autoloader
|
||||||
|
|
||||||
|
- name: Deploy via rsync
|
||||||
|
env:
|
||||||
|
USERNAME: ${{ secrets.DEMO_USERNAME }}
|
||||||
|
PASSWORD: ${{ secrets.DEMO_PASSWORD }}
|
||||||
|
HOST: ${{ secrets.DEMO_HOST }}
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
lftp -c "
|
||||||
|
set ftp:ssl-allow no;
|
||||||
|
open -u $USERNAME,$PASSWORD $PRODUCTION_HOST;
|
||||||
|
mirror --reverse --delete --verbose --ignore-time --parallel=10 \
|
||||||
|
assets assets;
|
||||||
|
mirror --reverse --delete --verbose --ignore-time --parallel=10 \
|
||||||
|
-x 'accounts/' \
|
||||||
|
-x 'cache/' \
|
||||||
|
-x 'sessions/' \
|
||||||
|
site site;
|
||||||
|
mirror --reverse --delete --verbose --ignore-time --parallel=10 \
|
||||||
|
kirby kirby;
|
||||||
|
mirror --reverse --delete --verbose --ignore-time --parallel=10 \
|
||||||
|
vendor vendor;
|
||||||
|
put index.php -o index.php;
|
||||||
|
quit"
|
||||||
64
.forgejo/workflows/deploy-preprod.yml
Normal file
64
.forgejo/workflows/deploy-preprod.yml
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
name: Deploy Preprod
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- preprod
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
name: Build and Deploy to Preprod
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: forgejo-ci-node:latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
run: |
|
||||||
|
git clone --depth 1 --branch preprod https://forge.studio-variable.com/${{ github.repository }}.git .
|
||||||
|
ls -la
|
||||||
|
|
||||||
|
- name: Install npm dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build frontend (preprod)
|
||||||
|
run: npm run build:preprod
|
||||||
|
|
||||||
|
- name: Install composer dependencies
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
composer install --no-dev --optimize-autoloader
|
||||||
|
|
||||||
|
- name: Deploy via rsync
|
||||||
|
env:
|
||||||
|
USERNAME: ${{ secrets.USERNAME }}
|
||||||
|
PASSWORD: ${{ secrets.PASSWORD }}
|
||||||
|
HOST: ${{ secrets.HOST }}
|
||||||
|
PREPROD_PATH: ${{ secrets.PREPROD_PATH }}
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
|
||||||
|
echo "Deploying site/"
|
||||||
|
sshpass -p "$PASSWORD" rsync -az --delete -O --no-perms --no-owner --no-group \
|
||||||
|
--include 'accounts/' \
|
||||||
|
--exclude 'accounts/*' \
|
||||||
|
--include 'sessions/' \
|
||||||
|
--exclude 'sessions/*' \
|
||||||
|
--include 'cache/' \
|
||||||
|
--exclude 'cache/*' \
|
||||||
|
-e 'ssh -p 2244 -o StrictHostKeyChecking=no' \
|
||||||
|
site/ $USERNAME@$HOST:$PREPROD_PATH/site/
|
||||||
|
|
||||||
|
echo "Deploying vendor/"
|
||||||
|
sshpass -p "$PASSWORD" rsync -az --delete -O \
|
||||||
|
-e 'ssh -p 2244 -o StrictHostKeyChecking=no' \
|
||||||
|
vendor/ $USERNAME@$HOST:$PREPROD_PATH/vendor/
|
||||||
|
|
||||||
|
echo "Deploying kirby/"
|
||||||
|
sshpass -p "$PASSWORD" rsync -az --delete -O \
|
||||||
|
-e 'ssh -p 2244 -o StrictHostKeyChecking=no' \
|
||||||
|
kirby/ $USERNAME@$HOST:$PREPROD_PATH/kirby/
|
||||||
|
|
||||||
|
echo "Deploying assets/"
|
||||||
|
sshpass -p "$PASSWORD" rsync -az --delete -O \
|
||||||
|
-e 'ssh -p 2244 -o StrictHostKeyChecking=no' \
|
||||||
|
assets/ $USERNAME@$HOST:$PREPROD_PATH/assets/
|
||||||
64
.forgejo/workflows/deploy-prod.yml
Normal file
64
.forgejo/workflows/deploy-prod.yml
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
name: Deploy Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
name: Build and Deploy to Production
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: forgejo-ci-node:latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
run: |
|
||||||
|
git clone --depth 1 --branch main https://forge.studio-variable.com/${{ github.repository }}.git .
|
||||||
|
ls -la
|
||||||
|
|
||||||
|
- name: Install npm dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build frontend (production)
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Install composer dependencies
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
composer install --no-dev --optimize-autoloader
|
||||||
|
|
||||||
|
- name: Deploy via rsync
|
||||||
|
env:
|
||||||
|
USERNAME: ${{ secrets.USERNAME }}
|
||||||
|
PASSWORD: ${{ secrets.PASSWORD }}
|
||||||
|
HOST: ${{ secrets.HOST }}
|
||||||
|
PROD_PATH: ${{ secrets.PROD_PATH }}
|
||||||
|
run: |
|
||||||
|
cd dist
|
||||||
|
|
||||||
|
echo "Deploying site/"
|
||||||
|
sshpass -p "$PASSWORD" rsync -az --delete -O \
|
||||||
|
--exclude 'accounts/*' \
|
||||||
|
--exclude 'cache/*' \
|
||||||
|
--exclude 'sessions/*' \
|
||||||
|
--include 'accounts/' \
|
||||||
|
--include 'cache/' \
|
||||||
|
--include 'sessions/' \
|
||||||
|
-e 'ssh -p 2244 -o StrictHostKeyChecking=no' \
|
||||||
|
site/ $USERNAME@$HOST:$PROD_PATH/site/
|
||||||
|
|
||||||
|
echo "Deploying vendor/"
|
||||||
|
sshpass -p "$PASSWORD" rsync -az --delete -O \
|
||||||
|
-e 'ssh -p 2244 -o StrictHostKeyChecking=no' \
|
||||||
|
vendor/ $USERNAME@$HOST:$PROD_PATH/vendor/
|
||||||
|
|
||||||
|
echo "Deploying kirby/"
|
||||||
|
sshpass -p "$PASSWORD" rsync -az --delete -O \
|
||||||
|
-e 'ssh -p 2244 -o StrictHostKeyChecking=no' \
|
||||||
|
kirby/ $USERNAME@$HOST:$PROD_PATH/kirby/
|
||||||
|
|
||||||
|
echo "Deploying assets/"
|
||||||
|
sshpass -p "$PASSWORD" rsync -az --delete -O \
|
||||||
|
-e 'ssh -p 2244 -o StrictHostKeyChecking=no' \
|
||||||
|
assets/ $USERNAME@$HOST:$PROD_PATH/assets/
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -62,18 +62,21 @@ public/.idea
|
||||||
|
|
||||||
public/site/cache/*
|
public/site/cache/*
|
||||||
!public/site/cache/index.html
|
!public/site/cache/index.html
|
||||||
|
!public/site/cache/.gitkeep
|
||||||
|
|
||||||
# Accounts
|
# Accounts
|
||||||
# ---------------
|
# ---------------
|
||||||
|
|
||||||
public/site/accounts/*
|
public/site/accounts/*
|
||||||
!public/site/accounts/index.html
|
!public/site/accounts/index.html
|
||||||
|
!public/site/accounts/.gitkeep
|
||||||
|
|
||||||
# Sessions
|
# Sessions
|
||||||
# ---------------
|
# ---------------
|
||||||
|
|
||||||
public/site/sessions/*
|
public/site/sessions/*
|
||||||
!public/site/sessions/index.html
|
!public/site/sessions/index.html
|
||||||
|
!public/site/sessions/.gitkeep
|
||||||
|
|
||||||
# License
|
# License
|
||||||
# ---------------
|
# ---------------
|
||||||
|
|
@ -91,3 +94,10 @@ public/vendor
|
||||||
# Content
|
# Content
|
||||||
# ---------------
|
# ---------------
|
||||||
/public/content
|
/public/content
|
||||||
|
|
||||||
|
# Claude settings
|
||||||
|
# ---------------
|
||||||
|
.claude
|
||||||
|
/.claude/*
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
344
CLAUDE.md
Normal file
344
CLAUDE.md
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Préférences de code et standards
|
||||||
|
|
||||||
|
### Principes généraux
|
||||||
|
|
||||||
|
**Clarté avant tout**
|
||||||
|
- Privilégier des noms de variables et fonctions explicites
|
||||||
|
- Éviter les commentaires sauf en cas de nécessité absolue
|
||||||
|
- Le code doit être auto-documenté par des noms clairs
|
||||||
|
|
||||||
|
**Organisation du code**
|
||||||
|
- Factoriser le code en petites fonctions bien nommées
|
||||||
|
- Éviter les blocs de code longs et complexes dans les hooks de lifecycle
|
||||||
|
- Une fonction = une responsabilité claire
|
||||||
|
|
||||||
|
**Ce qui est considéré comme "bricolage" (à éviter)**
|
||||||
|
- Paramètres d'URL pour gérer des états applicatifs
|
||||||
|
- Commentaires qui expliquent ce que fait le code au lieu de nommer correctement
|
||||||
|
- Abstractions prématurées ou sur-ingénierie
|
||||||
|
|
||||||
|
### Style de communication
|
||||||
|
|
||||||
|
**Feedback direct**
|
||||||
|
- Pas de politesses inutiles
|
||||||
|
- Dire immédiatement si quelque chose ne va pas
|
||||||
|
- Tester tout de suite et signaler les problèmes
|
||||||
|
|
||||||
|
**Qualité attendue**
|
||||||
|
- Standards élevés sur la qualité du code
|
||||||
|
- Pas de code médiocre accepté
|
||||||
|
- Exigence de clarté et d'explicité constante
|
||||||
65
package-lock.json
generated
65
package-lock.json
generated
|
|
@ -19,6 +19,7 @@
|
||||||
"three": "^0.168.0",
|
"three": "^0.168.0",
|
||||||
"uniqid": "^5.4.0",
|
"uniqid": "^5.4.0",
|
||||||
"vue": "^3.5.6",
|
"vue": "^3.5.6",
|
||||||
|
"vue-i18n": "^11.2.8",
|
||||||
"vue-router": "^4.4.5"
|
"vue-router": "^4.4.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -542,6 +543,50 @@
|
||||||
"@swc/helpers": "^0.5.0"
|
"@swc/helpers": "^0.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@intlify/core-base": {
|
||||||
|
"version": "11.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.8.tgz",
|
||||||
|
"integrity": "sha512-nBq6Y1tVkjIUsLsdOjDSJj4AsjvD0UG3zsg9Fyc+OivwlA/oMHSKooUy9tpKj0HqZ+NWFifweHavdljlBLTwdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/message-compiler": "11.2.8",
|
||||||
|
"@intlify/shared": "11.2.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/message-compiler": {
|
||||||
|
"version": "11.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.8.tgz",
|
||||||
|
"integrity": "sha512-A5n33doOjmHsBtCN421386cG1tWp5rpOjOYPNsnpjIJbQ4POF0QY2ezhZR9kr0boKwaHjbOifvyQvHj2UTrDFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/shared": "11.2.8",
|
||||||
|
"source-map-js": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/shared": {
|
||||||
|
"version": "11.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.8.tgz",
|
||||||
|
"integrity": "sha512-l6e4NZyUgv8VyXXH4DbuucFOBmxLF56C/mqh2tvApbzl2Hrhi1aTDcuv5TKdxzfHYmpO3UB0Cz04fgDT9vszfw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
|
|
@ -1857,6 +1902,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-i18n": {
|
||||||
|
"version": "11.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.8.tgz",
|
||||||
|
"integrity": "sha512-vJ123v/PXCZntd6Qj5Jumy7UBmIuE92VrtdX+AXr+1WzdBHojiBxnAxdfctUFL+/JIN+VQH4BhsfTtiGsvVObg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/core-base": "11.2.8",
|
||||||
|
"@intlify/shared": "11.2.8",
|
||||||
|
"@vue/devtools-api": "^6.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-router": {
|
"node_modules/vue-router": {
|
||||||
"version": "4.5.1",
|
"version": "4.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
"three": "^0.168.0",
|
"three": "^0.168.0",
|
||||||
"uniqid": "^5.4.0",
|
"uniqid": "^5.4.0",
|
||||||
"vue": "^3.5.6",
|
"vue": "^3.5.6",
|
||||||
|
"vue-i18n": "^11.2.8",
|
||||||
"vue-router": "^4.4.5"
|
"vue-router": "^4.4.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
2
public/.user.ini
Normal file
2
public/.user.ini
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
; Augmentation temporaire de la limite mémoire pour le chargement des notifications
|
||||||
|
memory_limit = 512M
|
||||||
|
|
@ -24,6 +24,18 @@ tabs:
|
||||||
type: hidden
|
type: hidden
|
||||||
isValidated:
|
isValidated:
|
||||||
type: hidden
|
type: hidden
|
||||||
|
validatedBy:
|
||||||
|
type: hidden
|
||||||
|
validatedByName:
|
||||||
|
type: hidden
|
||||||
|
validatedByEmail:
|
||||||
|
type: hidden
|
||||||
|
validatedAt:
|
||||||
|
type: hidden
|
||||||
|
validationReadby:
|
||||||
|
type: hidden
|
||||||
|
validationDialogUri:
|
||||||
|
type: hidden
|
||||||
pdf:
|
pdf:
|
||||||
label: PDF
|
label: PDF
|
||||||
type: files
|
type: files
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,34 @@ tabs:
|
||||||
fields:
|
fields:
|
||||||
stepName:
|
stepName:
|
||||||
type: hidden
|
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:
|
pdf:
|
||||||
label: PDF
|
label: PDF
|
||||||
type: files
|
type: files
|
||||||
multiple: false
|
multiple: false
|
||||||
uploads: pdf
|
uploads: pdf
|
||||||
|
description:
|
||||||
|
type: textarea
|
||||||
|
size: tiny
|
||||||
|
buttons: false
|
||||||
|
moodboard:
|
||||||
|
label: Images
|
||||||
|
type: files
|
||||||
|
template: image
|
||||||
|
layout: cards
|
||||||
|
size: medium
|
||||||
files: tabs/files
|
files: tabs/files
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ tabs:
|
||||||
fields:
|
fields:
|
||||||
lastCacheUpdate:
|
lastCacheUpdate:
|
||||||
type: hidden
|
type: hidden
|
||||||
|
# Champs pour project-request
|
||||||
isClientRequest:
|
isClientRequest:
|
||||||
type: hidden
|
type: hidden
|
||||||
default: "false"
|
default: "false"
|
||||||
|
|
@ -30,6 +31,32 @@ tabs:
|
||||||
disabled: true
|
disabled: true
|
||||||
when:
|
when:
|
||||||
isClientRequest: "true"
|
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:
|
currentStep:
|
||||||
label: Étape en cours
|
label: Étape en cours
|
||||||
type: radio
|
type: radio
|
||||||
|
|
@ -58,6 +85,10 @@ tabs:
|
||||||
query: page.logo.toFile
|
query: page.logo.toFile
|
||||||
layout: cardlets
|
layout: cardlets
|
||||||
required: true
|
required: true
|
||||||
|
users:
|
||||||
|
label: Utilisateurs assignés
|
||||||
|
type: users
|
||||||
|
multiple: true
|
||||||
|
|
||||||
- width: 2/3
|
- width: 2/3
|
||||||
sections:
|
sections:
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ sections:
|
||||||
fr: Projet(s) en cours
|
fr: Projet(s) en cours
|
||||||
en: Current project(s)
|
en: Current project(s)
|
||||||
link: https://designtopack.morphozbygroupepochet.com/
|
link: https://designtopack.morphozbygroupepochet.com/
|
||||||
value: "{{ user.projects.toPages.count }}"
|
value: "{{ user.currentProjects.count }}"
|
||||||
icon: folder
|
icon: folder
|
||||||
content:
|
content:
|
||||||
label: ' '
|
label: ' '
|
||||||
|
|
@ -52,8 +52,3 @@ sections:
|
||||||
layout: cardlets
|
layout: cardlets
|
||||||
required: true
|
required: true
|
||||||
width: 1/2
|
width: 1/2
|
||||||
projects:
|
|
||||||
label: Projets
|
|
||||||
type: pages
|
|
||||||
query: page('projects').children
|
|
||||||
width: 1/2
|
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ fields:
|
||||||
- Sales Manager
|
- Sales Manager
|
||||||
default: Project Panager
|
default: Project Panager
|
||||||
width: 1/4
|
width: 1/4
|
||||||
projects:
|
hiddenProjects:
|
||||||
label: Projets
|
label: Projets masqués
|
||||||
type: pages
|
type: pages
|
||||||
query: page('projects').children
|
query: page('projects').children
|
||||||
width: 3/4
|
width: 3/4
|
||||||
|
|
|
||||||
0
public/site/cache/.gitkeep
vendored
Normal file
0
public/site/cache/.gitkeep
vendored
Normal file
|
|
@ -25,11 +25,14 @@ return [
|
||||||
'css' => 'assets/css/panel.css',
|
'css' => 'assets/css/panel.css',
|
||||||
'favicon' => 'favicon.svg',
|
'favicon' => 'favicon.svg',
|
||||||
'menu' => require(__DIR__ . '/menu.php'),
|
'menu' => require(__DIR__ . '/menu.php'),
|
||||||
|
'install' => 'true'
|
||||||
],
|
],
|
||||||
'routes' => [
|
'routes' => [
|
||||||
|
require(__DIR__ . '/routes/en-locale.php'),
|
||||||
require(__DIR__ . '/routes/logout.php'),
|
require(__DIR__ . '/routes/logout.php'),
|
||||||
require(__DIR__ . '/routes/login.php'),
|
require(__DIR__ . '/routes/login.php'),
|
||||||
require(__DIR__ . '/routes/toggle-favorite.php'),
|
require(__DIR__ . '/routes/toggle-favorite.php'),
|
||||||
|
require(__DIR__ . '/routes/toggle-hidden-project.php'),
|
||||||
require(__DIR__ . '/routes/upload-images.php'),
|
require(__DIR__ . '/routes/upload-images.php'),
|
||||||
require(__DIR__ . '/routes/save-page.php'),
|
require(__DIR__ . '/routes/save-page.php'),
|
||||||
require(__DIR__ . '/routes/save-file.php'),
|
require(__DIR__ . '/routes/save-file.php'),
|
||||||
|
|
@ -40,6 +43,8 @@ return [
|
||||||
require(__DIR__ . '/routes/validate-brief.php'),
|
require(__DIR__ . '/routes/validate-brief.php'),
|
||||||
require(__DIR__ . '/routes/request-project-creation.php'),
|
require(__DIR__ . '/routes/request-project-creation.php'),
|
||||||
require(__DIR__ . '/routes/request-optimization-appointment.php'),
|
require(__DIR__ . '/routes/request-optimization-appointment.php'),
|
||||||
|
require(__DIR__ . '/routes/migrate-notifications.php'),
|
||||||
|
require(__DIR__ . '/routes/migrate-user-projects.php'),
|
||||||
],
|
],
|
||||||
'hooks' => [
|
'hooks' => [
|
||||||
'page.create:after' => require_once(__DIR__ . '/hooks/create-steps.php'),
|
'page.create:after' => require_once(__DIR__ . '/hooks/create-steps.php'),
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
// file.update:after
|
// file.update:after
|
||||||
return function ($newFile, $oldFile) {
|
return function ($newFile, $oldFile) {
|
||||||
$project = $newFile->parent()->template() == 'project' ? $newFile->parent() : $newFile->parent()->parents()->findBy('template', 'project');
|
$project = $newFile->parent()->template() == 'project' ? $newFile->parent() : $newFile->parent()->parents()->findBy('template', 'project');
|
||||||
if ($project) {
|
if ($project) {
|
||||||
$steps = $project->rebuildStepsCache();
|
$project->rebuildStepsCache();
|
||||||
|
// Invalider aussi le cache des notifications (commentaires sur fichiers, etc.)
|
||||||
|
$project->invalidateNotificationsCache();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
// page.update:after && page.changeStatus:after
|
// page.update:after && page.changeStatus:after
|
||||||
return function($newPage, $oldPage) {
|
return function($newPage, $oldPage) {
|
||||||
$project = $newPage->template() == 'project' ? $newPage : $newPage->parents()->findBy('template', 'project');
|
$project = $newPage->template() == 'project' ? $newPage : $newPage->parents()->findBy('template', 'project');
|
||||||
if ($project) {
|
if ($project) {
|
||||||
$steps = $project->rebuildStepsCache();
|
$project->rebuildStepsCache();
|
||||||
|
// Invalider aussi le cache des notifications (briefs validés, etc.)
|
||||||
|
$project->invalidateNotificationsCache();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -59,6 +59,15 @@ $menu = [
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
'-',
|
'-',
|
||||||
|
'analytics' => [
|
||||||
|
'label' => 'Analytics',
|
||||||
|
'icon' => 'chart',
|
||||||
|
'link' => 'pages/analytics',
|
||||||
|
'current' => function (string $current): bool {
|
||||||
|
$path = Kirby\Cms\App::instance()->path();
|
||||||
|
return Str::contains($path, 'pages/analytics');
|
||||||
|
}
|
||||||
|
],
|
||||||
'-',
|
'-',
|
||||||
'users',
|
'users',
|
||||||
'system'
|
'system'
|
||||||
|
|
|
||||||
44
public/site/config/routes/en-locale.php
Normal file
44
public/site/config/routes/en-locale.php
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route catch-all pour le préfixe /en/
|
||||||
|
* Permet d'accéder aux pages avec /en/... pour la version anglaise
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pattern' => 'en/(:all?)',
|
||||||
|
'action' => function ($uri = '') {
|
||||||
|
// Si l'URI se termine par .json, chercher la page et retourner le JSON
|
||||||
|
if (str_ends_with($uri, '.json')) {
|
||||||
|
$uri = str_replace('.json', '', $uri);
|
||||||
|
$page = page($uri);
|
||||||
|
|
||||||
|
if (!$page) {
|
||||||
|
// Si pas de page trouvée, essayer avec 'home'
|
||||||
|
if ($uri === '' || $uri === '/') {
|
||||||
|
$page = page('home');
|
||||||
|
} else {
|
||||||
|
return false; // 404
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retourner la page pour que Kirby serve le template .json.php
|
||||||
|
return $page;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pour les URLs normales (sans .json), chercher la page
|
||||||
|
$page = page($uri);
|
||||||
|
|
||||||
|
if (!$page) {
|
||||||
|
// Si pas de page trouvée, essayer avec 'home'
|
||||||
|
if ($uri === '' || $uri === '/') {
|
||||||
|
$page = page('home');
|
||||||
|
} else {
|
||||||
|
return false; // 404
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retourner la page pour que Kirby serve le template .php
|
||||||
|
return $page;
|
||||||
|
}
|
||||||
|
];
|
||||||
175
public/site/config/routes/migrate-notifications.php
Normal file
175
public/site/config/routes/migrate-notifications.php
Normal 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
|
||||||
|
];
|
||||||
|
}
|
||||||
|
];
|
||||||
75
public/site/config/routes/migrate-user-projects.php
Normal file
75
public/site/config/routes/migrate-user-projects.php
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script de migration : inverse la relation User→Projects en Project→Users.
|
||||||
|
*
|
||||||
|
* Pour chaque user (non-admin) ayant un champ projects non vide,
|
||||||
|
* ajoute ce user dans le champ `users` du projet correspondant.
|
||||||
|
*
|
||||||
|
* Idempotent : pas de doublons si exécuté plusieurs fois.
|
||||||
|
* À supprimer après migration.
|
||||||
|
*
|
||||||
|
* Usage: POST /migrate-user-projects.json
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pattern' => 'migrate-user-projects.json',
|
||||||
|
'method' => 'POST',
|
||||||
|
'action' => function () {
|
||||||
|
$user = kirby()->user();
|
||||||
|
|
||||||
|
if (!$user || $user->role()->id() !== 'admin') {
|
||||||
|
return [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Cette action nécessite les droits administrateur.'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$migrated = [];
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
$nonAdminUsers = kirby()->users()->filter(fn($u) => $u->role()->id() !== 'admin');
|
||||||
|
|
||||||
|
foreach ($nonAdminUsers as $u) {
|
||||||
|
if (!$u->projects()->exists() || $u->projects()->isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userProjects = $u->projects()->toPages();
|
||||||
|
|
||||||
|
foreach ($userProjects as $project) {
|
||||||
|
try {
|
||||||
|
$currentUsers = $project->users()->yaml();
|
||||||
|
|
||||||
|
$userUuid = $u->uuid()->toString();
|
||||||
|
|
||||||
|
if (in_array($userUuid, $currentUsers)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentUsers[] = $userUuid;
|
||||||
|
$project->update(['users' => $currentUsers]);
|
||||||
|
|
||||||
|
$migrated[] = [
|
||||||
|
'user' => $u->name()->value(),
|
||||||
|
'email' => $u->email(),
|
||||||
|
'project' => $project->title()->value(),
|
||||||
|
];
|
||||||
|
} catch (\Throwable $th) {
|
||||||
|
$errors[] = [
|
||||||
|
'user' => $u->email(),
|
||||||
|
'project' => $project->title()->value(),
|
||||||
|
'error' => $th->getMessage()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => count($migrated) . ' assignations migrées.',
|
||||||
|
'migrated' => $migrated,
|
||||||
|
'errors' => $errors
|
||||||
|
];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
@ -7,37 +7,26 @@ return [
|
||||||
$json = file_get_contents('php://input');
|
$json = file_get_contents('php://input');
|
||||||
$data = json_decode($json);
|
$data = json_decode($json);
|
||||||
|
|
||||||
$user = kirby()->user();
|
$user = kirby()->user();
|
||||||
$project = page($data->projectUri);
|
$project = page($data->projectUri);
|
||||||
|
|
||||||
|
$date = new DateTime();
|
||||||
|
$formattedDate = $date->format(DateTime::ISO8601);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$newProject = $project->update([
|
$project->update([
|
||||||
"hasOptimizationRequest" => "true",
|
"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 {
|
// Note: Les notifications sont maintenant dérivées.
|
||||||
$date = new DateTime();
|
// Plus besoin d'appeler createNotification().
|
||||||
$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);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"status" => "success",
|
"status" => "success",
|
||||||
|
|
@ -45,7 +34,7 @@ return [
|
||||||
} catch (\Throwable $th) {
|
} catch (\Throwable $th) {
|
||||||
return [
|
return [
|
||||||
"status" => "error",
|
"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()
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,24 @@ return [
|
||||||
|
|
||||||
$client = kirby()->user()->client()->toPage()->uuid();
|
$client = kirby()->user()->client()->toPage()->uuid();
|
||||||
|
|
||||||
|
$date = new DateTime();
|
||||||
|
$formattedDate = $date->format(DateTime::ISO8601);
|
||||||
|
|
||||||
$projectData = [
|
$projectData = [
|
||||||
"slug" => esc(Str::slug($data->title)),
|
"slug" => esc(Str::slug($data->title)),
|
||||||
"template" => "project",
|
"template" => "project",
|
||||||
"content" => [
|
"content" => [
|
||||||
"title" => esc($data->title),
|
"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],
|
"client" => [$client],
|
||||||
"isClientRequest" => "true",
|
"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 {
|
try {
|
||||||
$newProject = $projects->createChild($projectData);
|
$newProject = $projects->createChild($projectData);
|
||||||
|
|
||||||
$date = new DateTime();
|
// Note: Les notifications sont maintenant dérivées.
|
||||||
$formattedDate = $date->format(DateTime::ISO8601);
|
// Plus besoin d'appeler createNotification().
|
||||||
|
|
||||||
$notificationData = [
|
|
||||||
"location" => [
|
|
||||||
"page" => $newProject
|
|
||||||
],
|
|
||||||
"date" => (string) $formattedDate,
|
|
||||||
"text" => nl2br(esc($data->details)),
|
|
||||||
"author" => $user,
|
|
||||||
"id" => Str::uuid(),
|
|
||||||
"type" => "project-request",
|
|
||||||
];
|
|
||||||
|
|
||||||
$newProject->createNotification($notificationData);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"status" => "success",
|
"status" => "success",
|
||||||
|
|
|
||||||
60
public/site/config/routes/toggle-hidden-project.php
Normal file
60
public/site/config/routes/toggle-hidden-project.php
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pattern' => '/toggle-hidden-project.json',
|
||||||
|
'method' => 'POST',
|
||||||
|
'action' => function() {
|
||||||
|
$json = file_get_contents("php://input");
|
||||||
|
$data = json_decode($json);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$user = kirby()->user();
|
||||||
|
$projectUuid = $data->projectUuid;
|
||||||
|
|
||||||
|
// Récupérer la collection des projets masqués actuels
|
||||||
|
$hiddenProjects = $user->hiddenProjects()->toPages();
|
||||||
|
|
||||||
|
// Trouver le projet à toggle
|
||||||
|
$projectPage = Find::page($projectUuid);
|
||||||
|
|
||||||
|
if (!$projectPage) {
|
||||||
|
throw new Exception('Projet introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle: ajouter ou retirer le projet de la collection
|
||||||
|
if ($hiddenProjects->has($projectPage)) {
|
||||||
|
$hiddenProjects->remove($projectPage);
|
||||||
|
$action = 'shown';
|
||||||
|
} else {
|
||||||
|
$hiddenProjects->add($projectPage);
|
||||||
|
$action = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir la collection en array puis en YAML
|
||||||
|
$array = $hiddenProjects->toArray();
|
||||||
|
$yaml = Data::encode($array, 'yaml');
|
||||||
|
|
||||||
|
// Mettre à jour l'utilisateur
|
||||||
|
$user->update([
|
||||||
|
'hiddenProjects' => $yaml
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Retourner les UUIDs pour le frontend
|
||||||
|
$hiddenProjectsUuids = [];
|
||||||
|
foreach ($hiddenProjects as $project) {
|
||||||
|
$hiddenProjectsUuids[] = $project->uuid();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'success',
|
||||||
|
'action' => $action,
|
||||||
|
'hiddenProjects' => $hiddenProjectsUuids
|
||||||
|
];
|
||||||
|
} catch (\Throwable $th) {
|
||||||
|
return [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Impossible de modifier les projets masqués : ' . $th->getMessage() . ' in file ' . $th->getFile() . ' line ' . $th->getLine()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
@ -9,27 +9,31 @@ return [
|
||||||
|
|
||||||
$page = page($data->briefUri);
|
$page = page($data->briefUri);
|
||||||
$project = $page->parent();
|
$project = $page->parent();
|
||||||
|
$user = kirby()->user();
|
||||||
try {
|
|
||||||
$newPage = $page->update([
|
|
||||||
'isValidated' => 'true'
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
try {
|
||||||
$timezone = new DateTimeZone('Europe/Paris');
|
$timezone = new DateTimeZone('Europe/Paris');
|
||||||
$dateTime = new DateTime('now', $timezone);
|
$dateTime = new DateTime('now', $timezone);
|
||||||
|
|
||||||
$notification = [
|
$updateData = [
|
||||||
'location' => [
|
'isValidated' => 'true',
|
||||||
'page' => $page,
|
// Métadonnées pour le système de notifications dérivées
|
||||||
],
|
'validatedBy' => (string) $user->uuid(),
|
||||||
'date' => $dateTime->format('Y-m-d\TH:i:sP'),
|
'validatedByName' => (string) $user->name(),
|
||||||
'text' => "Nouveau brief",
|
'validatedByEmail' => (string) $user->email(),
|
||||||
'author' => kirby()->user(),
|
'validatedAt' => $dateTime->format('Y-m-d\TH:i:sP'),
|
||||||
'id' => Str::uuid(),
|
'validationReadby' => [],
|
||||||
'type' => 'content'
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$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 [
|
return [
|
||||||
"success" => "'" . $project->title()->value() . "' brief validated."
|
"success" => "'" . $project->title()->value() . "' brief validated."
|
||||||
|
|
|
||||||
|
|
@ -27,15 +27,28 @@ return function ($page, $kirby, $site) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($kirby->user()->projects()->exists() && $kirby->user()->projects()->isNotEmpty()) {
|
$userProjects = $kirby->user()->currentProjects()->merge(
|
||||||
$userData['projects'] = $kirby->user()->projects()->toPages()->map(function ($project) {
|
$kirby->user()->archivedProjects()
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($userProjects->count() > 0) {
|
||||||
|
$userData['projects'] = $userProjects->map(function ($project) {
|
||||||
return [
|
return [
|
||||||
"title" => (string) $project->title(),
|
"title" => (string) $project->title(),
|
||||||
"uri" => (string) $project->uri(),
|
"uri" => (string) $project->uri(),
|
||||||
"step" => (string) $project->getStepLabel(),
|
"step" => (string) $project->currentStep(),
|
||||||
|
"uuid" => (string) $project->uuid(),
|
||||||
];
|
];
|
||||||
})->data;
|
})->data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($kirby->user()->hiddenProjects()->exists() && $kirby->user()->hiddenProjects()->isNotEmpty()) {
|
||||||
|
$userData['hiddenProjects'] = $kirby->user()->hiddenProjects()->toPages()->map(function ($project) {
|
||||||
|
return (string) $project->uuid();
|
||||||
|
})->values();
|
||||||
|
} else {
|
||||||
|
$userData['hiddenProjects'] = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,62 @@
|
||||||
use adrienpayet\notifications\NotificationsPage;
|
use adrienpayet\notifications\NotificationsPage;
|
||||||
|
|
||||||
class ProjectPage extends NotificationsPage {
|
class ProjectPage extends NotificationsPage {
|
||||||
public function getSteps() {
|
public function getSteps() {
|
||||||
$apiCache = kirby()->cache('api');
|
$apiCache = kirby()->cache('api');
|
||||||
$stepsData = $apiCache?->get($this->slug() . '_' . 'steps');
|
$stepsData = $apiCache?->get($this->slug() . '_' . 'steps');
|
||||||
|
|
||||||
if ($stepsData === null || count($stepsData) === 0) {
|
if ($stepsData === null || count($stepsData) === 0) {
|
||||||
$this->rebuildStepsCache();
|
$this->rebuildStepsCache();
|
||||||
};
|
};
|
||||||
|
|
||||||
$stepsData = $apiCache->get($this->slug() . '_' . 'steps');
|
$stepsData = $apiCache->get($this->slug() . '_' . 'steps');
|
||||||
|
|
||||||
return $stepsData;
|
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() {
|
public function rebuildStepsCache() {
|
||||||
// Create steps
|
// Create steps
|
||||||
|
|
@ -55,7 +99,6 @@ class ProjectPage extends NotificationsPage {
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'label' => $child->title()->value(),
|
|
||||||
'id' => $child->stepName()->value(),
|
'id' => $child->stepName()->value(),
|
||||||
'slug' => $child->slug(),
|
'slug' => $child->slug(),
|
||||||
'index' => intval($child->stepIndex()->value()),
|
'index' => intval($child->stepIndex()->value()),
|
||||||
|
|
@ -98,19 +141,19 @@ class ProjectPage extends NotificationsPage {
|
||||||
|
|
||||||
if ($child->hasChildren()) {
|
if ($child->hasChildren()) {
|
||||||
$files['dynamic'] = [];
|
$files['dynamic'] = [];
|
||||||
|
|
||||||
foreach ($child->children() as $key => $track) {
|
foreach ($child->children() as $key => $track) {
|
||||||
$trackData = [
|
$trackData = [
|
||||||
'title' => (string) $track->title(),
|
'title' => (string) $track->title(),
|
||||||
'slug' => (string) $track->slug(),
|
'slug' => (string) $track->slug(),
|
||||||
'backgroundColor' => (string) $track->backgroundColor(),
|
'backgroundColor' => (string) $track->backgroundColor(),
|
||||||
'files' => [],
|
'files' => [],
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($track->views()->toFiles() as $view) {
|
foreach ($track->views()->toFiles() as $view) {
|
||||||
$trackData['files'][] = getFileData($view, true);
|
$trackData['files'][] = getFileData($view, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($track->group()->isNotEmpty()) {
|
if ($track->group()->isNotEmpty()) {
|
||||||
$files['dynamic'][$track->group()->value()][] = $trackData;
|
$files['dynamic'][$track->group()->value()][] = $trackData;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -118,12 +161,36 @@ class ProjectPage extends NotificationsPage {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Récupérer l'ordre des groupes depuis le champ groups
|
||||||
|
$orderedGroups = $child->groups()->split();
|
||||||
|
|
||||||
|
// Réorganiser $files['dynamic'] selon l'ordre défini
|
||||||
|
if (!empty($orderedGroups)) {
|
||||||
|
$orderedDynamic = [];
|
||||||
|
|
||||||
|
foreach ($orderedGroups as $group) {
|
||||||
|
if (isset($files['dynamic'][$group])) {
|
||||||
|
$orderedDynamic[$group] = $files['dynamic'][$group];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajouter les groupes non définis dans le champ à la fin
|
||||||
|
foreach ($files['dynamic'] as $group => $tracks) {
|
||||||
|
if (!isset($orderedDynamic[$group])) {
|
||||||
|
$orderedDynamic[$group] = $tracks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$files['dynamic'] = $orderedDynamic;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toujours mettre "Autres pistes" à la fin
|
||||||
if (isset($files['dynamic']['Autres pistes'])) {
|
if (isset($files['dynamic']['Autres pistes'])) {
|
||||||
$others = $files['dynamic']['Autres pistes'];
|
$others = $files['dynamic']['Autres pistes'];
|
||||||
unset($files['dynamic']['Autres pistes']);
|
unset($files['dynamic']['Autres pistes']);
|
||||||
$files['dynamic']['Autres pistes'] = $others;
|
$files['dynamic']['Autres pistes'] = $others;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($child->rawGlass()->isNotEmpty() || $child->finishedGlass()->isNotEmpty()) {
|
if ($child->rawGlass()->isNotEmpty() || $child->finishedGlass()->isNotEmpty()) {
|
||||||
|
|
@ -133,7 +200,7 @@ class ProjectPage extends NotificationsPage {
|
||||||
if ($child->rawGlass()->isNotEmpty()) {
|
if ($child->rawGlass()->isNotEmpty()) {
|
||||||
$files['static']['rawGlass'] = getFileData($child->rawGlass()->toFile());
|
$files['static']['rawGlass'] = getFileData($child->rawGlass()->toFile());
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($child->finishedGlass()->isNotEmpty()) {
|
if ($child->finishedGlass()->isNotEmpty()) {
|
||||||
$files['static']['finishedGlass'] = getFileData($child->finishedGlass()->toFile());
|
$files['static']['finishedGlass'] = getFileData($child->finishedGlass()->toFile());
|
||||||
}
|
}
|
||||||
|
|
@ -149,30 +216,18 @@ class ProjectPage extends NotificationsPage {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getStepLabel() {
|
|
||||||
$stepsLabel = [
|
|
||||||
"clientBrief" => "brief",
|
|
||||||
"proposal" => "offre commerciale",
|
|
||||||
"extendedBrief" => "brief enrichi",
|
|
||||||
"industrialIdeation" => "idéation industrielle",
|
|
||||||
"virtualSample" => "échantillon virtuel",
|
|
||||||
"physicalSample" => "échantillon physique",
|
|
||||||
];
|
|
||||||
|
|
||||||
return $stepsLabel[$this->currentStep()->value()] ?? "brief";
|
|
||||||
}
|
|
||||||
|
|
||||||
// public function printManagers() {
|
// public function printManagers() {
|
||||||
// return A::implode($this->managers()->toUsers()->pluck('name'), ', ');
|
// return A::implode($this->managers()->toUsers()->pluck('name'), ', ');
|
||||||
// }
|
// }
|
||||||
|
|
||||||
public function managers() {
|
public function managers() {
|
||||||
return kirby()->users()->filter(function($user) {
|
if ($this->users()->isEmpty()) {
|
||||||
if ($user->role() != 'admin' && $user->projects()->isEmpty()) {
|
return kirby()->users()->filterBy('role', 'admin');
|
||||||
return false;
|
}
|
||||||
}
|
$projectUsers = $this->users()->toUsers();
|
||||||
|
return kirby()->users()->filter(function($user) use ($projectUsers) {
|
||||||
return $user->role() == 'admin' || $user->projects()->toPages()->has($this);
|
return $user->role() == 'admin' || $projectUsers->has($user);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
18
public/site/plugins/analytics/blueprints/pages/analytics.yml
Normal file
18
public/site/plugins/analytics/blueprints/pages/analytics.yml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
title: Analytics
|
||||||
|
icon: chart
|
||||||
|
buttons: false
|
||||||
|
options:
|
||||||
|
changeTitle: false
|
||||||
|
|
||||||
|
status:
|
||||||
|
- unlisted
|
||||||
|
|
||||||
|
columns:
|
||||||
|
- width: 1/1
|
||||||
|
sections:
|
||||||
|
dashboard:
|
||||||
|
type: fields
|
||||||
|
fields:
|
||||||
|
analytics:
|
||||||
|
type: analytics-dashboard
|
||||||
|
label: false
|
||||||
37
public/site/plugins/analytics/classes/AnalyticsPage.php
Normal file
37
public/site/plugins/analytics/classes/AnalyticsPage.php
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace adrienpayet\analytics;
|
||||||
|
|
||||||
|
use Kirby\Cms\Page;
|
||||||
|
|
||||||
|
class AnalyticsPage extends Page
|
||||||
|
{
|
||||||
|
public function getAnalyticsData(array $filters = []): array
|
||||||
|
{
|
||||||
|
$user = kirby()->user();
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->isAdmin()) {
|
||||||
|
return AnalyticsStore::getAggregatedData($filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedProjects = $user->currentProjects();
|
||||||
|
$allowedEmails = [];
|
||||||
|
foreach ($allowedProjects as $project) {
|
||||||
|
$users = kirby()->users();
|
||||||
|
foreach ($users as $u) {
|
||||||
|
if ($u->currentProjects()->has($project)) {
|
||||||
|
$allowedEmails[] = $u->email()->value();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$filters['emails'] = array_unique($allowedEmails);
|
||||||
|
|
||||||
|
return AnalyticsStore::getAggregatedData($filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
159
public/site/plugins/analytics/classes/AnalyticsStore.php
Normal file
159
public/site/plugins/analytics/classes/AnalyticsStore.php
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace adrienpayet\analytics;
|
||||||
|
|
||||||
|
use Kirby\Data\Yaml;
|
||||||
|
use Kirby\Toolkit\F;
|
||||||
|
|
||||||
|
class AnalyticsStore
|
||||||
|
{
|
||||||
|
private static function getFilePath(): string
|
||||||
|
{
|
||||||
|
return kirby()->root('content') . '/analytics/visits.yml';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function ensureFileExists(): void
|
||||||
|
{
|
||||||
|
$filePath = self::getFilePath();
|
||||||
|
$dirPath = dirname($filePath);
|
||||||
|
|
||||||
|
if (!is_dir($dirPath)) {
|
||||||
|
F::mkdir($dirPath, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!F::exists($filePath)) {
|
||||||
|
F::write($filePath, Yaml::encode(['visits' => []]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function addVisit(Visit $visit): void
|
||||||
|
{
|
||||||
|
self::ensureFileExists();
|
||||||
|
|
||||||
|
$filePath = self::getFilePath();
|
||||||
|
$data = Yaml::decode(F::read($filePath));
|
||||||
|
|
||||||
|
$visits = $data['visits'] ?? [];
|
||||||
|
$visits[] = $visit->toArray();
|
||||||
|
|
||||||
|
// Limiter à 10000 visites max
|
||||||
|
if (count($visits) > 10000) {
|
||||||
|
$visits = array_slice($visits, -10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['visits'] = $visits;
|
||||||
|
F::write($filePath, Yaml::encode($data));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getVisits(array $filters = []): array
|
||||||
|
{
|
||||||
|
self::ensureFileExists();
|
||||||
|
|
||||||
|
$filePath = self::getFilePath();
|
||||||
|
$data = Yaml::decode(F::read($filePath));
|
||||||
|
$visits = $data['visits'] ?? [];
|
||||||
|
|
||||||
|
// Convertir en objets Visit
|
||||||
|
$visits = array_map(fn($v) => Visit::fromArray($v), $visits);
|
||||||
|
|
||||||
|
// Filtrer par daterange
|
||||||
|
if (!empty($filters['startDate']) || !empty($filters['endDate'])) {
|
||||||
|
$visits = array_filter($visits, function($visit) use ($filters) {
|
||||||
|
$timestamp = strtotime($visit->timestamp);
|
||||||
|
|
||||||
|
if (!empty($filters['startDate'])) {
|
||||||
|
$startDate = strtotime($filters['startDate'] . ' 00:00:00');
|
||||||
|
if ($timestamp < $startDate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filters['endDate'])) {
|
||||||
|
$endDate = strtotime($filters['endDate'] . ' 23:59:59');
|
||||||
|
if ($timestamp > $endDate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrer par projet
|
||||||
|
if (!empty($filters['project'])) {
|
||||||
|
$projectId = $filters['project'];
|
||||||
|
$visits = array_filter($visits, function($visit) use ($projectId) {
|
||||||
|
return str_contains($visit->pageUrl, "/projects/{$projectId}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrer par email (permissions)
|
||||||
|
if (!empty($filters['emails'])) {
|
||||||
|
$allowedEmails = $filters['emails'];
|
||||||
|
$visits = array_filter($visits, function($visit) use ($allowedEmails) {
|
||||||
|
return in_array($visit->email, $allowedEmails);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrer par nom de page
|
||||||
|
if (!empty($filters['pageNames'])) {
|
||||||
|
$pageNames = $filters['pageNames'];
|
||||||
|
$visits = array_filter($visits, function($visit) use ($pageNames) {
|
||||||
|
$name = $visit->pageName ?: $visit->pageUrl;
|
||||||
|
return in_array($name, $pageNames);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values($visits);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getAggregatedData(array $filters = []): array
|
||||||
|
{
|
||||||
|
self::ensureFileExists();
|
||||||
|
|
||||||
|
// Visits sans filtre pageNames → pour construire la liste des pages (options)
|
||||||
|
$filtersWithoutPages = $filters;
|
||||||
|
unset($filtersWithoutPages['pageNames']);
|
||||||
|
$allVisits = self::getVisits($filtersWithoutPages);
|
||||||
|
|
||||||
|
$visitsByPage = [];
|
||||||
|
foreach ($allVisits as $visit) {
|
||||||
|
$page = $visit->pageName ?: $visit->pageUrl;
|
||||||
|
$visitsByPage[$page] = ($visitsByPage[$page] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
arsort($visitsByPage);
|
||||||
|
|
||||||
|
// Visits avec tous les filtres → pour les stats affichées
|
||||||
|
$visits = !empty($filters['pageNames'])
|
||||||
|
? self::getVisits($filters)
|
||||||
|
: $allVisits;
|
||||||
|
|
||||||
|
$visitsByDay = [];
|
||||||
|
foreach ($visits as $visit) {
|
||||||
|
$day = date('Y-m-d', strtotime($visit->timestamp));
|
||||||
|
$visitsByDay[$day] = ($visitsByDay[$day] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
ksort($visitsByDay);
|
||||||
|
|
||||||
|
$uniqueSessions = count(array_unique(array_map(fn($v) => $v->sessionId, $visits)));
|
||||||
|
|
||||||
|
// Visites par jour par page (toutes les pages)
|
||||||
|
$visitsByDayByPage = [];
|
||||||
|
foreach ($visits as $visit) {
|
||||||
|
$page = $visit->pageName ?: $visit->pageUrl;
|
||||||
|
$day = date('Y-m-d', strtotime($visit->timestamp));
|
||||||
|
$visitsByDayByPage[$page][$day] = ($visitsByDayByPage[$page][$day] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
foreach ($visitsByDayByPage as &$days) {
|
||||||
|
ksort($days);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'totalVisits' => count($visits),
|
||||||
|
'uniqueSessions' => $uniqueSessions,
|
||||||
|
'visitsByDay' => $visitsByDay,
|
||||||
|
'visitsByPage' => array_slice($visitsByPage, 0, 10, true),
|
||||||
|
'visitsByDayByPage' => $visitsByDayByPage,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
46
public/site/plugins/analytics/classes/Visit.php
Normal file
46
public/site/plugins/analytics/classes/Visit.php
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace adrienpayet\analytics;
|
||||||
|
|
||||||
|
class Visit
|
||||||
|
{
|
||||||
|
public string $id;
|
||||||
|
public string $email;
|
||||||
|
public ?string $country;
|
||||||
|
public string $timestamp;
|
||||||
|
public string $sessionId;
|
||||||
|
public string $pageUrl;
|
||||||
|
public string $pageType;
|
||||||
|
public ?string $pageName;
|
||||||
|
|
||||||
|
public function __construct(array $data)
|
||||||
|
{
|
||||||
|
$this->id = $data['id'] ?? uniqid('visit_', true);
|
||||||
|
$this->email = $data['email'];
|
||||||
|
$this->country = $data['country'] ?? null;
|
||||||
|
$this->timestamp = $data['timestamp'] ?? date('Y-m-d H:i:s');
|
||||||
|
$this->sessionId = $data['sessionId'];
|
||||||
|
$this->pageUrl = $data['pageUrl'];
|
||||||
|
$this->pageType = $data['pageType'];
|
||||||
|
$this->pageName = $data['pageName'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'email' => $this->email,
|
||||||
|
'country' => $this->country,
|
||||||
|
'timestamp' => $this->timestamp,
|
||||||
|
'sessionId' => $this->sessionId,
|
||||||
|
'pageUrl' => $this->pageUrl,
|
||||||
|
'pageType' => $this->pageType,
|
||||||
|
'pageName' => $this->pageName,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
return new self($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
public/site/plugins/analytics/fields/dashboard.php
Normal file
18
public/site/plugins/analytics/fields/dashboard.php
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'props' => [
|
||||||
|
'value' => function ($value = null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'computed' => [
|
||||||
|
'analyticsData' => function () {
|
||||||
|
$page = $this->model();
|
||||||
|
if (method_exists($page, 'getAnalyticsData')) {
|
||||||
|
return $page->getAnalyticsData();
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
]
|
||||||
|
];
|
||||||
123
public/site/plugins/analytics/index.css
Normal file
123
public/site/plugins/analytics/index.css
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
|
||||||
|
.k-analytics-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.k-analytics-filters label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.k-date-inputs-wrapper {
|
||||||
|
display: flex;
|
||||||
|
column-gap: 1rem;
|
||||||
|
}
|
||||||
|
.k-analytics-filters input[type='date'] {
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--rounded);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
.k-analytics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin: 3rem 0;
|
||||||
|
}
|
||||||
|
.k-analytics-user-filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-left: 2rem;
|
||||||
|
}
|
||||||
|
.k-field-name-user,
|
||||||
|
.k-field-name-page {
|
||||||
|
min-width: 15rem;
|
||||||
|
}
|
||||||
|
.k-field-name-top-pages {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
.k-analytics-page-filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-left: 2rem;
|
||||||
|
}
|
||||||
|
.k-analytics-card {
|
||||||
|
background: var(--color-background);
|
||||||
|
border-radius: var(--rounded);
|
||||||
|
}
|
||||||
|
.k-analytics-card h3 {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.k-analytics-metric {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.k-analytics-chart-container {
|
||||||
|
background: var(--color-background);
|
||||||
|
border-radius: var(--rounded);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.k-analytics-chart-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.k-analytics-chart-header h3 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.k-analytics-chart-container canvas {
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
.k-analytics-empty {
|
||||||
|
background: var(--color-background);
|
||||||
|
border-radius: var(--rounded);
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.k-analytics-empty p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
.k-analytics-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.k-analytics-list li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.375rem 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.k-analytics-list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.k-analytics-list-label {
|
||||||
|
color: var(--color-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
.k-analytics-list-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
11703
public/site/plugins/analytics/index.js
Normal file
11703
public/site/plugins/analytics/index.js
Normal file
File diff suppressed because it is too large
Load diff
27
public/site/plugins/analytics/index.php
Normal file
27
public/site/plugins/analytics/index.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use adrienpayet\analytics\Visit;
|
||||||
|
use adrienpayet\analytics\AnalyticsPage;
|
||||||
|
use adrienpayet\analytics\AnalyticsStore;
|
||||||
|
|
||||||
|
F::loadClasses([
|
||||||
|
"adrienpayet\\analytics\\Visit" => __DIR__ . "/classes/Visit.php",
|
||||||
|
"adrienpayet\\analytics\\AnalyticsPage" => __DIR__ . "/classes/AnalyticsPage.php",
|
||||||
|
"adrienpayet\\analytics\\AnalyticsStore" => __DIR__ . "/classes/AnalyticsStore.php",
|
||||||
|
]);
|
||||||
|
|
||||||
|
Kirby::plugin("adrienpayet/analytics", [
|
||||||
|
"pageModels" => [
|
||||||
|
"analytics" => AnalyticsPage::class,
|
||||||
|
],
|
||||||
|
"blueprints" => [
|
||||||
|
"pages/analytics" => __DIR__ . "/blueprints/pages/analytics.yml",
|
||||||
|
],
|
||||||
|
"routes" => [
|
||||||
|
require(__DIR__ . "/routes/track.php"),
|
||||||
|
require(__DIR__ . "/routes/get-data.php"),
|
||||||
|
],
|
||||||
|
"fields" => [
|
||||||
|
"analytics-dashboard" => require(__DIR__ . "/fields/dashboard.php"),
|
||||||
|
],
|
||||||
|
]);
|
||||||
30
public/site/plugins/analytics/package-lock.json
generated
Normal file
30
public/site/plugins/analytics/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"name": "analytics",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"chart.js": "^4.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
public/site/plugins/analytics/package.json
Normal file
9
public/site/plugins/analytics/package.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"dev": "npx -y kirbyup src/index.js --watch",
|
||||||
|
"build": "npx -y kirbyup src/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"chart.js": "^4.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
80
public/site/plugins/analytics/routes/get-data.php
Normal file
80
public/site/plugins/analytics/routes/get-data.php
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pattern' => 'analytics-data.json',
|
||||||
|
'method' => 'GET',
|
||||||
|
'action' => function () {
|
||||||
|
$kirby = kirby();
|
||||||
|
$user = $kirby->user();
|
||||||
|
|
||||||
|
// Seuls les admins peuvent accéder aux données analytics
|
||||||
|
if (!$user || !$user->isAdmin()) {
|
||||||
|
return [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Unauthorized'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$analyticsPage = $kirby->page('analytics');
|
||||||
|
|
||||||
|
if (!$analyticsPage) {
|
||||||
|
return [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Analytics page not found'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $kirby->request();
|
||||||
|
$filters = [];
|
||||||
|
|
||||||
|
if ($startDate = $request->query()->get('startDate')) {
|
||||||
|
$filters['startDate'] = $startDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($endDate = $request->query()->get('endDate')) {
|
||||||
|
$filters['endDate'] = $endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($project = $request->query()->get('project')) {
|
||||||
|
$filters['project'] = $project;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($_GET['emails'])) {
|
||||||
|
$filters['emails'] = explode(',', $_GET['emails']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($_GET['pageNames'])) {
|
||||||
|
$filters['pageNames'] = explode(',', $_GET['pageNames']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $analyticsPage->getAnalyticsData($filters);
|
||||||
|
|
||||||
|
$users = [];
|
||||||
|
foreach ($kirby->users() as $u) {
|
||||||
|
$email = (string) $u->email();
|
||||||
|
$name = $u->name()->isNotEmpty() ? (string) $u->name() : $email;
|
||||||
|
$label = $name;
|
||||||
|
|
||||||
|
$clientField = $u->content()->get('client');
|
||||||
|
if ($clientField && $clientField->isNotEmpty()) {
|
||||||
|
$clientPage = $clientField->toPage();
|
||||||
|
if ($clientPage) {
|
||||||
|
$label .= ' (' . $clientPage->title() . ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$users[] = [
|
||||||
|
'email' => $email,
|
||||||
|
'label' => $label,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($users, fn($a, $b) => strcasecmp($a['label'], $b['label']));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'success',
|
||||||
|
'data' => $data,
|
||||||
|
'users' => $users
|
||||||
|
];
|
||||||
|
}
|
||||||
|
];
|
||||||
61
public/site/plugins/analytics/routes/track.php
Normal file
61
public/site/plugins/analytics/routes/track.php
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use adrienpayet\analytics\Visit;
|
||||||
|
use adrienpayet\analytics\AnalyticsStore;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pattern' => 'track-visit.json',
|
||||||
|
'method' => 'POST',
|
||||||
|
'action' => function () {
|
||||||
|
$kirby = kirby();
|
||||||
|
$user = $kirby->user();
|
||||||
|
|
||||||
|
// Seuls les utilisateurs connectés peuvent tracker
|
||||||
|
if (!$user) {
|
||||||
|
return [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Unauthorized'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $kirby->request()->body()->toArray();
|
||||||
|
|
||||||
|
// Détection du pays
|
||||||
|
$country = null;
|
||||||
|
|
||||||
|
// 1. Header Cloudflare
|
||||||
|
if (isset($_SERVER['HTTP_CF_IPCOUNTRY'])) {
|
||||||
|
$country = $_SERVER['HTTP_CF_IPCOUNTRY'];
|
||||||
|
}
|
||||||
|
// 2. Fallback : API ipapi.co (optionnel, peut être désactivé)
|
||||||
|
elseif (!empty($_SERVER['REMOTE_ADDR']) && $_SERVER['REMOTE_ADDR'] !== '127.0.0.1') {
|
||||||
|
try {
|
||||||
|
$ip = $_SERVER['REMOTE_ADDR'];
|
||||||
|
$response = @file_get_contents("https://ipapi.co/{$ip}/country/", false, stream_context_create([
|
||||||
|
'http' => ['timeout' => 1]
|
||||||
|
]));
|
||||||
|
if ($response) {
|
||||||
|
$country = trim($response);
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Ignorer les erreurs de géolocalisation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$visit = new Visit([
|
||||||
|
'email' => (string) $user->email(),
|
||||||
|
'country' => $country,
|
||||||
|
'sessionId' => $data['sessionId'] ?? '',
|
||||||
|
'pageUrl' => $data['pageUrl'] ?? '',
|
||||||
|
'pageType' => $data['pageType'] ?? 'unknown',
|
||||||
|
'pageName' => $data['pageName'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
AnalyticsStore::addVisit($visit);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => 'success',
|
||||||
|
'message' => 'Visit tracked'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,491 @@
|
||||||
|
<template>
|
||||||
|
<div class="k-analytics-dashboard">
|
||||||
|
<div class="k-analytics-filters">
|
||||||
|
<div class="k-analytics-date-filter">
|
||||||
|
<header class="k-field-header">
|
||||||
|
<label class="k-label k-field-label" title="Filtrer par dates">
|
||||||
|
<span class="k-label-text">Filtrer par dates </span>
|
||||||
|
</label>
|
||||||
|
</header>
|
||||||
|
<div class="k-date-inputs-wrapper">
|
||||||
|
<label>
|
||||||
|
Du
|
||||||
|
<input type="date" v-model="startDate" @change="fetchData" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Au
|
||||||
|
<input type="date" v-model="endDate" @change="fetchData" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="k-analytics-user-filter">
|
||||||
|
<k-multiselect-field
|
||||||
|
:options="userOptions"
|
||||||
|
:value="selectedEmails"
|
||||||
|
label="Filtrer par utilisateur(s)"
|
||||||
|
search="true"
|
||||||
|
name="user"
|
||||||
|
@input="onUserSelectionChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="k-analytics-page-filter">
|
||||||
|
<k-multiselect-field
|
||||||
|
:options="pageOptions"
|
||||||
|
:value="selectedPages"
|
||||||
|
label="Filtrer par page(s)"
|
||||||
|
search="true"
|
||||||
|
name="page"
|
||||||
|
@input="onPageSelectionChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!hasData" class="k-analytics-empty">
|
||||||
|
<p>Aucune donnée à afficher</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="k-analytics-grid">
|
||||||
|
<div class="k-analytics-card">
|
||||||
|
<h3>Sessions uniques</h3>
|
||||||
|
<div class="k-analytics-metric">{{ data.uniqueSessions }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="k-analytics-card">
|
||||||
|
<h3>Pages vues</h3>
|
||||||
|
<div class="k-analytics-metric">{{ data.totalVisits }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="k-analytics-card">
|
||||||
|
<h3>Pages / session</h3>
|
||||||
|
<div class="k-analytics-metric">{{ pagesPerSession }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="k-analytics-chart-container">
|
||||||
|
<div class="k-analytics-chart-header">
|
||||||
|
<h3>Visites par jour</h3>
|
||||||
|
<k-toggle-field
|
||||||
|
v-if="selectedPages.length === 0"
|
||||||
|
:value="showTopPagesOnly"
|
||||||
|
before="Moyenne"
|
||||||
|
after="Pages les + visitées"
|
||||||
|
text=" "
|
||||||
|
name="top-pages"
|
||||||
|
@input="onToggleTopPages"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<canvas ref="chartCanvas"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="k-analytics-card"
|
||||||
|
v-if="data.visitsByPage && Object.keys(data.visitsByPage).length"
|
||||||
|
>
|
||||||
|
<h3>Pages les plus visitées</h3>
|
||||||
|
<ul class="k-analytics-list">
|
||||||
|
<li v-for="(count, page) in data.visitsByPage" :key="page">
|
||||||
|
<span class="k-analytics-list-label">{{ page }}</span>
|
||||||
|
<span class="k-analytics-list-value">{{ count }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {
|
||||||
|
Chart,
|
||||||
|
LineController,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
LinearScale,
|
||||||
|
CategoryScale,
|
||||||
|
Filler,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from 'chart.js';
|
||||||
|
|
||||||
|
Chart.register(
|
||||||
|
LineController,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
LinearScale,
|
||||||
|
CategoryScale,
|
||||||
|
Filler,
|
||||||
|
Tooltip,
|
||||||
|
Legend
|
||||||
|
);
|
||||||
|
|
||||||
|
const CHART_COLORS = [
|
||||||
|
'#4271ae',
|
||||||
|
'#c82829',
|
||||||
|
'#8959a8',
|
||||||
|
'#4d9a0f',
|
||||||
|
'#f5871f',
|
||||||
|
'#3e999f',
|
||||||
|
'#718c00',
|
||||||
|
'#a3685a',
|
||||||
|
'#525252',
|
||||||
|
'#eab700',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
analyticsData: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
data: this.analyticsData || {},
|
||||||
|
chart: null,
|
||||||
|
users: [],
|
||||||
|
selectedEmails: [],
|
||||||
|
selectedPages: [],
|
||||||
|
showTopPagesOnly: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
hasData() {
|
||||||
|
return this.data && this.data.totalVisits > 0;
|
||||||
|
},
|
||||||
|
pagesPerSession() {
|
||||||
|
if (!this.data?.uniqueSessions) return '0';
|
||||||
|
return (this.data.totalVisits / this.data.uniqueSessions).toFixed(1);
|
||||||
|
},
|
||||||
|
userOptions() {
|
||||||
|
return this.users.map((u) => ({ value: u.email, text: u.label }));
|
||||||
|
},
|
||||||
|
pageOptions() {
|
||||||
|
if (!this.data?.visitsByPage) return [];
|
||||||
|
return Object.keys(this.data.visitsByPage)
|
||||||
|
.map((p) => ({ value: p, text: p }))
|
||||||
|
.sort((a, b) => a.text.localeCompare(b.text));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
analyticsData(newVal) {
|
||||||
|
this.data = newVal || {};
|
||||||
|
this.renderChart();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.setDefaultDates();
|
||||||
|
this.fetchData();
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeUnmount() {
|
||||||
|
this.destroyChart();
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
setDefaultDates() {
|
||||||
|
const now = new Date();
|
||||||
|
const thirtyDaysAgo = new Date(now);
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
|
||||||
|
this.endDate = now.toISOString().split('T')[0];
|
||||||
|
this.startDate = thirtyDaysAgo.toISOString().split('T')[0];
|
||||||
|
},
|
||||||
|
|
||||||
|
onUserSelectionChange(emails) {
|
||||||
|
this.selectedEmails = emails;
|
||||||
|
this.fetchData();
|
||||||
|
},
|
||||||
|
|
||||||
|
onPageSelectionChange(pages) {
|
||||||
|
this.selectedPages = pages;
|
||||||
|
this.fetchData();
|
||||||
|
},
|
||||||
|
|
||||||
|
onToggleTopPages(value) {
|
||||||
|
this.showTopPagesOnly = value;
|
||||||
|
this.renderChart();
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchData() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (this.startDate) params.set('startDate', this.startDate);
|
||||||
|
if (this.endDate) params.set('endDate', this.endDate);
|
||||||
|
if (this.selectedEmails.length) {
|
||||||
|
params.set('emails', this.selectedEmails.join(','));
|
||||||
|
}
|
||||||
|
if (this.selectedPages.length) {
|
||||||
|
params.set('pageNames', this.selectedPages.join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/analytics-data.json?${params}`);
|
||||||
|
const json = await response.json();
|
||||||
|
|
||||||
|
console.log(json.data);
|
||||||
|
|
||||||
|
if (json.status === 'success') {
|
||||||
|
this.data = json.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json.users && !this.users.length) {
|
||||||
|
this.users = json.users;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderChart();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Analytics fetch error:', e);
|
||||||
|
this.renderChart();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
destroyChart() {
|
||||||
|
if (this.chart) {
|
||||||
|
this.chart.destroy();
|
||||||
|
this.chart = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
formatDateFR(isoDate) {
|
||||||
|
const [y, m, d] = isoDate.split('-');
|
||||||
|
return `${d}/${m}/${y}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderChart() {
|
||||||
|
this.destroyChart();
|
||||||
|
|
||||||
|
const canvas = this.$refs.chartCanvas;
|
||||||
|
if (!canvas || !this.data?.visitsByDay) return;
|
||||||
|
|
||||||
|
const allDays = Object.keys(this.data.visitsByDay);
|
||||||
|
if (!allDays.length) return;
|
||||||
|
|
||||||
|
const labels = allDays.map((d) => this.formatDateFR(d));
|
||||||
|
const showPerPage =
|
||||||
|
this.selectedPages.length === 0 &&
|
||||||
|
this.showTopPagesOnly &&
|
||||||
|
this.data.visitsByDayByPage;
|
||||||
|
|
||||||
|
let datasets;
|
||||||
|
let maxValue;
|
||||||
|
|
||||||
|
if (showPerPage) {
|
||||||
|
const topPages = this.data.visitsByPage
|
||||||
|
? Object.keys(this.data.visitsByPage)
|
||||||
|
: [];
|
||||||
|
const pages = topPages.sort((a, b) => a.localeCompare(b));
|
||||||
|
datasets = pages.map((page, i) => {
|
||||||
|
const color = CHART_COLORS[i % CHART_COLORS.length];
|
||||||
|
const values = allDays.map(
|
||||||
|
(day) => this.data.visitsByDayByPage[page]?.[day] || 0
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
label: page,
|
||||||
|
data: values,
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
fill: false,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 2,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
borderWidth: 2,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
maxValue = Math.max(...datasets.flatMap((ds) => ds.data));
|
||||||
|
} else {
|
||||||
|
const values = Object.values(this.data.visitsByDay);
|
||||||
|
datasets = [
|
||||||
|
{
|
||||||
|
label: 'Visites',
|
||||||
|
data: values,
|
||||||
|
borderColor: '#4271ae',
|
||||||
|
backgroundColor: 'rgba(66, 113, 174, 0.1)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 3,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
maxValue = Math.max(...values);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.chart = new Chart(canvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: { labels, datasets },
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
display: showPerPage,
|
||||||
|
position: 'bottom',
|
||||||
|
labels: { boxWidth: 12, padding: 12 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
max: maxValue + 3,
|
||||||
|
ticks: { precision: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.k-analytics-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-analytics-filters label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-date-inputs-wrapper {
|
||||||
|
display: flex;
|
||||||
|
column-gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-analytics-filters input[type='date'] {
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--rounded);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-analytics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin: 3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-analytics-user-filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-left: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-field-name-user,
|
||||||
|
.k-field-name-page {
|
||||||
|
min-width: 15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-field-name-top-pages {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-analytics-page-filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-left: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-analytics-card {
|
||||||
|
background: var(--color-background);
|
||||||
|
border-radius: var(--rounded);
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-analytics-card h3 {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-analytics-metric {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-analytics-chart-container {
|
||||||
|
background: var(--color-background);
|
||||||
|
border-radius: var(--rounded);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-analytics-chart-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-analytics-chart-header h3 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-analytics-chart-container canvas {
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-analytics-empty {
|
||||||
|
background: var(--color-background);
|
||||||
|
border-radius: var(--rounded);
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-analytics-empty p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-analytics-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-analytics-list li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.375rem 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-analytics-list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-analytics-list-label {
|
||||||
|
color: var(--color-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.k-analytics-list-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
7
public/site/plugins/analytics/src/index.js
Normal file
7
public/site/plugins/analytics/src/index.js
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import AnalyticsDashboard from "./components/AnalyticsDashboard.vue";
|
||||||
|
|
||||||
|
window.panel.plugin("adrienpayet/analytics", {
|
||||||
|
fields: {
|
||||||
|
"analytics-dashboard": AnalyticsDashboard
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -46,6 +46,7 @@ return [
|
||||||
'author' => kirby()->user(),
|
'author' => kirby()->user(),
|
||||||
'id' => Str::uuid(),
|
'id' => Str::uuid(),
|
||||||
'type' => 'comment',
|
'type' => 'comment',
|
||||||
|
'readby' => [], // Pour le système de notifications dérivées
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isset($data->position->pageIndex)) {
|
if (isset($data->position->pageIndex)) {
|
||||||
|
|
@ -62,11 +63,8 @@ return [
|
||||||
|
|
||||||
echo json_encode(getFileData($newFile));
|
echo json_encode(getFileData($newFile));
|
||||||
|
|
||||||
try {
|
// Note: Les notifications sont maintenant dérivées des commentaires.
|
||||||
$project->createNotification($commentData);
|
// Plus besoin d'appeler createNotification().
|
||||||
} catch (\Throwable $th) {
|
|
||||||
throw new Exception($th->getMessage() . '. line ' . $th->getLine() . ' in file ' . $th->getFile(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
exit;
|
exit;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,8 @@ return [
|
||||||
|
|
||||||
echo json_encode(getFileData($newFile));
|
echo json_encode(getFileData($newFile));
|
||||||
|
|
||||||
$project = $page->parents()->findBy('template', 'project');
|
// Note: Les notifications sont maintenant dérivées des commentaires.
|
||||||
$project->deleteNotification($data->id);
|
// La suppression du commentaire supprime automatiquement la notification.
|
||||||
|
|
||||||
exit;
|
exit;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -31,18 +31,19 @@ return [
|
||||||
"author" => kirby()->user(),
|
"author" => kirby()->user(),
|
||||||
"id" => Str::uuid(),
|
"id" => Str::uuid(),
|
||||||
"type" => "comment-reply",
|
"type" => "comment-reply",
|
||||||
|
"readby" => [], // Pour le système de notifications dérivées
|
||||||
];
|
];
|
||||||
$newReply = new Reply($replyData);
|
$newReply = new Reply($replyData);
|
||||||
$comment['replies'][] = $newReply->toArray();
|
$comment['replies'][] = $newReply->toArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$newFile = $file->update([
|
$newFile = $file->update([
|
||||||
'comments' => $comments
|
'comments' => $comments
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$project = $page->parents()->findBy("template", "project");
|
// Note: Les notifications sont maintenant dérivées des commentaires.
|
||||||
$project->createNotification($replyData);
|
// Plus besoin d'appeler createNotification().
|
||||||
|
|
||||||
return getFileData($newFile);
|
return getFileData($newFile);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,6 @@ function processDTLProposals($page) {
|
||||||
],
|
],
|
||||||
"path" => "/projects/" . $page->slug() . "?dialog=proposal&fileIndex=" . $index,
|
"path" => "/projects/" . $page->slug() . "?dialog=proposal&fileIndex=" . $index,
|
||||||
"date" => $proposalFile->modified("d/MM/Y"),
|
"date" => $proposalFile->modified("d/MM/Y"),
|
||||||
"stepLabel" => "Proposition commerciale",
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -112,7 +111,6 @@ function processDTLProposals($page) {
|
||||||
],
|
],
|
||||||
"path" => "/projects/" . $page->slug() . "?dialog=industrial-ideation",
|
"path" => "/projects/" . $page->slug() . "?dialog=industrial-ideation",
|
||||||
"date" => $proposalFile->modified("d/MM/Y"),
|
"date" => $proposalFile->modified("d/MM/Y"),
|
||||||
"stepLabel" => "Idéation industrielle",
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -129,7 +127,6 @@ function processDTLProposals($page) {
|
||||||
],
|
],
|
||||||
"path" => "/projects/" . $page->slug() . "?dialog=virtual-sample",
|
"path" => "/projects/" . $page->slug() . "?dialog=virtual-sample",
|
||||||
"date" => $proposalPage->modified("d/MM/Y"),
|
"date" => $proposalPage->modified("d/MM/Y"),
|
||||||
"stepLabel" => "Échantillon virtuel - piste dynamique",
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -147,7 +144,6 @@ function processDTLProposals($page) {
|
||||||
],
|
],
|
||||||
"path" => "/projects/" . $page->slug() . "?dialog=virtual-sample",
|
"path" => "/projects/" . $page->slug() . "?dialog=virtual-sample",
|
||||||
"date" => $proposalFile->modified("d/MM/Y"),
|
"date" => $proposalFile->modified("d/MM/Y"),
|
||||||
"stepLabel" => "Échantillon virtuel - piste statique",
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,54 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
load([
|
use adrienpayet\notifications\NotificationCollector;
|
||||||
"ProjectPage" => "models/ProjectPage.php",
|
use adrienpayet\notifications\providers\CommentProvider;
|
||||||
], __DIR__);
|
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([
|
F::loadClasses([
|
||||||
// Own classes
|
// Nouvelles classes - Système de providers
|
||||||
"adrienpayet\\notifications\\Notification" => __DIR__ . "/src/Notification.php",
|
"adrienpayet\\notifications\\NotificationProvider" => __DIR__ . "/src/NotificationProvider.php",
|
||||||
"adrienpayet\\notifications\\NotificationsPage" => __DIR__ . "/src/NotificationsPage.php",
|
"adrienpayet\\notifications\\NotificationCollector" => __DIR__ . "/src/NotificationCollector.php",
|
||||||
|
"adrienpayet\\notifications\\providers\\CommentProvider" => __DIR__ . "/src/providers/CommentProvider.php",
|
||||||
// Shared classes
|
"adrienpayet\\notifications\\providers\\ReplyProvider" => __DIR__ . "/src/providers/ReplyProvider.php",
|
||||||
"adrienpayet\\D2P\\data\\Position" => __DIR__ . "/../classes/Position.php",
|
"adrienpayet\\notifications\\providers\\ProjectRequestProvider" => __DIR__ . "/src/providers/ProjectRequestProvider.php",
|
||||||
"adrienpayet\\D2P\data\Author" => __DIR__ . "/../classes/Author.php",
|
"adrienpayet\\notifications\\providers\\AppointmentRequestProvider" => __DIR__ . "/src/providers/AppointmentRequestProvider.php",
|
||||||
"adrienpayet\\D2P\\data\\location\\Location" => __DIR__ . "/../classes/location/Location.php",
|
"adrienpayet\\notifications\\providers\\ContentProvider" => __DIR__ . "/src/providers/ContentProvider.php",
|
||||||
"adrienpayet\\D2P\\data\\location\\PageDetails" => __DIR__ . "/../classes/location/PageDetails.php",
|
|
||||||
"adrienpayet\\D2P\\data\\location\\ProjectDetails" => __DIR__ . "/../classes/location/ProjectDetails.php",
|
// Anciennes classes - Gardées pour rétro-compatibilité pendant migration
|
||||||
"adrienpayet\\D2P\\data\\location\\FileDetails" => __DIR__ . "/../classes/location/FileDetails.php",
|
"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", [
|
Kirby::plugin("adrienpayet/pdc-notifications", [
|
||||||
"routes" => [
|
"options" => [
|
||||||
require(__DIR__ . "/routes/readAll.php"),
|
"collector" => $collector
|
||||||
require(__DIR__ . "/routes/read.php")
|
],
|
||||||
],
|
"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"),
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
42
public/site/plugins/notifications/routes/mark-all-read.php
Normal file
42
public/site/plugins/notifications/routes/mark-all-read.php
Normal 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->currentProjects()->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()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
46
public/site/plugins/notifications/routes/mark-as-read.php
Normal file
46
public/site/plugins/notifications/routes/mark-as-read.php
Normal 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()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
181
public/site/plugins/notifications/src/NotificationCollector.php
Normal file
181
public/site/plugins/notifications/src/NotificationCollector.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}})})();
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
:icon="icon"
|
:icon="icon"
|
||||||
:title="title"
|
:title="title"
|
||||||
@click="refreshCache()"
|
@click="refreshCache()"
|
||||||
|
:disabled="isProcessing"
|
||||||
>{{ text }}</k-button
|
>{{ text }}</k-button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -24,6 +25,8 @@ const { pageUri, pageStatus, lastCacheUpdate } = defineProps({
|
||||||
const text = ref("Rafraîchir");
|
const text = ref("Rafraîchir");
|
||||||
const icon = ref("refresh");
|
const icon = ref("refresh");
|
||||||
const theme = ref("aqua-icon");
|
const theme = ref("aqua-icon");
|
||||||
|
const isProcessing = ref(false);
|
||||||
|
|
||||||
const title = computed(() => {
|
const title = computed(() => {
|
||||||
return lastCacheUpdate?.length > 0
|
return lastCacheUpdate?.length > 0
|
||||||
? "Dernière mise à jour : " + lastCacheUpdate
|
? "Dernière mise à jour : " + lastCacheUpdate
|
||||||
|
|
@ -31,25 +34,91 @@ const title = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
async function refreshCache() {
|
async function refreshCache() {
|
||||||
text.value = "En cours…";
|
isProcessing.value = true;
|
||||||
icon.value = "loader";
|
icon.value = "loader";
|
||||||
theme.value = "orange-icon";
|
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 = {
|
const init = {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
body: JSON.stringify({ pageUri }),
|
body: JSON.stringify({ pageUri }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await fetch("/refresh-cache.json", init);
|
try {
|
||||||
const json = await res.json();
|
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);
|
console.log(json);
|
||||||
text.value = "Terminé";
|
text.value = "Terminé";
|
||||||
icon.value = "check";
|
icon.value = "check";
|
||||||
|
|
@ -58,6 +127,13 @@ async function refreshCache() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
location.href = location.href;
|
location.href = location.href;
|
||||||
}, 1500);
|
}, 1500);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
text.value = "Erreur";
|
||||||
|
icon.value = "alert";
|
||||||
|
theme.value = "red-icon";
|
||||||
|
isProcessing.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
set_time_limit(0);
|
set_time_limit(60);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'pattern' => '/refresh-cache.json',
|
'pattern' => '/refresh-cache.json',
|
||||||
|
|
@ -10,17 +10,42 @@ return [
|
||||||
|
|
||||||
if ($data->pageUri === 'projects') {
|
if ($data->pageUri === 'projects') {
|
||||||
$projects = page('projects')->children();
|
$projects = page('projects')->children();
|
||||||
foreach ($projects as $project) {
|
|
||||||
$project->rebuildStepsCache();
|
|
||||||
|
|
||||||
$formatter = new IntlDateFormatter('fr_FR', IntlDateFormatter::SHORT, IntlDateFormatter::SHORT, 'Europe/Paris');
|
// Support du batch processing
|
||||||
$project->update([
|
$offset = isset($data->offset) ? intval($data->offset) : 0;
|
||||||
'lastCacheUpdate' => $formatter->format(time())
|
$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 [
|
return [
|
||||||
'satus' => 'success',
|
'status' => 'success',
|
||||||
'message' => 'Données des pages projets rafraîchies avec succès.'
|
'message' => "Batch terminé : $processed projets traités.",
|
||||||
|
'processed' => $offset + $processed,
|
||||||
|
'total' => $total,
|
||||||
|
'remaining' => $remaining,
|
||||||
|
'hasMore' => $hasMore,
|
||||||
|
'nextOffset' => $hasMore ? $offset + $limit : null
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
|
|
@ -41,7 +66,7 @@ return [
|
||||||
|
|
||||||
if (!$project) {
|
if (!$project) {
|
||||||
return [
|
return [
|
||||||
'satus' => 'error',
|
'status' => 'error',
|
||||||
'message' => 'Impossible de rafraîchir les données de la page ' . $data->pageUri . '. Aucun projet correspondant.'
|
'message' => 'Impossible de rafraîchir les données de la page ' . $data->pageUri . '. Aucun projet correspondant.'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +80,7 @@ return [
|
||||||
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'satus' => 'success',
|
'status' => 'success',
|
||||||
'message' => 'Données de la page ' . $data->pageUri . ' rafraîchie avec succès.'
|
'message' => 'Données de la page ' . $data->pageUri . ' rafraîchie avec succès.'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,21 @@
|
||||||
Kirby::plugin('adrienpayet/pdc-authorized-projects', [
|
Kirby::plugin('adrienpayet/pdc-authorized-projects', [
|
||||||
'userMethods' => [
|
'userMethods' => [
|
||||||
'currentProjects' => function() {
|
'currentProjects' => function() {
|
||||||
if ($this->role() == 'admin') {
|
$listed = page('projects')->children()->listed();
|
||||||
return page('projects')->children()->listed();
|
if ($this->role() == 'admin' && $this->hasNoAssignedProjects()) {
|
||||||
} else {
|
return $listed;
|
||||||
return $this->projects()->toPages()->listed();
|
|
||||||
}
|
}
|
||||||
|
return $listed->filter(fn($project) => $project->users()->toUsers()->has($this));
|
||||||
},
|
},
|
||||||
'archivedProjects' => function() {
|
'archivedProjects' => function() {
|
||||||
if ($this->role() == 'admin') {
|
$unlisted = page('projects')->children()->unlisted();
|
||||||
return page('projects')->children()->unlisted();
|
if ($this->role() == 'admin' && $this->hasNoAssignedProjects()) {
|
||||||
} else {
|
return $unlisted;
|
||||||
return $this->projects()->toPages()->unlisted();
|
|
||||||
}
|
}
|
||||||
|
return $unlisted->filter(fn($project) => $project->users()->toUsers()->has($this));
|
||||||
|
},
|
||||||
|
'hasNoAssignedProjects' => function() {
|
||||||
|
return page('projects')->children()->filter(fn($p) => $p->users()->toUsers()->has($this))->isEmpty();
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
0
public/site/sessions/.gitkeep
Normal file
0
public/site/sessions/.gitkeep
Normal file
|
|
@ -1,5 +1,18 @@
|
||||||
<?php
|
<?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 = [
|
$project = [
|
||||||
'title' => $page->title()->value(),
|
'title' => $page->title()->value(),
|
||||||
'url' => $page->url(),
|
'url' => $page->url(),
|
||||||
|
|
@ -11,7 +24,7 @@ $project = [
|
||||||
'steps' => $page->getSteps(),
|
'steps' => $page->getSteps(),
|
||||||
'designToLight' => $page->isDTLEnabled()->isTrue() ? processDTLProposals($page) : null,
|
'designToLight' => $page->isDTLEnabled()->isTrue() ? processDTLProposals($page) : null,
|
||||||
'hasOptimizationRequest' => $page->hasOptimizationRequest()->isTrue(),
|
'hasOptimizationRequest' => $page->hasOptimizationRequest()->isTrue(),
|
||||||
'notifications' => $page->notifications()->yaml(),
|
'notifications' => $notifications,
|
||||||
];
|
];
|
||||||
|
|
||||||
$pageData = array_merge($genericData, $project);
|
$pageData = array_merge($genericData, $project);
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
$data = [
|
||||||
'title' => $project->title()->value(),
|
'title' => $project->title()->value(),
|
||||||
'url' => $project->url(),
|
'url' => $project->url(),
|
||||||
'uri' => '/' . $project->uri(),
|
'uri' => '/' . $project->uri(),
|
||||||
'modified' => $project->modified('Y-MM-d'),
|
'modified' => $project->modified('Y-MM-d'),
|
||||||
'currentStep' => $project->currentStep()->value(),
|
'currentStep' => $project->currentStep()->value(),
|
||||||
'status' => $project->status(),
|
'status' => $project->status(),
|
||||||
'logo' => $project->client()->toPage() ? $project->client()->toPage()->logo()->toFile()->url() : '',
|
'logo' => $project->client()->toPage() ? $project->client()->toPage()->logo()->toFile()->url() : '',
|
||||||
'steps' => $project->getSteps(),
|
'steps' => $project->getSteps(),
|
||||||
'notifications' => Yaml::decode($project->notifications()->value),
|
'notifications' => $notifications,
|
||||||
'uuid' => (string) $project->uuid(),
|
'uuid' => (string) $project->uuid(),
|
||||||
'slug' => (string) $project->slug(),
|
'slug' => (string) $project->slug(),
|
||||||
'isDTLEnabled' => $project->isDTLEnabled()->isTrue(),
|
'isDTLEnabled' => $project->isDTLEnabled()->isTrue(),
|
||||||
'hasOptimizationRequest' => $project->hasOptimizationRequest()->isTrue(),
|
'hasOptimizationRequest' => $project->hasOptimizationRequest()->isTrue(),
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($project->isDTLEnabled()) {
|
if ($project->isDTLEnabled()) {
|
||||||
$data['designToLight'] = processDTLProposals($project);
|
$data['designToLight'] = processDTLProposals($project);
|
||||||
|
|
@ -33,8 +41,15 @@ function getProjectData($project)
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$currentUser = $kirby->user();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$children = $kirby->user()->role() == 'admin' ? $page->childrenAndDrafts()->map(fn($project) => getProjectData($project))->values() : $kirby->user()->projects()->toPages()->map(fn($project) => getProjectData($project))->values();
|
if ($currentUser->role() == 'admin' && $currentUser->hasNoAssignedProjects()) {
|
||||||
|
$projectCollection = $page->childrenAndDrafts();
|
||||||
|
} else {
|
||||||
|
$projectCollection = $currentUser->currentProjects()->merge($currentUser->archivedProjects());
|
||||||
|
}
|
||||||
|
$children = $projectCollection->map(fn($project) => getProjectData($project, $currentUser))->values();
|
||||||
} catch (\Throwable $th) {
|
} catch (\Throwable $th) {
|
||||||
throw new Exception($th->getMessage() . ' line ' . $th->getLine() . ' in file ' . $th->getFile(), 1);
|
throw new Exception($th->getMessage() . ' line ' . $th->getLine() . ' in file ' . $th->getFile(), 1);
|
||||||
$children = [];
|
$children = [];
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.comments.empty::after {
|
.comments.empty::after {
|
||||||
content: "Partagez vos idées en ajoutant des commentaires";
|
content: attr(data-empty-message);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,22 @@
|
||||||
<div v-if="isExpanded" id="menu" class="flex | rounded-xl">
|
<div v-if="isExpanded" id="menu" class="flex | rounded-xl">
|
||||||
<header class="w-full | flex">
|
<header class="w-full | flex">
|
||||||
<!-- TODO: à dynamiser en récupérant le $site->title() -->
|
<!-- TODO: à dynamiser en récupérant le $site->title() -->
|
||||||
<p lang="en">Design to Pack</p>
|
<p :lang="currentLocale">Design to Pack</p>
|
||||||
|
<div class="lang-toggle">
|
||||||
|
<button
|
||||||
|
@click="switchLocale('fr')"
|
||||||
|
:class="{ active: currentLocale === 'fr' }"
|
||||||
|
>
|
||||||
|
FR
|
||||||
|
</button>
|
||||||
|
<span class="slash">/</span>
|
||||||
|
<button
|
||||||
|
@click="switchLocale('en')"
|
||||||
|
:class="{ active: currentLocale === 'en' }"
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<nav class="w-full | flow">
|
<nav class="w-full | flow">
|
||||||
<ul class="flex">
|
<ul class="flex">
|
||||||
|
|
@ -51,25 +66,23 @@
|
||||||
>{{ mainItem.title }}</router-link
|
>{{ mainItem.title }}</router-link
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-if="mainItem.title === 'Inspirations' && page?.newInspirations"
|
v-if="
|
||||||
|
mainItem.title === t('menu.inspirations') && page?.newInspirations
|
||||||
|
"
|
||||||
class="pill pill--secondary"
|
class="pill pill--secondary"
|
||||||
>{{ 'Nouveautés' }}</span
|
>{{ t('menu.news') }}</span
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<details :class="{ skeleton: !currentProjects }" open>
|
<details :class="{ skeleton: !currentProjects }" open>
|
||||||
<summary>Projets en cours</summary>
|
<summary>{{ t('menu.currentProjects') }}</summary>
|
||||||
<ul v-if="currentProjects.length > 0">
|
<ul v-if="currentProjects.length > 0">
|
||||||
<li
|
<li
|
||||||
v-for="project in currentProjects"
|
v-for="project in currentProjects"
|
||||||
:class="{ active: isCurrent(project) }"
|
:class="{ active: isCurrent(project) }"
|
||||||
>
|
>
|
||||||
<router-link
|
<router-link
|
||||||
:to="
|
:to="getProjectPath(project)"
|
||||||
isEmptyBrief(project)
|
|
||||||
? project.uri + '/client-brief'
|
|
||||||
: project.uri
|
|
||||||
"
|
|
||||||
:class="hasUnreadNotification(project) ? 'new' : undefined"
|
:class="hasUnreadNotification(project) ? 'new' : undefined"
|
||||||
:data-dtl="project.isDTLEnabled ? 'true' : undefined"
|
:data-dtl="project.isDTLEnabled ? 'true' : undefined"
|
||||||
@click="collapse()"
|
@click="collapse()"
|
||||||
|
|
@ -79,13 +92,13 @@
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
<details v-if="archivedProjects.length">
|
<details v-if="archivedProjects.length">
|
||||||
<summary>Projets archivés</summary>
|
<summary>{{ t('menu.archivedProjects') }}</summary>
|
||||||
<ul>
|
<ul>
|
||||||
<li
|
<li
|
||||||
v-for="project in archivedProjects"
|
v-for="project in archivedProjects"
|
||||||
:class="{ active: isCurrent(project) }"
|
:class="{ active: isCurrent(project) }"
|
||||||
>
|
>
|
||||||
<router-link :to="project.uri" @click="collapse()">{{
|
<router-link :to="getProjectPath(project)" @click="collapse()">{{
|
||||||
project.title
|
project.title
|
||||||
}}</router-link>
|
}}</router-link>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -96,13 +109,19 @@
|
||||||
<ul class="flex">
|
<ul class="flex">
|
||||||
<li data-icon="user">
|
<li data-icon="user">
|
||||||
<a
|
<a
|
||||||
:href="user.role === 'admin' ? '/panel/account' : '/account'"
|
:href="
|
||||||
|
user.role === 'admin'
|
||||||
|
? '/panel/account'
|
||||||
|
: currentLocale === 'en'
|
||||||
|
? '/en/account'
|
||||||
|
: '/account'
|
||||||
|
"
|
||||||
@click="collapse()"
|
@click="collapse()"
|
||||||
>Profil</a
|
>{{ t('menu.profile') }}</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li data-icon="logout">
|
<li data-icon="logout">
|
||||||
<a href="/logout" @click="collapse()">Déconnexion</a>
|
<a href="/logout" @click="collapse()">{{ t('menu.logout') }}</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
@ -113,17 +132,23 @@
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useProjectsStore } from '../stores/projects';
|
import { useProjectsStore } from '../stores/projects';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useUserStore } from '../stores/user';
|
import { useUserStore } from '../stores/user';
|
||||||
import { usePageStore } from '../stores/page';
|
import { usePageStore } from '../stores/page';
|
||||||
import { useProjectStore } from '../stores/project';
|
import { useProjectStore } from '../stores/project';
|
||||||
|
import { useLocaleStore } from '../stores/locale';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
const isExpanded = ref(true);
|
const isExpanded = ref(true);
|
||||||
const { user, notifications } = storeToRefs(useUserStore());
|
const { user, notifications } = storeToRefs(useUserStore());
|
||||||
const { currentProjects, archivedProjects } = storeToRefs(useProjectsStore());
|
const { currentProjects, archivedProjects } = storeToRefs(useProjectsStore());
|
||||||
const { isEmptyBrief } = useProjectStore();
|
const { isEmptyBrief } = useProjectStore();
|
||||||
const { page } = storeToRefs(usePageStore());
|
const { page } = storeToRefs(usePageStore());
|
||||||
|
const localeStore = useLocaleStore();
|
||||||
|
const { currentLocale } = storeToRefs(localeStore);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const unreadNotificationsCount = computed(() => {
|
const unreadNotificationsCount = computed(() => {
|
||||||
if (!user.value) return 0;
|
if (!user.value) return 0;
|
||||||
|
|
@ -135,34 +160,37 @@ const unreadNotificationsCount = computed(() => {
|
||||||
return count === 0 ? 0 : count;
|
return count === 0 ? 0 : count;
|
||||||
});
|
});
|
||||||
|
|
||||||
const mainItems = [
|
const mainItems = computed(() => {
|
||||||
{
|
const prefix = currentLocale.value === 'en' ? '/en' : '';
|
||||||
title: 'Home',
|
return [
|
||||||
path: '/',
|
{
|
||||||
icon: 'home',
|
title: t('menu.home'),
|
||||||
},
|
path: prefix + '/',
|
||||||
{
|
icon: 'home',
|
||||||
title: 'Notifications',
|
},
|
||||||
path: '/notifications',
|
{
|
||||||
icon: 'megaphone',
|
title: t('menu.notifications'),
|
||||||
},
|
path: prefix + '/notifications',
|
||||||
{
|
icon: 'megaphone',
|
||||||
title: 'Réunions',
|
},
|
||||||
path: '/reunions',
|
{
|
||||||
icon: 'calendar',
|
title: t('menu.meetings'),
|
||||||
disabled: true,
|
path: prefix + '/reunions',
|
||||||
},
|
icon: 'calendar',
|
||||||
{
|
disabled: true,
|
||||||
title: 'Design to Light',
|
},
|
||||||
path: '/design-to-light',
|
{
|
||||||
icon: 'leaf',
|
title: t('menu.designToLight'),
|
||||||
},
|
path: prefix + '/design-to-light',
|
||||||
{
|
icon: 'leaf',
|
||||||
title: 'Inspirations',
|
},
|
||||||
path: '/inspirations',
|
{
|
||||||
icon: 'inspiration',
|
title: t('menu.inspirations'),
|
||||||
},
|
path: prefix + '/inspirations',
|
||||||
];
|
icon: 'inspiration',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
function toggleExpand() {
|
function toggleExpand() {
|
||||||
isExpanded.value = !isExpanded.value;
|
isExpanded.value = !isExpanded.value;
|
||||||
|
|
@ -192,6 +220,33 @@ function collapse() {
|
||||||
isExpanded.value = false;
|
isExpanded.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function switchLocale(newLocale) {
|
||||||
|
if (newLocale === currentLocale.value) return;
|
||||||
|
|
||||||
|
let newPath = route.path;
|
||||||
|
|
||||||
|
if (newLocale === 'en') {
|
||||||
|
if (!newPath.startsWith('/en')) {
|
||||||
|
newPath = '/en' + newPath;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (newPath.startsWith('/en')) {
|
||||||
|
newPath = newPath.replace(/^\/en/, '') || '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localeStore.setLocale(newLocale);
|
||||||
|
router.push(newPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectPath(project) {
|
||||||
|
const prefix = currentLocale.value === 'en' ? '/en' : '';
|
||||||
|
const path = isEmptyBrief(project)
|
||||||
|
? project.uri + '/client-brief'
|
||||||
|
: project.uri;
|
||||||
|
return prefix + path;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -243,6 +298,25 @@ button[aria-controls='menu'][aria-expanded='false']
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Lang toggle */
|
||||||
|
.lang-toggle {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
height: 1.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-toggle button {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-toggle button.active {
|
||||||
|
background-color: transparent !important;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-toggle span.slash {
|
||||||
|
padding: 0 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
button[aria-controls='menu'][aria-expanded='true'] {
|
button[aria-controls='menu'][aria-expanded='true'] {
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
@ -285,6 +359,7 @@ button[aria-controls='menu'][aria-expanded='false']
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
column-gap: calc(var(--gutter) / 2);
|
||||||
}
|
}
|
||||||
#menu header::before {
|
#menu header::before {
|
||||||
content: '';
|
content: '';
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,12 @@
|
||||||
modal
|
modal
|
||||||
:draggable="false"
|
:draggable="false"
|
||||||
:dismissableMask="true"
|
:dismissableMask="true"
|
||||||
header="Demander la création d’un projet"
|
:header="t('dialogs.requestProject')"
|
||||||
class="dialog"
|
class="dialog"
|
||||||
:closeOnEscape="true"
|
:closeOnEscape="true"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<h2 class="font-serif text-lg">Demander la création d’un projet</h2>
|
<h2 class="font-serif text-lg">{{ t('dialogs.requestProject') }}</h2>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
|
|
@ -18,24 +18,28 @@
|
||||||
class="w-full h-full p-16 flex flex-col"
|
class="w-full h-full p-16 flex flex-col"
|
||||||
style="--row-gap: 1rem"
|
style="--row-gap: 1rem"
|
||||||
>
|
>
|
||||||
<label for="project-title" class="sr-only">Nom du projet</label>
|
<label for="project-title" class="sr-only">{{
|
||||||
|
t('forms.projectName')
|
||||||
|
}}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
v-model="title"
|
v-model="title"
|
||||||
id="project-title"
|
id="project-title"
|
||||||
placeholder="Nom du projet"
|
:placeholder="t('forms.projectName')"
|
||||||
class="w-full rounded-md border border-grey-200 px-16 py-12"
|
class="w-full rounded-md border border-grey-200 px-16 py-12"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label for="project-details" class="sr-only">Détails du projet</label>
|
<label for="project-details" class="sr-only">{{
|
||||||
|
t('forms.projectDetails')
|
||||||
|
}}</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="project-details"
|
id="project-details"
|
||||||
name="details"
|
name="details"
|
||||||
v-model="details"
|
v-model="details"
|
||||||
cols="30"
|
cols="30"
|
||||||
rows="10"
|
rows="10"
|
||||||
placeholder="Détails du projet…"
|
:placeholder="t('forms.projectDetailsPlaceholder')"
|
||||||
class="w-full flex-1 rounded-md border border-grey-200 px-16 py-12"
|
class="w-full flex-1 rounded-md border border-grey-200 px-16 py-12"
|
||||||
required
|
required
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
@ -52,26 +56,24 @@
|
||||||
class="flex font-medium mt-4"
|
class="flex font-medium mt-4"
|
||||||
style="--column-gap: var(--space-4)"
|
style="--column-gap: var(--space-4)"
|
||||||
>
|
>
|
||||||
Créer avec
|
{{ t('dialogs.createWithDTL') }}
|
||||||
<span class="flex justify-center text-sm" data-icon="leaf"
|
<span class="flex justify-center text-sm" data-icon="leaf">{{
|
||||||
>Design to Light</span
|
t('dtl.title')
|
||||||
>
|
}}</span>
|
||||||
</label>
|
</label>
|
||||||
<p class="text-sm mt-8 mb-4">
|
<p class="text-sm mt-8 mb-4">
|
||||||
Découvrez la note environnementale de votre projet et allégez l’impact
|
{{ t('dialogs.dtlDescription') }}
|
||||||
de votre projet grâce à nos expertises d’optimisation du poids de
|
|
||||||
flacon.
|
|
||||||
</p>
|
</p>
|
||||||
<router-link to="/design-to-light" class="text-sm font-medium"
|
<router-link to="/design-to-light" class="text-sm font-medium">{{
|
||||||
>En savoir plus</router-link
|
t('dialogs.learnMore')
|
||||||
>
|
}}</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="flex-columns w-full mt-16" style="column-gap: 0.5rem">
|
<footer class="flex-columns w-full mt-16" style="column-gap: 0.5rem">
|
||||||
<button class="btn btn--black-10" @click="emits('close')">
|
<button class="btn btn--black-10" @click="emits('close')">
|
||||||
Annuler
|
{{ t('buttons.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn" type="submit">Soumettre</button>
|
<button class="btn" type="submit">{{ t('buttons.submit') }}</button>
|
||||||
</footer>
|
</footer>
|
||||||
</form>
|
</form>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
@ -81,6 +83,9 @@
|
||||||
import Dialog from 'primevue/dialog';
|
import Dialog from 'primevue/dialog';
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
import { useApiStore } from '../stores/api';
|
import { useApiStore } from '../stores/api';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const title = ref('');
|
const title = ref('');
|
||||||
const details = ref('');
|
const details = ref('');
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<header class="flex">
|
<header class="flex">
|
||||||
<h2 id="tabslist" class="sr-only">Projets</h2>
|
<h2 id="tabslist" class="sr-only">{{ t('brief.projects') }}</h2>
|
||||||
<Tabs :tabs="tabs" @update:currentTab="changeTab" />
|
<Tabs :tabs="tabs" @update:currentTab="changeTab" />
|
||||||
</header>
|
</header>
|
||||||
<section
|
<section
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
:aria-label="tabs[0].label"
|
:aria-label="tabs[0].label"
|
||||||
class="flow"
|
class="flow"
|
||||||
:class="{ skeleton: isProjectsLoading }"
|
:class="{ skeleton: isProjectsLoading }"
|
||||||
|
:data-empty-text="t('projects.none')"
|
||||||
>
|
>
|
||||||
<Project
|
<Project
|
||||||
v-for="project in currentProjects"
|
v-for="project in currentProjects"
|
||||||
|
|
@ -25,6 +26,7 @@
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
:aria-label="tabs[1].label"
|
:aria-label="tabs[1].label"
|
||||||
class="flow"
|
class="flow"
|
||||||
|
:data-empty-text="t('projects.none')"
|
||||||
>
|
>
|
||||||
<Project
|
<Project
|
||||||
v-for="project in archivedProjects"
|
v-for="project in archivedProjects"
|
||||||
|
|
@ -39,7 +41,9 @@ import Project from './project/Project.vue';
|
||||||
import { useProjectsStore } from '../stores/projects';
|
import { useProjectsStore } from '../stores/projects';
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const { projects, currentProjects, archivedProjects, isProjectsLoading } =
|
const { projects, currentProjects, archivedProjects, isProjectsLoading } =
|
||||||
storeToRefs(useProjectsStore());
|
storeToRefs(useProjectsStore());
|
||||||
|
|
||||||
|
|
@ -47,13 +51,13 @@ const currentTab = ref('currentProjects');
|
||||||
const tabs = computed(() => {
|
const tabs = computed(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: 'Projets en cours',
|
label: t('projects.current'),
|
||||||
id: 'currentProjects',
|
id: 'currentProjects',
|
||||||
count: currentProjects.value.length,
|
count: currentProjects.value.length,
|
||||||
isActive: currentTab.value === 'currentProjects',
|
isActive: currentTab.value === 'currentProjects',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Projets archivés',
|
label: t('projects.archived'),
|
||||||
id: 'archivedProjects',
|
id: 'archivedProjects',
|
||||||
count: archivedProjects.value.length,
|
count: archivedProjects.value.length,
|
||||||
isActive: currentTab.value === 'archivedProjects',
|
isActive: currentTab.value === 'archivedProjects',
|
||||||
|
|
@ -72,7 +76,7 @@ section {
|
||||||
min-height: calc(100vh - 8.5rem);
|
min-height: calc(100vh - 8.5rem);
|
||||||
}
|
}
|
||||||
section:not(.skeleton):empty::after {
|
section:not(.skeleton):empty::after {
|
||||||
content: 'Aucun projet pour le moment';
|
content: attr(data-empty-text);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
v-model="currentValue"
|
v-model="currentValue"
|
||||||
:options="items"
|
:options="items"
|
||||||
optionLabel="title"
|
optionLabel="title"
|
||||||
:placeholder="'Sélectionnez une déclinaison'"
|
:placeholder="t('forms.selectVariation')"
|
||||||
:maxSelectedLabels="3"
|
:maxSelectedLabels="3"
|
||||||
class="font-serif"
|
class="font-serif"
|
||||||
:class="{ active: currentValue }"
|
:class="{ active: currentValue }"
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
<p v-if="currentValue">
|
<p v-if="currentValue">
|
||||||
{{ currentValue.title }}
|
{{ currentValue.title }}
|
||||||
</p>
|
</p>
|
||||||
<p v-else>Sélectionnez une déclinaison</p>
|
<p v-else>{{ t('forms.selectVariation') }}</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #option="slotProps">
|
<template #option="slotProps">
|
||||||
|
|
@ -65,6 +65,9 @@
|
||||||
import { onBeforeMount, ref, watch, nextTick } from 'vue';
|
import { onBeforeMount, ref, watch, nextTick } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useDialogStore } from '../stores/dialog';
|
import { useDialogStore } from '../stores/dialog';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
const { items, label, isCompareModeEnabled, index } = defineProps({
|
const { items, label, isCompareModeEnabled, index } = defineProps({
|
||||||
|
|
@ -76,112 +79,149 @@ const { items, label, isCompareModeEnabled, index } = defineProps({
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const currentValue = ref(null);
|
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());
|
const { activeTracks } = storeToRefs(useDialogStore());
|
||||||
|
|
||||||
// Utils
|
function normalizeSlug(slug) {
|
||||||
function isSame(a, b) {
|
return slug.replace(/_/g, '-');
|
||||||
if (!a || !b) return false;
|
|
||||||
if (a.slug && b.slug) return a.slug === b.slug;
|
|
||||||
return a.title === b.title;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toVariation(v) {
|
function areVariationsEqual(variationA, variationB) {
|
||||||
if (!v) return null;
|
if (!variationA || !variationB) return false;
|
||||||
return Array.isArray(v) ? v[v.length - 1] || null : v;
|
|
||||||
|
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
|
function extractVariation(value) {
|
||||||
onBeforeMount(() => {
|
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;
|
syncing.value = true;
|
||||||
|
|
||||||
if (index === 0) {
|
const matchedVariations = findMatchingVariationsInStore(storeVariations);
|
||||||
currentValue.value = items[0] || null;
|
|
||||||
// si le store est vide, initialiser avec la variation du premier sélecteur
|
if (isCompareModeEnabled) {
|
||||||
if (!activeTracks.value || activeTracks.value.length === 0) {
|
currentValue.value = matchedVariations.length ? [...matchedVariations] : [];
|
||||||
const v = toVariation(items[0]);
|
|
||||||
if (v) activeTracks.value = [v];
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// les autres ne forcent pas le store ; leur currentValue restera à null
|
currentValue.value = matchedVariations[0] || null;
|
||||||
currentValue.value = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nextTick(() => (syncing.value = false));
|
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(
|
watch(
|
||||||
() => isCompareModeEnabled,
|
() => isCompareModeEnabled,
|
||||||
(flag) => {
|
(shouldBeArray) => {
|
||||||
syncing.value = true;
|
syncing.value = true;
|
||||||
if (flag) {
|
currentValue.value = convertValueForCompareMode(
|
||||||
if (currentValue.value && !Array.isArray(currentValue.value)) {
|
currentValue.value,
|
||||||
currentValue.value = [currentValue.value];
|
shouldBeArray
|
||||||
}
|
);
|
||||||
} else {
|
|
||||||
if (Array.isArray(currentValue.value)) {
|
|
||||||
currentValue.value = currentValue.value[0] || null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nextTick(() => (syncing.value = false));
|
nextTick(() => (syncing.value = false));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Détection ajout / suppression dans le MultiSelect (côté composant)
|
watch(currentValue, handleVariationChange, { deep: true });
|
||||||
// On n'agit que si l'ajout/suppression concerne une variation appartenant à `items`
|
|
||||||
watch(
|
|
||||||
currentValue,
|
|
||||||
(newVal, oldVal) => {
|
|
||||||
if (syncing.value) return;
|
|
||||||
|
|
||||||
const newItems = Array.isArray(newVal) ? newVal : newVal ? [newVal] : [];
|
|
||||||
const oldItems = Array.isArray(oldVal) ? oldVal : oldVal ? [oldVal] : [];
|
|
||||||
|
|
||||||
const added = newItems.find((n) => !oldItems.some((o) => isSame(o, n)));
|
|
||||||
const removed = oldItems.find((o) => !newItems.some((n) => isSame(n, o)));
|
|
||||||
|
|
||||||
if (added && items.some((it) => isSame(it, added))) {
|
|
||||||
selectTrack(added, 'add');
|
|
||||||
} else if (removed && items.some((it) => isSame(it, removed))) {
|
|
||||||
selectTrack(removed, 'remove');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Quand activeTracks change elsewhere -> synchroniser l'affichage local
|
|
||||||
// Mais n'adopter que les variations qui appartiennent à ce Selector (`items`)
|
|
||||||
watch(
|
watch(
|
||||||
activeTracks,
|
activeTracks,
|
||||||
(newVal) => {
|
(storeVariations) => {
|
||||||
syncing.value = true;
|
const variationsList = Array.isArray(storeVariations)
|
||||||
|
? storeVariations
|
||||||
const storeList = Array.isArray(newVal) ? newVal : [];
|
: [];
|
||||||
// ne garder que les variations du store qui sont dans `items`
|
syncCurrentValueFromStore(variationsList);
|
||||||
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));
|
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true, immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Logique centrale de sélection (ajout / suppression)
|
function removeVariationFromActiveTracks(variation) {
|
||||||
// Règles :
|
activeTracks.value = activeTracks.value.filter(
|
||||||
// - mode normal -> activeTracks = [variation]
|
(track) => !areVariationsEqual(track, 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 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 (!variation) return;
|
||||||
|
|
||||||
if (!isCompareModeEnabled) {
|
if (!isCompareModeEnabled) {
|
||||||
|
|
@ -190,34 +230,12 @@ function selectTrack(track, action = 'add') {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'remove') {
|
if (action === 'remove') {
|
||||||
const wasFirst =
|
removeVariationFromActiveTracks(variation);
|
||||||
activeTracks.value.length && isSame(activeTracks.value[0], variation);
|
|
||||||
activeTracks.value = activeTracks.value.filter(
|
|
||||||
(t) => !isSame(t, variation)
|
|
||||||
);
|
|
||||||
|
|
||||||
// si on a retiré la première et qu'il reste une piste, elle devient naturellement index 0
|
|
||||||
// pas d'action supplémentaire nécessaire ici (déjà assuré par le filter)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// action === 'add'
|
|
||||||
if (activeTracks.value.some((t) => isSame(t, variation))) {
|
|
||||||
// déjà présent -> ignore
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeTracks.value.length === 0) {
|
|
||||||
activeTracks.value = [variation];
|
|
||||||
} else if (activeTracks.value.length === 1) {
|
|
||||||
activeTracks.value = [activeTracks.value[0], variation];
|
|
||||||
} else {
|
} else {
|
||||||
// remplacer le 2e
|
addVariationToActiveTracks(variation);
|
||||||
activeTracks.value = [activeTracks.value[0], variation];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helpers pour affichage (inchangés)
|
|
||||||
function getFrontViewUrl(item) {
|
function getFrontViewUrl(item) {
|
||||||
if (!item) return '';
|
if (!item) return '';
|
||||||
if (Array.isArray(item)) {
|
if (Array.isArray(item)) {
|
||||||
|
|
@ -231,8 +249,8 @@ function getFrontViewUrl(item) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function setImage() {
|
function setImage() {
|
||||||
return getFrontViewUrl(currentValue.value)
|
return getFrontViewUrl(currentValue.value)
|
||||||
? '--image: url(\'' + getFrontViewUrl(currentValue.value) + '\')'
|
? "--image: url('" + getFrontViewUrl(currentValue.value) + "')"
|
||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -250,7 +268,8 @@ function setImage() {
|
||||||
padding: var(--space-8) var(--space-48) var(--space-8) var(--space-16);
|
padding: var(--space-8) var(--space-48) var(--space-8) var(--space-16);
|
||||||
}
|
}
|
||||||
.selector-dropdown.has-image,
|
.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);
|
padding-left: var(--space-64);
|
||||||
}
|
}
|
||||||
.selector-dropdown.has-image:before {
|
.selector-dropdown.has-image:before {
|
||||||
|
|
@ -290,7 +309,9 @@ function setImage() {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
[role='combobox'] p,
|
[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;
|
max-height: 1lh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,7 @@
|
||||||
|
|
||||||
<footer v-if="!comment.isEditMode" class="comment__replies">
|
<footer v-if="!comment.isEditMode" class="comment__replies">
|
||||||
<p v-if="comment.replies?.length > 0">
|
<p v-if="comment.replies?.length > 0">
|
||||||
{{ comment.replies.length }} réponse{{
|
{{ comment.replies.length }} {{ comment.replies.length > 1 ? t('comments.replies') : t('comments.reply') }}
|
||||||
comment.replies.length > 1 ? 's' : ''
|
|
||||||
}}
|
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
v-if="userStore.canEditComment(comment)"
|
v-if="userStore.canEditComment(comment)"
|
||||||
|
|
@ -52,14 +50,14 @@
|
||||||
data-icon="edit"
|
data-icon="edit"
|
||||||
@click="editComment($event)"
|
@click="editComment($event)"
|
||||||
>
|
>
|
||||||
<span class="sr-only">Éditer</span>
|
<span class="sr-only">{{ t('comments.edit') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn btn--transparent btn--icon btn--sm"
|
class="btn btn--transparent btn--icon btn--sm"
|
||||||
data-icon="delete"
|
data-icon="delete"
|
||||||
@click="deleteComment($event)"
|
@click="deleteComment($event)"
|
||||||
>
|
>
|
||||||
<span class="sr-only">Supprimer</span>
|
<span class="sr-only">{{ t('buttons.delete') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
@ -68,11 +66,11 @@
|
||||||
<input
|
<input
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn--tranparent"
|
class="btn btn--tranparent"
|
||||||
value="Sauvegarder"
|
:value="t('buttons.save')"
|
||||||
@click="saveEditedComment($event)"
|
@click="saveEditedComment($event)"
|
||||||
/>
|
/>
|
||||||
<button class="btn btn--white-10" @click="cancelEditComment($event)">
|
<button class="btn btn--white-10" @click="cancelEditComment($event)">
|
||||||
Annuler
|
{{ t('buttons.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
|
|
@ -88,9 +86,11 @@ import { computed, onMounted, ref, useTemplateRef } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { usePageStore } from '../../stores/page';
|
import { usePageStore } from '../../stores/page';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
dayjs.locale('fr');
|
dayjs.locale('fr');
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const { comment, commentIndex } = defineProps({
|
const { comment, commentIndex } = defineProps({
|
||||||
comment: Object,
|
comment: Object,
|
||||||
commentIndex: Number,
|
commentIndex: Number,
|
||||||
|
|
@ -125,11 +125,11 @@ function formatDate() {
|
||||||
const dateNumber = parseInt(dayjs(comment.date).format('YYMMD'));
|
const dateNumber = parseInt(dayjs(comment.date).format('YYMMD'));
|
||||||
|
|
||||||
if (dateNumber === todayNumber) {
|
if (dateNumber === todayNumber) {
|
||||||
return "Aujourd'hui";
|
return t('dates.today');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dateNumber === todayNumber - 1) {
|
if (dateNumber === todayNumber - 1) {
|
||||||
return 'hier';
|
return t('dates.yesterday');
|
||||||
}
|
}
|
||||||
|
|
||||||
return dayjs(comment.date).format('D MMM YY');
|
return dayjs(comment.date).format('D MMM YY');
|
||||||
|
|
@ -153,7 +153,7 @@ async function read() {
|
||||||
page.value.uri
|
page.value.uri
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('Erreur lors de la lecture de la notification : ', error);
|
console.log(t('errors.readNotificationFailed'), error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<aside id="comments-container" aria-labelledby="comments-label">
|
<aside id="comments-container" aria-labelledby="comments-label">
|
||||||
<h2 id="comments-label" class="sr-only">Commentaires</h2>
|
<h2 id="comments-label" class="sr-only">{{ t('comments.title') }}</h2>
|
||||||
<div
|
<div
|
||||||
class="comments | flow"
|
class="comments | flow"
|
||||||
:class="{ empty: !comments || comments.length === 0 }"
|
:class="{ empty: !comments || comments.length === 0 }"
|
||||||
|
:data-empty-message="t('comments.emptyMessage')"
|
||||||
>
|
>
|
||||||
<template v-if="comments">
|
<template v-if="comments">
|
||||||
<template v-if="!openedComment">
|
<template v-if="!openedComment">
|
||||||
|
|
@ -26,7 +27,7 @@
|
||||||
isAddOpen = false;
|
isAddOpen = false;
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<span>Retour à la liste</span>
|
<span>{{ t('buttons.backToList') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<Comment
|
<Comment
|
||||||
:comment="openedComment"
|
:comment="openedComment"
|
||||||
|
|
@ -53,7 +54,7 @@
|
||||||
class="btn btn--white-20 | w-full"
|
class="btn btn--white-20 | w-full"
|
||||||
@click="toggleCommentPositionMode(true)"
|
@click="toggleCommentPositionMode(true)"
|
||||||
>
|
>
|
||||||
Ajouter un commentaire
|
{{ t('buttons.addComment') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-else-if="openedComment && !isAddOpen"
|
v-else-if="openedComment && !isAddOpen"
|
||||||
|
|
@ -61,7 +62,7 @@
|
||||||
class="btn btn--white-20 | justify-start w-full | text-white-50"
|
class="btn btn--white-20 | justify-start w-full | text-white-50"
|
||||||
@click="isAddOpen = true"
|
@click="isAddOpen = true"
|
||||||
>
|
>
|
||||||
Répondre…
|
{{ t('buttons.reply') }}
|
||||||
</button>
|
</button>
|
||||||
<!-- TODO: afficher #new-comment une fois le bouton Ajouter un commentaire cliqué -->
|
<!-- TODO: afficher #new-comment une fois le bouton Ajouter un commentaire cliqué -->
|
||||||
<div
|
<div
|
||||||
|
|
@ -70,11 +71,10 @@
|
||||||
class="bg-primary | text-sm text-white | rounded-lg | p-12"
|
class="bg-primary | text-sm text-white | rounded-lg | p-12"
|
||||||
>
|
>
|
||||||
<p class="flex justify-start | mb-12" data-icon="comment">
|
<p class="flex justify-start | mb-12" data-icon="comment">
|
||||||
<strong>Nouveau commentaire</strong>
|
<strong>{{ t('comments.new') }}</strong>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Dans la zone du contenu, cliquez où vous souhaitez positionner le
|
{{ t('comments.newInstruction') }}
|
||||||
commentaire
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
|
|
@ -84,13 +84,13 @@
|
||||||
class="flow | p-12 | rounded-xl"
|
class="flow | p-12 | rounded-xl"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
>
|
>
|
||||||
<label class="sr-only" for="comment">Votre commentaire</label>
|
<label class="sr-only" for="comment">{{ t('comments.your') }}</label>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="draftComment.text"
|
v-model="draftComment.text"
|
||||||
:disabled="isSubmitting ? true : undefined"
|
:disabled="isSubmitting ? true : undefined"
|
||||||
name="comment"
|
name="comment"
|
||||||
id="comment"
|
id="comment"
|
||||||
placeholder="Ajouter un commentaire…"
|
:placeholder="t('forms.commentPlaceholder')"
|
||||||
rows="5"
|
rows="5"
|
||||||
class="text-sm | rounded-lg bg-black p-12"
|
class="text-sm | rounded-lg bg-black p-12"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
@ -99,11 +99,11 @@
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn"
|
class="btn"
|
||||||
:class="{ submitting: isSubmitting }"
|
:class="{ submitting: isSubmitting }"
|
||||||
:value="isSubmitting ? 'En cours' : undefined"
|
:value="isSubmitting ? t('comments.inProgress') : undefined"
|
||||||
:disabled="isSubmitting ? true : undefined"
|
:disabled="isSubmitting ? true : undefined"
|
||||||
/>
|
/>
|
||||||
<button class="btn btn--white-10" @click="isAddOpen = false">
|
<button class="btn btn--white-10" @click="isAddOpen = false">
|
||||||
Annuler
|
{{ t('buttons.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -122,9 +122,11 @@ import { useDialogStore } from '../../stores/dialog';
|
||||||
import Comment from './Comment.vue';
|
import Comment from './Comment.vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
dayjs.locale('fr');
|
dayjs.locale('fr');
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const { user } = useUserStore();
|
const { user } = useUserStore();
|
||||||
const { page } = usePageStore();
|
const { page } = usePageStore();
|
||||||
const dialog = useDialogStore();
|
const dialog = useDialogStore();
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
<template>
|
<template>
|
||||||
<button id="dtl-btn" class="bg-black rounded-md p-4" :title="'Design to Light: ' + (hasAlternatives ? 'New' : grade)" data-icon="leaf" :data-grade="grade" :data-new="hasAlternatives ? true : undefined">
|
<button id="dtl-btn" class="bg-black rounded-md p-4" :title="t('dtl.grade', { grade: hasAlternatives ? t('menu.news') : grade })" data-icon="leaf" :data-grade="grade" :data-new="hasAlternatives ? true : undefined">
|
||||||
<span lang="en" class="sr-only">Design to Light</span>
|
<span lang="en" class="sr-only">{{ t('dtl.title') }}</span>
|
||||||
<span v-if="hasAlternatives" lang="en" class="new">New</span>
|
<span v-if="hasAlternatives" lang="en" class="new">{{ t('menu.news') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import { usePageStore } from "../../stores/page";
|
import { usePageStore } from "../../stores/page";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const { page } = storeToRefs(usePageStore());
|
const { page } = storeToRefs(usePageStore());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@
|
||||||
proposals.length === 1 && isDialogOpen
|
proposals.length === 1 && isDialogOpen
|
||||||
? activeProposal.title
|
? activeProposal.title
|
||||||
? activeProposal.title
|
? activeProposal.title
|
||||||
: 'Design to light'
|
: t('dtl.title')
|
||||||
: 'Design to light'
|
: t('dtl.title')
|
||||||
}}
|
}}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
class="btn btn--icon btn--transparent | ml-auto"
|
class="btn btn--icon btn--transparent | ml-auto"
|
||||||
data-icon="close"
|
data-icon="close"
|
||||||
>
|
>
|
||||||
<span class="sr-only">Fermer</span>
|
<span class="sr-only">{{ t('buttons.close') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<nav v-if="!isDialogOpen" class="tabs" role="tablist" tabindex="-1">
|
<nav v-if="!isDialogOpen" class="tabs" role="tablist" tabindex="-1">
|
||||||
|
|
@ -42,8 +42,8 @@
|
||||||
proposal.title
|
proposal.title
|
||||||
? proposal.title
|
? proposal.title
|
||||||
: index === 0
|
: index === 0
|
||||||
? 'Proposition initiale'
|
? t('dtl.initialProposal')
|
||||||
: 'Alternative ' + index
|
: t('dtl.alternative', { index })
|
||||||
}}
|
}}
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -67,12 +67,12 @@
|
||||||
/>
|
/>
|
||||||
</router-link>
|
</router-link>
|
||||||
<p>
|
<p>
|
||||||
Données basées sur la proposition <br />du {{ activeProposal.date }}
|
{{ t('dtl.proposalBasedOn') }} <br />du {{ activeProposal.date }}
|
||||||
<br />{{ activeProposal.stepLabel }}
|
<br />{{ stepLabel }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="note-globale" class="px-32 py-16 border-b flow">
|
<div id="note-globale" class="px-32 py-16 border-b flow">
|
||||||
<h3>Note globale</h3>
|
<h3>{{ t('dtl.globalScore') }}</h3>
|
||||||
<div class="flex" style="--column-gap: 1rem">
|
<div class="flex" style="--column-gap: 1rem">
|
||||||
<p :data-grade="activeProposal.grades.global.letter">
|
<p :data-grade="activeProposal.grades.global.letter">
|
||||||
<strong class="sr-only">{{
|
<strong class="sr-only">{{
|
||||||
|
|
@ -100,15 +100,15 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="positionnement" class="px-32 py-16 border-b flow">
|
<div id="positionnement" class="px-32 py-16 border-b flow">
|
||||||
<h3>Positionnement</h3>
|
<h3>{{ t('dtl.positioning') }}</h3>
|
||||||
<dl>
|
<dl>
|
||||||
<dt id="design">Design</dt>
|
<dt id="design">{{ t('dtl.design') }}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<span class="sr-only">{{
|
<span class="sr-only">{{
|
||||||
activeProposal.grades.position.complexity
|
activeProposal.grades.position.complexity
|
||||||
}}</span>
|
}}</span>
|
||||||
</dd>
|
</dd>
|
||||||
<dt id="poids">Poids</dt>
|
<dt id="poids">{{ t('dtl.weight') }}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<span class="sr-only">{{
|
<span class="sr-only">{{
|
||||||
activeProposal.grades.position.weight
|
activeProposal.grades.position.weight
|
||||||
|
|
@ -132,7 +132,7 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="indicateur" class="px-32 py-16 border-b flow">
|
<div id="indicateur" class="px-32 py-16 border-b flow">
|
||||||
<h3>Indicateur des composants impliqués</h3>
|
<h3>{{ t('dtl.indicators') }}</h3>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<template
|
<template
|
||||||
v-for="indicator in activeProposal.grades.indicators"
|
v-for="indicator in activeProposal.grades.indicators"
|
||||||
|
|
@ -170,8 +170,8 @@
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
page.hasOptimizationRequest
|
page.hasOptimizationRequest
|
||||||
? "Demande d'expertise en cours de traitement…"
|
? t('dtl.requestPending')
|
||||||
: 'Demander une expertise d’optimisation'
|
: t('dtl.requestOptimization')
|
||||||
}}
|
}}
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
@ -190,6 +190,7 @@ import { storeToRefs } from 'pinia';
|
||||||
import { ref, onBeforeUnmount, computed } from 'vue';
|
import { ref, onBeforeUnmount, computed } from 'vue';
|
||||||
import { useDialogStore } from '../../stores/dialog';
|
import { useDialogStore } from '../../stores/dialog';
|
||||||
import { usePageStore } from '../../stores/page';
|
import { usePageStore } from '../../stores/page';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
const { proposals } = defineProps({
|
const { proposals } = defineProps({
|
||||||
proposals: Array,
|
proposals: Array,
|
||||||
|
|
@ -197,6 +198,7 @@ const { proposals } = defineProps({
|
||||||
|
|
||||||
const { page } = storeToRefs(usePageStore());
|
const { page } = storeToRefs(usePageStore());
|
||||||
const { openedFile, activeTracks } = storeToRefs(useDialogStore());
|
const { openedFile, activeTracks } = storeToRefs(useDialogStore());
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const isDialogOpen = computed(() => {
|
const isDialogOpen = computed(() => {
|
||||||
if (openedFile.value) {
|
if (openedFile.value) {
|
||||||
|
|
@ -215,6 +217,17 @@ const emits = defineEmits(['close']);
|
||||||
const activeProposal =
|
const activeProposal =
|
||||||
proposals.length === 1 ? computed(() => proposals[0]) : ref(proposals[0]);
|
proposals.length === 1 ? computed(() => proposals[0]) : ref(proposals[0]);
|
||||||
|
|
||||||
|
const stepLabel = computed(() => {
|
||||||
|
const proposal = activeProposal.value || activeProposal;
|
||||||
|
const location = proposal.location;
|
||||||
|
|
||||||
|
if (location.step === 'virtualSample') {
|
||||||
|
return location.type === 'dynamic' ? t('dtl.dynamicTrack') : t('dtl.staticTrack');
|
||||||
|
}
|
||||||
|
|
||||||
|
return t('steps.' + location.step);
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener('keyup', closeOnEscape);
|
window.addEventListener('keyup', closeOnEscape);
|
||||||
window.addEventListener('click', close);
|
window.addEventListener('click', close);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,15 @@
|
||||||
modal
|
modal
|
||||||
:draggable="false"
|
:draggable="false"
|
||||||
:dismissableMask="true"
|
:dismissableMask="true"
|
||||||
header="Demander un rendez-vous"
|
:header="t('dialogs.requestMeeting')"
|
||||||
class="dialog"
|
class="dialog"
|
||||||
:closeOnEscape="true"
|
:closeOnEscape="true"
|
||||||
@click="preventClose($event)"
|
@click="preventClose($event)"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<h2 class="font-serif text-lg">Demander un rendez-vous</h2>
|
<h2 class="font-serif text-lg">{{ t('dialogs.requestMeeting') }}</h2>
|
||||||
<p class="flex justify-center text-sm" data-icon="leaf">
|
<p class="flex justify-center text-sm" data-icon="leaf">
|
||||||
Design to Light
|
{{ t('dtl.title') }}
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
class="w-full h-full p-16 flex flex-col"
|
class="w-full h-full p-16 flex flex-col"
|
||||||
style="--row-gap: 1rem"
|
style="--row-gap: 1rem"
|
||||||
>
|
>
|
||||||
<label for="projects" class="sr-only">Projet</label>
|
<label for="projects" class="sr-only">{{ t('brief.projects') }}</label>
|
||||||
<select
|
<select
|
||||||
name="projects"
|
name="projects"
|
||||||
id="projects"
|
id="projects"
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
class="w-full rounded-md border border-grey-200 px-16 py-12"
|
class="w-full rounded-md border border-grey-200 px-16 py-12"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">Sélectionnez le projet</option>
|
<option value="">{{ t('forms.selectVariation') }}</option>
|
||||||
<option
|
<option
|
||||||
v-for="project in currentProjects"
|
v-for="project in currentProjects"
|
||||||
:key="project.uri"
|
:key="project.uri"
|
||||||
|
|
@ -41,35 +41,37 @@
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<label for="appointment-subject" class="sr-only"
|
<label for="appointment-subject" class="sr-only">{{
|
||||||
>Objet du rendez-vous</label
|
t('forms.meetingSubject')
|
||||||
>
|
}}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
v-model="subject"
|
v-model="subject"
|
||||||
id="appointment-subject"
|
id="appointment-subject"
|
||||||
placeholder="Objet du rendez-vous"
|
:placeholder="t('forms.meetingSubject')"
|
||||||
class="w-full rounded-md border border-grey-200 px-16 py-12"
|
class="w-full rounded-md border border-grey-200 px-16 py-12"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label for="appointment-details" class="sr-only">Détails du projet</label>
|
<label for="appointment-details" class="sr-only">{{
|
||||||
|
t('forms.meetingDetails')
|
||||||
|
}}</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="appointment-details"
|
id="appointment-details"
|
||||||
name="details"
|
name="details"
|
||||||
v-model="details"
|
v-model="details"
|
||||||
cols="30"
|
cols="30"
|
||||||
rows="10"
|
rows="10"
|
||||||
placeholder="Décrivez votre demande…"
|
:placeholder="t('forms.meetingDetailsPlaceholder')"
|
||||||
class="w-full flex-1 rounded-md border border-grey-200 px-16 py-12"
|
class="w-full flex-1 rounded-md border border-grey-200 px-16 py-12"
|
||||||
required
|
required
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
<footer class="flex-columns w-full mt-16" style="column-gap: 0.5rem">
|
<footer class="flex-columns w-full mt-16" style="column-gap: 0.5rem">
|
||||||
<button class="btn btn--black-10" @click="emits('close')">
|
<button class="btn btn--black-10" @click="emits('close')">
|
||||||
Annuler
|
{{ t('buttons.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn" type="submit">Soumettre</button>
|
<button class="btn" type="submit">{{ t('buttons.submit') }}</button>
|
||||||
</footer>
|
</footer>
|
||||||
</form>
|
</form>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
@ -82,6 +84,9 @@ import { storeToRefs } from 'pinia';
|
||||||
import { useProjectsStore } from '../../stores/projects';
|
import { useProjectsStore } from '../../stores/projects';
|
||||||
import { usePageStore } from '../../stores/page';
|
import { usePageStore } from '../../stores/page';
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const { page } = storeToRefs(usePageStore());
|
const { page } = storeToRefs(usePageStore());
|
||||||
const { currentProjects } = storeToRefs(useProjectsStore());
|
const { currentProjects } = storeToRefs(useProjectsStore());
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<!-- Favorite button -->
|
<!-- Favorite button -->
|
||||||
<button
|
<button
|
||||||
class="favorite"
|
class="favorite"
|
||||||
:aria-label="isFavorite ? 'Retirer des favoris' : 'Ajouter aux favoris'"
|
:aria-label="isFavorite ? t('inspirations.removeFromFavorites') : t('inspirations.addToFavorites')"
|
||||||
:aria-pressed="isFavorite"
|
:aria-pressed="isFavorite"
|
||||||
@click="toggleFavorite"
|
@click="toggleFavorite"
|
||||||
>
|
>
|
||||||
|
|
@ -34,6 +34,9 @@
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { useUserStore } from "../../stores/user";
|
import { useUserStore } from "../../stores/user";
|
||||||
import { useApiStore } from "../../stores/api";
|
import { useApiStore } from "../../stores/api";
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
const { item, inspirationUri } = defineProps({
|
const { item, inspirationUri } = defineProps({
|
||||||
|
|
@ -70,7 +73,7 @@ async function toggleFavorite() {
|
||||||
// Update item favorite users list based on API response
|
// Update item favorite users list based on API response
|
||||||
item.favoriteForUsers = newFavoriteUsers;
|
item.favoriteForUsers = newFavoriteUsers;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to toggle favorite:", error);
|
console.error(t('errors.toggleFavoriteFailed'), error);
|
||||||
isFavorite.value = previousState; // Rollback on failure
|
isFavorite.value = previousState; // Rollback on failure
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,12 @@
|
||||||
<strong
|
<strong
|
||||||
class="notification__type | font-medium text-primary"
|
class="notification__type | font-medium text-primary"
|
||||||
data-icon="calendar"
|
data-icon="calendar"
|
||||||
>Demande de rendez-vous</strong
|
>{{ t('notifications.meetingRequest') }}</strong
|
||||||
>
|
>
|
||||||
<span class="notification__client | text-grey-700"
|
<span class="notification__client | text-grey-700"
|
||||||
>{{ notification.project.title }}
|
>{{ notification.project.title }}
|
||||||
{{
|
{{
|
||||||
notification.project.status === "draft" ? "(brouillon)" : ""
|
notification.project.status === "draft" ? t('notifications.draft') : ""
|
||||||
}}</span
|
}}</span
|
||||||
>
|
>
|
||||||
<time
|
<time
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
v-if="notification.type"
|
v-if="notification.type"
|
||||||
class="notification__body | text-md font-medium | line-clamp"
|
class="notification__body | text-md font-medium | line-clamp"
|
||||||
v-html="
|
v-html="
|
||||||
'Auteur : ' +
|
t('notifications.author') + ' ' +
|
||||||
(notification.author.name
|
(notification.author.name
|
||||||
? notification.author.name + ' (' + notification.author.email + ')'
|
? notification.author.name + ' (' + notification.author.email + ')'
|
||||||
: notification.author.email) +
|
: notification.author.email) +
|
||||||
|
|
@ -44,6 +44,9 @@
|
||||||
import { useNotificationsStore } from "../../stores/notifications";
|
import { useNotificationsStore } from "../../stores/notifications";
|
||||||
import { useUserStore } from "../../stores/user";
|
import { useUserStore } from "../../stores/user";
|
||||||
import { useApiStore } from "../../stores/api";
|
import { useApiStore } from "../../stores/api";
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const { notification } = defineProps({ notification: Object });
|
const { notification } = defineProps({ notification: Object });
|
||||||
const { formatDate } = useNotificationsStore();
|
const { formatDate } = useNotificationsStore();
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,14 @@
|
||||||
class="notification | bg-white rounded-lg | p-16 | flow"
|
class="notification | bg-white rounded-lg | p-16 | flow"
|
||||||
data-type="content"
|
data-type="content"
|
||||||
@click="readNotification()"
|
@click="readNotification()"
|
||||||
title="Aller au contenu"
|
:title="t('notifications.goToContent')"
|
||||||
>
|
>
|
||||||
<header>
|
<header>
|
||||||
<p class="flex">
|
<p class="flex">
|
||||||
<strong
|
<strong
|
||||||
class="notification__type | font-medium text-primary"
|
class="notification__type | font-medium text-primary"
|
||||||
data-icon="content"
|
data-icon="content"
|
||||||
>Contenu</strong
|
>{{ t('notifications.content') }}</strong
|
||||||
>
|
>
|
||||||
<span class="notification__client | text-grey-700">{{
|
<span class="notification__client | text-grey-700">{{
|
||||||
notification.project.title
|
notification.project.title
|
||||||
|
|
@ -36,6 +36,9 @@ import { useRouter } from "vue-router";
|
||||||
import { useNotificationsStore } from "../../stores/notifications";
|
import { useNotificationsStore } from "../../stores/notifications";
|
||||||
import { useUserStore } from "../../stores/user";
|
import { useUserStore } from "../../stores/user";
|
||||||
import { useApiStore } from "../../stores/api";
|
import { useApiStore } from "../../stores/api";
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { notification } = defineProps({ notification: Object });
|
const { notification } = defineProps({ notification: Object });
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,12 @@
|
||||||
<strong
|
<strong
|
||||||
class="notification__type | font-medium text-primary"
|
class="notification__type | font-medium text-primary"
|
||||||
data-icon="document"
|
data-icon="document"
|
||||||
>Demande de création de projet</strong
|
>{{ t('notifications.projectRequest') }}</strong
|
||||||
>
|
>
|
||||||
<span class="notification__client | text-grey-700"
|
<span class="notification__client | text-grey-700"
|
||||||
>{{ notification.project.title }}
|
>{{ notification.project.title }}
|
||||||
{{
|
{{
|
||||||
notification.project.status === "draft" ? "(brouillon)" : ""
|
notification.project.status === "draft" ? t('notifications.draft') : ""
|
||||||
}}</span
|
}}</span
|
||||||
>
|
>
|
||||||
<time
|
<time
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
v-if="notification.type"
|
v-if="notification.type"
|
||||||
class="notification__body | text-md font-medium | line-clamp"
|
class="notification__body | text-md font-medium | line-clamp"
|
||||||
v-html="
|
v-html="
|
||||||
'De la part de ' +
|
t('notifications.from') + ' ' +
|
||||||
(notification.author.name
|
(notification.author.name
|
||||||
? notification.author.name + ' (' + notification.author.email + ')'
|
? notification.author.name + ' (' + notification.author.email + ')'
|
||||||
: notification.author.email) +
|
: notification.author.email) +
|
||||||
|
|
@ -44,6 +44,9 @@
|
||||||
import { useNotificationsStore } from "../../stores/notifications";
|
import { useNotificationsStore } from "../../stores/notifications";
|
||||||
import { useUserStore } from "../../stores/user";
|
import { useUserStore } from "../../stores/user";
|
||||||
import { useApiStore } from "../../stores/api";
|
import { useApiStore } from "../../stores/api";
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const { notification } = defineProps({ notification: Object });
|
const { notification } = defineProps({ notification: Object });
|
||||||
const { formatDate } = useNotificationsStore();
|
const { formatDate } = useNotificationsStore();
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,9 @@
|
||||||
data-icon="comment"
|
data-icon="comment"
|
||||||
@click="isCommentsOpen = !isCommentsOpen"
|
@click="isCommentsOpen = !isCommentsOpen"
|
||||||
>
|
>
|
||||||
<span class="sr-only"
|
<span class="sr-only">{{
|
||||||
>{{ isCommentsOpen ? 'Masquer' : 'Afficher' }} les commentaires</span
|
isCommentsOpen ? t('buttons.hideComments') : t('buttons.showComments')
|
||||||
>
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
id="download-pdf"
|
id="download-pdf"
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
:href="openedFile.url"
|
:href="openedFile.url"
|
||||||
download
|
download
|
||||||
>
|
>
|
||||||
<span class="sr-only">Télécharger le fichier PDF</span>
|
<span class="sr-only">{{ t('buttons.downloadPdf') }}</span>
|
||||||
</a>
|
</a>
|
||||||
<Comments v-if="isCommentsOpen" />
|
<Comments v-if="isCommentsOpen" />
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -36,6 +36,9 @@ import { ref, watch, computed, unref } from 'vue';
|
||||||
import { useDialogStore } from '../../stores/dialog';
|
import { useDialogStore } from '../../stores/dialog';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { VPdfViewer, useLicense } from '@vue-pdf-viewer/viewer';
|
import { VPdfViewer, useLicense } from '@vue-pdf-viewer/viewer';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const licenseKey =
|
const licenseKey =
|
||||||
import.meta.env.VITE_VPV_LICENSE ??
|
import.meta.env.VITE_VPV_LICENSE ??
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,13 @@
|
||||||
</router-link>
|
</router-link>
|
||||||
</h3>
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
Dernière mise à jour le
|
{{ t('dates.updatedOn') }}
|
||||||
<time :datetime="project.modified">{{ frenchFormattedModified }}</time>
|
<time :datetime="project.modified">{{ formattedModified }}</time>
|
||||||
</p>
|
</p>
|
||||||
</hgroup>
|
</hgroup>
|
||||||
<img :src="project.logo" alt="Logo" class="project-logo | rounded-sm" />
|
<img :src="project.logo" alt="Logo" class="project-logo | rounded-sm" />
|
||||||
<ol
|
<ol
|
||||||
|
v-if="Array.isArray(project.steps)"
|
||||||
class="project-steps"
|
class="project-steps"
|
||||||
:data-steps="project.steps.length"
|
:data-steps="project.steps.length"
|
||||||
:style="'--steps:' + project.steps.length"
|
:style="'--steps:' + project.steps.length"
|
||||||
|
|
@ -31,7 +32,7 @@
|
||||||
:data-status="setStatus(project.steps, project.currentStep, step)"
|
:data-status="setStatus(project.steps, project.currentStep, step)"
|
||||||
>
|
>
|
||||||
<span class="pill" :data-icon="step.id">
|
<span class="pill" :data-icon="step.id">
|
||||||
<span>{{ step.label }}</span>
|
<span>{{ t('steps.' + step.id) }}</span>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
@ -41,15 +42,17 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import 'dayjs/locale/fr';
|
import 'dayjs/locale/fr';
|
||||||
|
import 'dayjs/locale/en';
|
||||||
|
import { computed } from 'vue';
|
||||||
import { useProjectStore } from '../../stores/project';
|
import { useProjectStore } from '../../stores/project';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
dayjs.locale('fr');
|
|
||||||
|
|
||||||
const { project } = defineProps({ project: Object });
|
const { project } = defineProps({ project: Object });
|
||||||
|
|
||||||
const frenchFormattedModified = dayjs(project.modified).format(
|
|
||||||
'dddd D MMMM YYYY'
|
|
||||||
);
|
|
||||||
|
|
||||||
const { stepsLabels, setStatus, isEmptyBrief } = useProjectStore();
|
const { stepsLabels, setStatus, isEmptyBrief } = useProjectStore();
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
|
const formattedModified = computed(() =>
|
||||||
|
dayjs(project.modified).locale(locale.value).format('dddd D MMMM YYYY')
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
:data-status="setStatus(page.steps, page.content.currentstep, step)"
|
:data-status="setStatus(page.steps, page.content.currentstep, step)"
|
||||||
>
|
>
|
||||||
<h2 :id="step.id">
|
<h2 :id="step.id">
|
||||||
<span :data-icon="step.id">{{ step.label }}</span>
|
<span :data-icon="step.id">{{ t('steps.' + step.id) }}</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div
|
<div
|
||||||
ref="cards-node"
|
ref="cards-node"
|
||||||
|
|
@ -23,21 +23,22 @@ import 'dayjs/locale/fr';
|
||||||
import { usePageStore } from '../../stores/page';
|
import { usePageStore } from '../../stores/page';
|
||||||
import { computed, onMounted, useTemplateRef } from 'vue';
|
import { computed, onMounted, useTemplateRef } from 'vue';
|
||||||
import { useProjectStore } from '../../stores/project';
|
import { useProjectStore } from '../../stores/project';
|
||||||
import ClientBrief from './cards/ClientBrief.vue';
|
import Brief from './cards/Brief.vue';
|
||||||
import MultipleDocuments from './cards/MultipleDocuments.vue';
|
import MultipleDocuments from './cards/MultipleDocuments.vue';
|
||||||
import SimpleDocument from './cards/SimpleDocument.vue';
|
import SimpleDocument from './cards/SimpleDocument.vue';
|
||||||
import VirtualSample from './cards/VirtualSample.vue';
|
import VirtualSample from './cards/VirtualSample.vue';
|
||||||
import PhysicalSample from './cards/PhysicalSample.vue';
|
import PhysicalSample from './cards/PhysicalSample.vue';
|
||||||
import { useUserStore } from '../../stores/user';
|
import { useUserStore } from '../../stores/user';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
const { step } = defineProps({
|
const { step } = defineProps({
|
||||||
step: Object,
|
step: Object,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cardsMap = {
|
const cardsMap = {
|
||||||
clientBrief: ClientBrief,
|
clientBrief: Brief,
|
||||||
proposal: MultipleDocuments,
|
proposal: MultipleDocuments,
|
||||||
extendedBrief: SimpleDocument,
|
extendedBrief: Brief,
|
||||||
industrialIdeation: SimpleDocument,
|
industrialIdeation: SimpleDocument,
|
||||||
virtualSample: VirtualSample,
|
virtualSample: VirtualSample,
|
||||||
physicalSample: PhysicalSample,
|
physicalSample: PhysicalSample,
|
||||||
|
|
@ -49,6 +50,7 @@ const { page } = usePageStore();
|
||||||
const { setStatus } = useProjectStore();
|
const { setStatus } = useProjectStore();
|
||||||
const cardsNode = useTemplateRef('cards-node');
|
const cardsNode = useTemplateRef('cards-node');
|
||||||
const { user } = useUserStore();
|
const { user } = useUserStore();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
modal
|
modal
|
||||||
:draggable="false"
|
:draggable="false"
|
||||||
:dismissableMask="true"
|
:dismissableMask="true"
|
||||||
header="Titre du PDF"
|
:header="t('dialogs.pdfTitle')"
|
||||||
class="dialog"
|
class="dialog"
|
||||||
:class="[
|
:class="[
|
||||||
{ 'with-comments': isCommentsOpen },
|
{ 'with-comments': isCommentsOpen },
|
||||||
|
|
@ -16,13 +16,13 @@
|
||||||
<template #header>
|
<template #header>
|
||||||
<button
|
<button
|
||||||
v-if="
|
v-if="
|
||||||
dialog.content.id === 'clientBrief' &&
|
['clientBrief', 'extendedBrief'].includes(dialog.content.id) &&
|
||||||
dialog.content.isValidated !== true
|
dialog.content.isValidated !== true
|
||||||
"
|
"
|
||||||
class="btn"
|
class="btn"
|
||||||
@click="validate()"
|
@click="validate()"
|
||||||
>
|
>
|
||||||
Valider et envoyer le brief
|
{{ t('buttons.validate') }}
|
||||||
</button>
|
</button>
|
||||||
<h2
|
<h2
|
||||||
v-if="openedFile"
|
v-if="openedFile"
|
||||||
|
|
@ -52,7 +52,9 @@ import { useRoute, useRouter } from 'vue-router';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useApiStore } from '../../stores/api';
|
import { useApiStore } from '../../stores/api';
|
||||||
import { usePageStore } from '../../stores/page';
|
import { usePageStore } from '../../stores/page';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const { openedFile, isCommentsOpen } = storeToRefs(useDialogStore());
|
const { openedFile, isCommentsOpen } = storeToRefs(useDialogStore());
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -91,7 +93,7 @@ const correspondingDTLProposal = computed(() => {
|
||||||
// Functions
|
// Functions
|
||||||
async function validate() {
|
async function validate() {
|
||||||
const response = await api.validateBrief(
|
const response = await api.validateBrief(
|
||||||
route.path + '/client-brief',
|
route.path + '/' + dialog.content.slug,
|
||||||
route.fullPath
|
route.fullPath
|
||||||
);
|
);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@
|
||||||
<label
|
<label
|
||||||
for="project-description"
|
for="project-description"
|
||||||
class="flex | text-sm text-grey-700 | mb-8"
|
class="flex | text-sm text-grey-700 | mb-8"
|
||||||
>Description du projet</label
|
>{{ t('forms.description') }}</label
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
name="project-description"
|
name="project-description"
|
||||||
id="project-description"
|
id="project-description"
|
||||||
placeholder="Ajoutez une description générale de votre projet…"
|
:placeholder="t('forms.descriptionPlaceholder')"
|
||||||
rows="2"
|
rows="2"
|
||||||
class="border border-grey-200 | rounded-xl | p-16 | w-full"
|
class="border border-grey-200 | rounded-xl | p-16 | w-full"
|
||||||
v-model="page.content.description"
|
v-model="page.content.description"
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
<fieldset class="project-details__filters | flex-1">
|
<fieldset class="project-details__filters | flex-1">
|
||||||
<legend class="text-sm text-grey-700 | mb-8">Filtrer par tags</legend>
|
<legend class="text-sm text-grey-700 | mb-8">{{ t('forms.filterByTags') }}</legend>
|
||||||
<div class="flex" style="gap: var(--space-8)">
|
<div class="flex" style="gap: var(--space-8)">
|
||||||
<button
|
<button
|
||||||
class="btn btn--sm btn--grey"
|
class="btn btn--sm btn--grey"
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
role="switch"
|
role="switch"
|
||||||
@click="clearTags()"
|
@click="clearTags()"
|
||||||
>
|
>
|
||||||
Voir tout
|
{{ t('buttons.seeAll') }}
|
||||||
</button>
|
</button>
|
||||||
<template v-for="tag in page.tags" :key="tag">
|
<template v-for="tag in page.tags" :key="tag">
|
||||||
<input
|
<input
|
||||||
|
|
@ -53,6 +53,9 @@ import { ref, watch } from "vue";
|
||||||
import { usePageStore } from "../../../stores/page";
|
import { usePageStore } from "../../../stores/page";
|
||||||
import StringUtils from "../../../utils/string";
|
import StringUtils from "../../../utils/string";
|
||||||
import debounce from "lodash/debounce";
|
import debounce from "lodash/debounce";
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const { selectedTags } = defineProps({
|
const { selectedTags } = defineProps({
|
||||||
selectedTags: Array,
|
selectedTags: Array,
|
||||||
|
|
@ -93,7 +96,7 @@ const saveDescription = debounce(() => {
|
||||||
console.log(json);
|
console.log(json);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Erreur lors de la sauvegarde :", error);
|
console.error(t('errors.saveFailed'), error);
|
||||||
isWaitingForSave.value = false;
|
isWaitingForSave.value = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
v-model:visible="isOpen"
|
v-model:visible="isOpen"
|
||||||
id="image-details"
|
id="image-details"
|
||||||
modal
|
modal
|
||||||
header="Détails de l’image"
|
:header="t('dialogs.imageDetails')"
|
||||||
class="bg-white | text-grey-800 | rounded-2xl | overflow-hidden"
|
class="bg-white | text-grey-800 | rounded-2xl | overflow-hidden"
|
||||||
>
|
>
|
||||||
<picture :style="'--image: url('+image.url+')'">
|
<picture :style="'--image: url('+image.url+')'">
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
</picture>
|
</picture>
|
||||||
<div class="flex flex-col | p-32" style="--row-gap: var(--space-32)">
|
<div class="flex flex-col | p-32" style="--row-gap: var(--space-32)">
|
||||||
<fieldset class="image__tags">
|
<fieldset class="image__tags">
|
||||||
<legend class="text-sm text-grey-700 | mb-8">Tags</legend>
|
<legend class="text-sm text-grey-700 | mb-8">{{ t('forms.tags') }}</legend>
|
||||||
<div class="flex" style="gap: var(--space-8)">
|
<div class="flex" style="gap: var(--space-8)">
|
||||||
<template v-for="(pageTag, index) in page.tags" :key="index">
|
<template v-for="(pageTag, index) in page.tags" :key="index">
|
||||||
<input
|
<input
|
||||||
|
|
@ -33,12 +33,12 @@
|
||||||
<label
|
<label
|
||||||
for="image-description"
|
for="image-description"
|
||||||
class="flex | text-sm text-grey-700 | mb-8"
|
class="flex | text-sm text-grey-700 | mb-8"
|
||||||
>Description de l’image</label
|
>{{ t('forms.imageDescription') }}</label
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
name="image-description"
|
name="image-description"
|
||||||
id="image-description"
|
id="image-description"
|
||||||
placeholder="Ajoutez une description à l’image…"
|
:placeholder="t('forms.imageDescriptionPlaceholder')"
|
||||||
class="border border-grey-200 | rounded-xl | p-16 | w-full"
|
class="border border-grey-200 | rounded-xl | p-16 | w-full"
|
||||||
v-model="image.description"
|
v-model="image.description"
|
||||||
@input="saveDescription()"
|
@input="saveDescription()"
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
class="btn btn--black-10 | ml-auto mt-auto"
|
class="btn btn--black-10 | ml-auto mt-auto"
|
||||||
@click="remove()"
|
@click="remove()"
|
||||||
>
|
>
|
||||||
Supprimer cette image
|
{{ t('buttons.deleteImage') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
@ -61,6 +61,9 @@ import { usePageStore } from "../../../stores/page";
|
||||||
import StringUtils from "../../../utils/string";
|
import StringUtils from "../../../utils/string";
|
||||||
import Dialog from "primevue/dialog";
|
import Dialog from "primevue/dialog";
|
||||||
import debounce from "lodash/debounce";
|
import debounce from "lodash/debounce";
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const { imageDetails } = defineProps({
|
const { imageDetails } = defineProps({
|
||||||
imageDetails: Object,
|
imageDetails: Object,
|
||||||
|
|
@ -97,7 +100,7 @@ function saveTags() {
|
||||||
console.log(json);
|
console.log(json);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Erreur lors de la sauvegarde :", error);
|
console.error(t('errors.saveFailed'), error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,7 +125,7 @@ const saveDescription = debounce(() => {
|
||||||
emit("");
|
emit("");
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Erreur lors de la sauvegarde :", error);
|
console.error(t('errors.saveFailed'), error);
|
||||||
});
|
});
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
|
|
@ -142,7 +145,7 @@ function remove() {
|
||||||
isOpen.value = false;
|
isOpen.value = false;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Erreur lors de la suppression :", error);
|
console.error(t('errors.deleteFailed'), error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
class="flex flex-col | bg-white | border border-grey-200 | text-grey-800 | font-medium | rounded-2xl"
|
class="flex flex-col | bg-white | border border-grey-200 | text-grey-800 | font-medium | rounded-2xl"
|
||||||
@click="isAddImagesModalOpen = true"
|
@click="isAddImagesModalOpen = true"
|
||||||
>
|
>
|
||||||
Ajouter une ou plusieurs images
|
{{ t('forms.addImages') }}
|
||||||
</button>
|
</button>
|
||||||
<template v-for="image in page.moodboard" :key="image.uri">
|
<template v-for="image in page.moodboard" :key="image.uri">
|
||||||
<figure
|
<figure
|
||||||
|
|
@ -52,11 +52,13 @@
|
||||||
import Header from "./Header.vue";
|
import Header from "./Header.vue";
|
||||||
import { usePageStore } from "../../../stores/page";
|
import { usePageStore } from "../../../stores/page";
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
import ImageDetailsModal from "./ImageDetailsModal.vue";
|
import ImageDetailsModal from "./ImageDetailsModal.vue";
|
||||||
import AddImagesModal from "./add-images-modal/AddImagesModal.vue";
|
import AddImagesModal from "./add-images-modal/AddImagesModal.vue";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
|
|
||||||
const { page } = storeToRefs(usePageStore());
|
const { page } = storeToRefs(usePageStore());
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const selectedTags = ref([]);
|
const selectedTags = ref([]);
|
||||||
const imageDetails = ref(null);
|
const imageDetails = ref(null);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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
|
|
||||||
l’importer.
|
|
||||||
</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">Qu’est 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>
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
:draggable="false"
|
:draggable="false"
|
||||||
:dismissableMask="true"
|
:dismissableMask="true"
|
||||||
modal
|
modal
|
||||||
header="Ajouter des images"
|
:header="t('dialogs.addImages')"
|
||||||
class="bg-white | text-grey-800 | rounded-2xl | overflow-hidden | p-32"
|
class="bg-white | text-grey-800 | rounded-2xl | overflow-hidden | p-32"
|
||||||
>
|
>
|
||||||
<div class="with-sidebar | h-full">
|
<div class="with-sidebar | h-full">
|
||||||
|
|
@ -56,19 +56,18 @@
|
||||||
id="delete-image"
|
id="delete-image"
|
||||||
v-model:visible="deleteIsOpen"
|
v-model:visible="deleteIsOpen"
|
||||||
modal
|
modal
|
||||||
header="Êtes-vous sûr de vouloir supprimer cette image ?"
|
:header="t('dialogs.deleteConfirm')"
|
||||||
class="bg-white | text-grey-800 | rounded-2xl | overflow-hidden | text-center | w-full max-w | p-16 pt-32"
|
class="bg-white | text-grey-800 | rounded-2xl | overflow-hidden | text-center | w-full max-w | p-16 pt-32"
|
||||||
style="--row-gap: var(--space-32); --max-w: 40rem"
|
style="--row-gap: var(--space-32); --max-w: 40rem"
|
||||||
>
|
>
|
||||||
<p class="text-grey-700 | px-16">
|
<p class="text-grey-700 | px-16">
|
||||||
Si vous supprimez cette image, celle-ci disparaîtra de votre brief ainsi
|
{{ t('dialogs.deleteWarning') }}
|
||||||
que toutes les informations qui lui sont attribuées.
|
|
||||||
</p>
|
</p>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<button class="btn btn--secondary | flex-1" @click="deleteIsOpen = false">
|
<button class="btn btn--secondary | flex-1" @click="deleteIsOpen = false">
|
||||||
Annuler
|
{{ t('buttons.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn | flex-1" @click="">Supprimer</button>
|
<button class="btn | flex-1" @click="">{{ t('buttons.delete') }}</button>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -79,6 +78,9 @@ import ImagesEditPanel from './ImagesEditPanel.vue';
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useAddImagesModalStore } from '../../../../stores/addImagesModal';
|
import { useAddImagesModalStore } from '../../../../stores/addImagesModal';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const { isAddImagesModalOpen } = defineProps({
|
const { isAddImagesModalOpen } = defineProps({
|
||||||
isAddImagesModalOpen: Boolean,
|
isAddImagesModalOpen: Boolean,
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
<textarea
|
<textarea
|
||||||
name="image-description"
|
name="image-description"
|
||||||
id="image-description"
|
id="image-description"
|
||||||
placeholder="Ajoutez une description à cette image…"
|
:placeholder="t('forms.imageDescriptionPlaceholder')"
|
||||||
rows="3"
|
rows="3"
|
||||||
class="border border-grey-200 | rounded-xl | p-16 | w-full"
|
class="border border-grey-200 | rounded-xl | p-16 | w-full"
|
||||||
v-model="image.description"
|
v-model="image.description"
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
</div>
|
</div>
|
||||||
<fieldset class="image-details__filters | flex-1">
|
<fieldset class="image-details__filters | flex-1">
|
||||||
<legend class="text-sm text-grey-700 | mb-8">
|
<legend class="text-sm text-grey-700 | mb-8">
|
||||||
Sélectionner un ou plusieurs tags
|
{{ t('forms.selectTags') }}
|
||||||
</legend>
|
</legend>
|
||||||
<div class="flex" style="gap: var(--space-8)">
|
<div class="flex" style="gap: var(--space-8)">
|
||||||
<template v-for="tag in page.tags" :key="tag">
|
<template v-for="tag in page.tags" :key="tag">
|
||||||
|
|
@ -95,7 +95,7 @@
|
||||||
</template>
|
</template>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
<button class="btn | w-full | mt-auto" @click="addImagesToBrief()">
|
<button class="btn | w-full | mt-auto" @click="addImagesToBrief()">
|
||||||
Ajouter les images sélectionnées
|
{{ t('buttons.addSelectedImages') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -109,8 +109,10 @@ import StringUtils from "../../../../utils/string";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import { useAddImagesModalStore } from "../../../../stores/addImagesModal";
|
import { useAddImagesModalStore } from "../../../../stores/addImagesModal";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
const { page } = storeToRefs(usePageStore());
|
const { page } = storeToRefs(usePageStore());
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const { activeTab } = storeToRefs(useAddImagesModalStore());
|
const { activeTab } = storeToRefs(useAddImagesModalStore());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@
|
||||||
:multiple="true"
|
:multiple="true"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
:maxFileSize="1000000"
|
:maxFileSize="1000000"
|
||||||
invalidFileSizeMessage="Fichier trop lourd"
|
:invalidFileSizeMessage="t('errors.saveFailed')"
|
||||||
chooseLabel="Ajouter une ou plusieurs images"
|
:chooseLabel="t('forms.addImages')"
|
||||||
class="flex flex-col justify-center | bg-white | border border-grey-200 | text-grey-800 | font-medium | rounded-xl"
|
class="flex flex-col justify-center | bg-white | border border-grey-200 | text-grey-800 | font-medium | rounded-xl"
|
||||||
ref="uploadBtn"
|
ref="uploadBtn"
|
||||||
>
|
>
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
removeFileCallback,
|
removeFileCallback,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div v-if="files.length > 0">Fichiers importés</div>
|
<div v-if="files.length > 0">{{ t('forms.uploadedFiles') }}</div>
|
||||||
</template>
|
</template>
|
||||||
</FileUpload>
|
</FileUpload>
|
||||||
<figure
|
<figure
|
||||||
|
|
@ -71,6 +71,9 @@ import { storeToRefs } from "pinia";
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { useAddImagesModalStore } from "../../../../stores/addImagesModal";
|
import { useAddImagesModalStore } from "../../../../stores/addImagesModal";
|
||||||
import ArrayUtils from "../../../../utils/array";
|
import ArrayUtils from "../../../../utils/array";
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const { page } = storeToRefs(usePageStore());
|
const { page } = storeToRefs(usePageStore());
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,20 @@
|
||||||
v-if="images.length > 0"
|
v-if="images.length > 0"
|
||||||
:step="step"
|
:step="step"
|
||||||
:images="images"
|
:images="images"
|
||||||
:uri="'/' + step.uri"
|
:uri="addLocalePrefix(step.uri)"
|
||||||
/>
|
/>
|
||||||
<Document v-if="pdf" :step="step" :pdf="pdf" />
|
<Document v-if="pdf" :step="step" :pdf="pdf" />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="images.length === 0"
|
v-if="images.length === 0 && step.id === 'clientBrief'"
|
||||||
class="btn | w-full"
|
class="btn | w-full"
|
||||||
@click="goToImagesBrief()"
|
@click="goToImagesBrief()"
|
||||||
>
|
>
|
||||||
Ajouter un brief via la plateforme
|
{{ t('brief.addPlatform') }}
|
||||||
</button>
|
</button>
|
||||||
<div class="btn | w-full" v-if="!pdf">
|
<div class="btn | w-full" v-if="!pdf && step.id === 'clientBrief'">
|
||||||
<label for="upload-pdf">
|
<label for="upload-pdf">
|
||||||
Ajouter un brief PDF
|
{{ t('brief.addPdf') }}
|
||||||
<input
|
<input
|
||||||
id="upload-pdf"
|
id="upload-pdf"
|
||||||
type="file"
|
type="file"
|
||||||
|
|
@ -35,10 +35,13 @@ import Images from "./Images.vue";
|
||||||
import Document from "./Document.vue";
|
import Document from "./Document.vue";
|
||||||
import { useBriefStore } from "../../../stores/brief";
|
import { useBriefStore } from "../../../stores/brief";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { addLocalePrefix } from "../../../utils/router";
|
||||||
|
|
||||||
const { step } = defineProps({ step: Object });
|
const { step } = defineProps({ step: Object });
|
||||||
const { addPdf } = useBriefStore();
|
const { addPdf } = useBriefStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const images = computed(() => {
|
const images = computed(() => {
|
||||||
return step.files.filter((file) => file.type === "image");
|
return step.files.filter((file) => file.type === "image");
|
||||||
|
|
@ -49,6 +52,6 @@ const pdf = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
function goToImagesBrief() {
|
function goToImagesBrief() {
|
||||||
router.push(location.pathname + "/client-brief?step=images");
|
router.push(location.pathname + "/" + step.slug);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<time
|
<time
|
||||||
class="card__date | text-grey-700"
|
class="card__date | text-grey-700"
|
||||||
:datetime="dayjs(date).format('YYYY-M-DD')"
|
:datetime="dayjs(date).format('YYYY-M-DD')"
|
||||||
>{{ dayjs(date).format("DD MMMM YYYY") }}</time
|
>{{ dayjs(date).locale(locale).format("DD MMMM YYYY") }}</time
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -11,6 +11,9 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import "dayjs/locale/fr";
|
import "dayjs/locale/fr";
|
||||||
|
import "dayjs/locale/en";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
const { date } = defineProps({ date: String });
|
const { date } = defineProps({ date: String });
|
||||||
|
const { locale } = useI18n();
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
v-if="pdf.comments?.length > 0"
|
v-if="pdf.comments?.length > 0"
|
||||||
class="order-last | text-sm text-primary font-medium"
|
class="order-last | text-sm text-primary font-medium"
|
||||||
>
|
>
|
||||||
<router-link :to="'/' + step.uri + '&comments=true'">
|
<router-link :to="addLocalePrefix(step.uri) + '&comments=true'">
|
||||||
{{ pdf.comments.length }} commentaire{{
|
{{ pdf.comments.length }} commentaire{{
|
||||||
pdf.comments.length > 1 ? "s" : ""
|
pdf.comments.length > 1 ? "s" : ""
|
||||||
}}
|
}}
|
||||||
|
|
@ -39,6 +39,7 @@ import { useRoute } from "vue-router";
|
||||||
import DateTime from "./DateTime.vue";
|
import DateTime from "./DateTime.vue";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { useDesignToLightStore } from "../../../stores/designToLight";
|
import { useDesignToLightStore } from "../../../stores/designToLight";
|
||||||
|
import { addLocalePrefix } from "../../../utils/router";
|
||||||
|
|
||||||
const { step, pdf, index } = defineProps({
|
const { step, pdf, index } = defineProps({
|
||||||
step: Object,
|
step: Object,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<hgroup class="order-last">
|
<hgroup class="order-last">
|
||||||
<h3 class="card__title | font-serif | text-lg">
|
<h3 class="card__title | font-serif | text-lg">
|
||||||
<router-link :to="uri" class="link-full">{{ step.label }}</router-link>
|
<router-link :to="uri" class="link-full">{{ t('steps.' + step.id) }}</router-link>
|
||||||
</h3>
|
</h3>
|
||||||
</hgroup>
|
</hgroup>
|
||||||
<DateTime :date="step.modified" />
|
<DateTime :date="step.modified" />
|
||||||
|
|
@ -42,6 +42,7 @@ import DateTime from './DateTime.vue';
|
||||||
import { useDesignToLightStore } from '../../../stores/designToLight';
|
import { useDesignToLightStore } from '../../../stores/designToLight';
|
||||||
import { useVirtualSampleStore } from '../../../stores/virtualSample';
|
import { useVirtualSampleStore } from '../../../stores/virtualSample';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
const { images, step, uri } = defineProps({
|
const { images, step, uri } = defineProps({
|
||||||
images: Array,
|
images: Array,
|
||||||
|
|
@ -51,13 +52,17 @@ const { images, step, uri } = defineProps({
|
||||||
|
|
||||||
const { isDesignToLightStep } = useDesignToLightStore();
|
const { isDesignToLightStep } = useDesignToLightStore();
|
||||||
const { allVariations } = useVirtualSampleStore();
|
const { allVariations } = useVirtualSampleStore();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const commentsCount = computed(() => {
|
const commentsCount = computed(() => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
|
||||||
if (Array.isArray(step.files)) {
|
if (Array.isArray(step.files)) {
|
||||||
|
// Ne compter que les commentaires des images, pas des documents (PDFs)
|
||||||
for (const file of step.files) {
|
for (const file of step.files) {
|
||||||
count += file?.comments?.length || 0;
|
if (file.type === 'image') {
|
||||||
|
count += file?.comments?.length || 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (step.files?.dynamic) {
|
if (step.files?.dynamic) {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
:style="'--cover: url(' + step.cover + ')'"
|
:style="'--cover: url(' + step.cover + ')'"
|
||||||
>
|
>
|
||||||
<h3 class="text-lg font-serif">
|
<h3 class="text-lg font-serif">
|
||||||
<router-link :to="'/' + step.uri" class="link-full">{{
|
<router-link :to="addLocalePrefix(step.uri)" class="link-full">{{
|
||||||
step.title
|
step.title
|
||||||
}}</router-link>
|
}}</router-link>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import "dayjs/locale/fr";
|
import "dayjs/locale/fr";
|
||||||
|
import { addLocalePrefix } from "../../../utils/router";
|
||||||
|
|
||||||
const { step } = defineProps({ step: Object });
|
const { step } = defineProps({ step: Object });
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import Images from './Images.vue';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useVirtualSampleStore } from '../../../stores/virtualSample';
|
import { useVirtualSampleStore } from '../../../stores/virtualSample';
|
||||||
|
import { addLocalePrefix } from '../../../utils/router';
|
||||||
|
|
||||||
const { step } = defineProps({ step: Object });
|
const { step } = defineProps({ step: Object });
|
||||||
|
|
||||||
|
|
@ -27,7 +28,7 @@ const images = computed(() => {
|
||||||
return allVariations.value.map((variation) => getFrontView(variation)) ?? [];
|
return allVariations.value.map((variation) => getFrontView(variation)) ?? [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const uri = '/' + step.uri;
|
const uri = addLocalePrefix(step.uri);
|
||||||
|
|
||||||
function getFrontView(variation) {
|
function getFrontView(variation) {
|
||||||
if (variation.files.length === 1) return variation.files[0];
|
if (variation.files.length === 1) return variation.files[0];
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@
|
||||||
>
|
>
|
||||||
<span>{{
|
<span>{{
|
||||||
isCompareModeEnabled
|
isCompareModeEnabled
|
||||||
? 'Quitter le mode comparer'
|
? t('buttons.exitCompare')
|
||||||
: 'Comparer les pistes'
|
: t('buttons.compareTracks')
|
||||||
}}</span>
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
:backgroundColor="activeTrack.backgroundColor"
|
:backgroundColor="activeTrack.backgroundColor"
|
||||||
/>
|
/>
|
||||||
<div v-else class="track-empty | bg-white rounded-xl w-full p-32">
|
<div v-else class="track-empty | bg-white rounded-xl w-full p-32">
|
||||||
<p>Contenu non disponible pour cette piste</p>
|
<p>{{ t('virtualSample.noContent') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
v-if="isCompareModeEnabled && activeTracks.length < 2"
|
v-if="isCompareModeEnabled && activeTracks.length < 2"
|
||||||
class="track-empty | bg-white rounded-xl w-full p-32"
|
class="track-empty | bg-white rounded-xl w-full p-32"
|
||||||
>
|
>
|
||||||
<p>Sélectionnez sur la piste que vous souhaitez comparer</p>
|
<p>{{ t('virtualSample.selectToCompare') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -61,13 +61,17 @@ import { storeToRefs } from 'pinia';
|
||||||
import { usePageStore } from '../../../stores/page';
|
import { usePageStore } from '../../../stores/page';
|
||||||
import { useDialogStore } from '../../../stores/dialog';
|
import { useDialogStore } from '../../../stores/dialog';
|
||||||
import { useVirtualSampleStore } from '../../../stores/virtualSample';
|
import { useVirtualSampleStore } from '../../../stores/virtualSample';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import Interactive360 from './Interactive360.vue';
|
import Interactive360 from './Interactive360.vue';
|
||||||
import SingleImage from './SingleImage.vue';
|
import SingleImage from './SingleImage.vue';
|
||||||
import Selector from '../../Selector.vue';
|
import Selector from '../../Selector.vue';
|
||||||
import slugify from 'slugify';
|
import slugify from 'slugify';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const { page } = storeToRefs(usePageStore());
|
const { page } = storeToRefs(usePageStore());
|
||||||
const { isCommentsOpen, isCommentPanelEnabled, activeTracks, openedFile } =
|
const { isCommentsOpen, isCommentPanelEnabled, activeTracks, openedFile } =
|
||||||
|
|
@ -83,7 +87,7 @@ const tracks = computed(() => {
|
||||||
|
|
||||||
for (const key in raw) {
|
for (const key in raw) {
|
||||||
list.push({
|
list.push({
|
||||||
title: key,
|
title: key === 'Autres pistes' ? t('virtualSample.otherTracks') : key,
|
||||||
slug: slugify(key),
|
slug: slugify(key),
|
||||||
variations: raw[key] || [],
|
variations: raw[key] || [],
|
||||||
});
|
});
|
||||||
|
|
@ -92,41 +96,74 @@ const tracks = computed(() => {
|
||||||
return list;
|
return list;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- INITIALISATION ----------
|
function normalizeSlug(slug) {
|
||||||
// onBeforeMount : on initialise toujours activeTracks avec une VARIATION (jamais un track)
|
return slug.replace(/_/g, '-');
|
||||||
onBeforeMount(() => {
|
}
|
||||||
// essayer la hash en priorité
|
|
||||||
let initialVariation = null;
|
|
||||||
|
|
||||||
|
function getVariationSlug(variation) {
|
||||||
|
return variation.slug || (variation.title ? slugify(variation.title) : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findVariationByHash(hashValue) {
|
||||||
|
const allVariations = tracks.value.flatMap((track) => track.variations || []);
|
||||||
|
const normalizedHash = normalizeSlug(hashValue);
|
||||||
|
|
||||||
|
return allVariations.find((variation) => {
|
||||||
|
const variationSlug = getVariationSlug(variation);
|
||||||
|
if (!variationSlug) return false;
|
||||||
|
|
||||||
|
const normalizedVariationSlug = normalizeSlug(variationSlug);
|
||||||
|
return normalizedVariationSlug === normalizedHash;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialVariation() {
|
||||||
if (route?.hash && route.hash.length > 0) {
|
if (route?.hash && route.hash.length > 0) {
|
||||||
const variations = tracks.value.flatMap((t) => t.variations || []);
|
const hashValue = route.hash.substring(1);
|
||||||
initialVariation =
|
const variationFromHash = findVariationByHash(hashValue);
|
||||||
variations.find((v) => v.slug === route.hash.substring(1)) || null;
|
if (variationFromHash) return variationFromHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallback : première variation du premier track
|
return tracks.value[0]?.variations?.[0] || null;
|
||||||
if (!initialVariation) {
|
}
|
||||||
initialVariation = tracks.value[0]?.variations?.[0] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (initialVariation) {
|
function initializeActiveTracks() {
|
||||||
activeTracks.value = [initialVariation];
|
const initialVariation = getInitialVariation();
|
||||||
} else {
|
activeTracks.value = initialVariation ? [initialVariation] : [];
|
||||||
activeTracks.value = []; // aucun contenu disponible
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// scroll si hash présent
|
function normalizeUrlHash() {
|
||||||
onMounted(() => {
|
if (route?.hash && route.hash.includes('_')) {
|
||||||
if (route.query?.comments) isCommentsOpen.value = true;
|
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;
|
if (!route?.hash || route.hash.length === 0) return;
|
||||||
|
|
||||||
const selector = route.hash.replace('#', '#track--');
|
const selectorId = route.hash.replace('#', '#track--');
|
||||||
const targetBtn = document.querySelector(selector);
|
const targetButton = document.querySelector(selectorId);
|
||||||
if (targetBtn) targetBtn.scrollIntoView();
|
if (targetButton) {
|
||||||
|
targetButton.scrollIntoView();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
initializeActiveTracks();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- COMPUTED / WATCH ----------
|
onMounted(() => {
|
||||||
|
openCommentsIfRequested();
|
||||||
|
normalizeUrlHash();
|
||||||
|
scrollToHashTarget();
|
||||||
|
});
|
||||||
|
|
||||||
const isSingleImage = computed(() => {
|
const isSingleImage = computed(() => {
|
||||||
return (
|
return (
|
||||||
|
|
@ -139,38 +176,52 @@ const singleFile = computed(() => {
|
||||||
return isSingleImage.value ? activeTracks.value[0].files[0] : null;
|
return isSingleImage.value ? activeTracks.value[0].files[0] : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
function updateOpenedFile(file) {
|
||||||
singleFile,
|
if (file) {
|
||||||
(newValue) => {
|
openedFile.value = file;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// quand on quitte le mode comparaison on retire l'élément secondaire si nécessaire
|
function enableCompareModeUI() {
|
||||||
if (!newValue && activeTracks.value.length === 2) {
|
isCommentsOpen.value = false;
|
||||||
|
isCommentPanelEnabled.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableCompareModeUI() {
|
||||||
|
isCommentPanelEnabled.value = true;
|
||||||
|
|
||||||
|
if (activeTracks.value.length === 2) {
|
||||||
activeTracks.value.pop();
|
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 ----------
|
watch(
|
||||||
function getCommentsCount(track) {
|
activeTracks,
|
||||||
if (!track || !Array.isArray(track.files)) return undefined;
|
(tracks) => {
|
||||||
let count = 0;
|
if (tracks && tracks.length > 0) {
|
||||||
for (const file of track.files) {
|
updateUrlHash(tracks[0]);
|
||||||
count += file?.comments?.length || 0;
|
}
|
||||||
}
|
},
|
||||||
return count > 0 ? count : undefined;
|
{ deep: true }
|
||||||
}
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
modal
|
modal
|
||||||
:draggable="false"
|
:draggable="false"
|
||||||
:dismissableMask="true"
|
:dismissableMask="true"
|
||||||
header="Titre du rendu"
|
:header="t('dialogs.renderTitle')"
|
||||||
class="dialog"
|
class="dialog"
|
||||||
:class="[
|
:class="[
|
||||||
{ 'with-comments': isCommentsOpen },
|
{ 'with-comments': isCommentsOpen },
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
aria-controls="dynamic"
|
aria-controls="dynamic"
|
||||||
@click="activeTab = 'dynamic'"
|
@click="activeTab = 'dynamic'"
|
||||||
>
|
>
|
||||||
<span>Présentation dynamique</span>
|
<span>{{ t('virtualSample.dynamicPresentation') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="step.files.static"
|
v-if="step.files.static"
|
||||||
|
|
@ -38,10 +38,10 @@
|
||||||
:aria-pressed="activeTab === 'static' ? true : false"
|
:aria-pressed="activeTab === 'static' ? true : false"
|
||||||
aria-controls="static"
|
aria-controls="static"
|
||||||
>
|
>
|
||||||
<span>Vue statique</span>
|
<span>{{ t('virtualSample.staticView') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="font-serif text-lg">Échantillon virtuel</h2>
|
<h2 class="font-serif text-lg">{{ t('virtualSample.title') }}</h2>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<DynamicView id="dynamic" v-if="activeTab === 'dynamic'" />
|
<DynamicView id="dynamic" v-if="activeTab === 'dynamic'" />
|
||||||
|
|
@ -66,8 +66,8 @@
|
||||||
>
|
>
|
||||||
<span>{{
|
<span>{{
|
||||||
!isLoopAnimationEnabled
|
!isLoopAnimationEnabled
|
||||||
? 'Animation en boucle'
|
? t('buttons.loopAnimation')
|
||||||
: 'Arrêter l’animation'
|
: t('buttons.stopAnimation')
|
||||||
}}</span>
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|
@ -79,7 +79,7 @@
|
||||||
@click="isCommentsOpen = !isCommentsOpen"
|
@click="isCommentsOpen = !isCommentsOpen"
|
||||||
>
|
>
|
||||||
<span class="sr-only"
|
<span class="sr-only"
|
||||||
>{{ isCommentsOpen ? 'Masquer' : 'Afficher' }} les commentaires</span
|
>{{ isCommentsOpen ? t('buttons.hideComments') : t('buttons.showComments') }}</span
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -103,7 +103,9 @@ import { useVirtualSampleStore } from '../../../stores/virtualSample';
|
||||||
import { useDialogStore } from '../../../stores/dialog';
|
import { useDialogStore } from '../../../stores/dialog';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { usePageStore } from '../../../stores/page';
|
import { usePageStore } from '../../../stores/page';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const { file } = defineProps({
|
const { file } = defineProps({
|
||||||
file: Object,
|
file: Object,
|
||||||
});
|
});
|
||||||
|
|
@ -141,12 +143,12 @@ watch(isOpen, (newValue) => {
|
||||||
const downloadText = computed(() => {
|
const downloadText = computed(() => {
|
||||||
if (activeTab.value === 'dynamic') {
|
if (activeTab.value === 'dynamic') {
|
||||||
if (activeTracks.value.length === 1) {
|
if (activeTracks.value.length === 1) {
|
||||||
return "Télécharger l'image";
|
return t('buttons.downloadImage');
|
||||||
} else {
|
} else {
|
||||||
return 'Télécharger les images';
|
return t('buttons.downloadImages');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return 'Télécharger le PDF';
|
return t('buttons.downloadPdf');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
201
src/locales/en.json
Normal file
201
src/locales/en.json
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
{
|
||||||
|
"menu": {
|
||||||
|
"home": "Home",
|
||||||
|
"notifications": "Notifications",
|
||||||
|
"meetings": "Meetings",
|
||||||
|
"designToLight": "Design to Light",
|
||||||
|
"inspirations": "Inspirations",
|
||||||
|
"profile": "Profile",
|
||||||
|
"logout": "Logout",
|
||||||
|
"currentProjects": "Current projects",
|
||||||
|
"archivedProjects": "Archived projects",
|
||||||
|
"news": "New",
|
||||||
|
"show": "Show menu",
|
||||||
|
"hide": "Hide menu",
|
||||||
|
"newModifications": "New modifications"
|
||||||
|
},
|
||||||
|
"steps": {
|
||||||
|
"clientBrief": "Client brief",
|
||||||
|
"proposal": "Commercial proposal",
|
||||||
|
"extendedBrief": "Extended brief",
|
||||||
|
"industrialIdeation": "Industrial ideation",
|
||||||
|
"virtualSample": "Virtual sample",
|
||||||
|
"physicalSample": "Physical sample"
|
||||||
|
},
|
||||||
|
"dtl": {
|
||||||
|
"dynamicTrack": "Virtual sample - dynamic track",
|
||||||
|
"staticTrack": "Virtual sample - static track",
|
||||||
|
"title": "Design to Light",
|
||||||
|
"grade": "Design to Light: {grade}",
|
||||||
|
"globalScore": "Global score",
|
||||||
|
"positioning": "Positioning",
|
||||||
|
"design": "Design",
|
||||||
|
"weight": "Weight",
|
||||||
|
"indicators": "Component indicators",
|
||||||
|
"requestOptimization": "Request optimization expertise",
|
||||||
|
"requestPending": "Expertise request being processed…",
|
||||||
|
"initialProposal": "Initial proposal",
|
||||||
|
"alternative": "Alternative {index}",
|
||||||
|
"proposalBasedOn": "Data based on the proposal"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"edit": "Edit",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
|
"delete": "Delete",
|
||||||
|
"submit": "Submit",
|
||||||
|
"retry": "Retry",
|
||||||
|
"add": "Add",
|
||||||
|
"close": "Close",
|
||||||
|
"validate": "Validate and send the brief",
|
||||||
|
"validateMinimum": "Add at least one image",
|
||||||
|
"showPassword": "Show password",
|
||||||
|
"hidePassword": "Hide password",
|
||||||
|
"showProject": "Show project",
|
||||||
|
"hideProject": "Hide project",
|
||||||
|
"showComments": "Show comments",
|
||||||
|
"hideComments": "Hide comments",
|
||||||
|
"backToProject": "Back to project",
|
||||||
|
"backToList": "Back to list",
|
||||||
|
"addComment": "Add a comment",
|
||||||
|
"reply": "Reply…",
|
||||||
|
"seeAll": "See all",
|
||||||
|
"deleteImage": "Delete this image",
|
||||||
|
"downloadImage": "Download image",
|
||||||
|
"downloadImages": "Download images",
|
||||||
|
"downloadPdf": "Download PDF",
|
||||||
|
"update": "Update",
|
||||||
|
"requestMeeting": "Request a meeting",
|
||||||
|
"markAllAsRead": "Mark all as read",
|
||||||
|
"loopAnimation": "Loop animation",
|
||||||
|
"stopAnimation": "Stop animation",
|
||||||
|
"compareTracks": "Compare tracks",
|
||||||
|
"exitCompare": "Exit compare mode",
|
||||||
|
"addSelectedImages": "Add selected images"
|
||||||
|
},
|
||||||
|
"forms": {
|
||||||
|
"email": "Email",
|
||||||
|
"emailPlaceholder": "email{'@'}example.com",
|
||||||
|
"password": "Password",
|
||||||
|
"passwordPlaceholder": "Minimum 8 characters",
|
||||||
|
"newPassword": "New password",
|
||||||
|
"confirmPassword": "Confirm new password…",
|
||||||
|
"projectName": "Project name",
|
||||||
|
"projectDetails": "Project details",
|
||||||
|
"projectDetailsPlaceholder": "Project details…",
|
||||||
|
"meetingSubject": "Meeting subject",
|
||||||
|
"meetingDetails": "Project details",
|
||||||
|
"meetingDetailsPlaceholder": "Describe your request…",
|
||||||
|
"description": "Project description",
|
||||||
|
"descriptionPlaceholder": "Add a general description of your project…",
|
||||||
|
"imageDescription": "Image description",
|
||||||
|
"imageDescriptionPlaceholder": "Add a description to the image…",
|
||||||
|
"commentPlaceholder": "Add a comment…",
|
||||||
|
"filterByTags": "Filter by tags",
|
||||||
|
"tags": "Tags",
|
||||||
|
"selectVariation": "Select a variation",
|
||||||
|
"uploadedFiles": "Uploaded files",
|
||||||
|
"addImages": "Add one or more images",
|
||||||
|
"selectTags": "Select one or more tags"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"login": "Login",
|
||||||
|
"passwordShown": "Password shown",
|
||||||
|
"passwordHidden": "Password hidden",
|
||||||
|
"fillFields": "Please fill in the fields.",
|
||||||
|
"inProgress": "In progress…",
|
||||||
|
"updateSuccess": "Update successful",
|
||||||
|
"updateInProgress": "in progress…"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"comments": "Comments",
|
||||||
|
"noClient": "No associated client",
|
||||||
|
"managedProjects": "Managed projects",
|
||||||
|
"myProjects": "My projects",
|
||||||
|
"currentStep": "Current step:",
|
||||||
|
"projectCount": "Number of projects",
|
||||||
|
"clientLogo": "{clientName} logo"
|
||||||
|
},
|
||||||
|
"brief": {
|
||||||
|
"addPlatform": "Add a brief via the platform",
|
||||||
|
"addPdf": "Add a PDF brief",
|
||||||
|
"projects": "Projects"
|
||||||
|
},
|
||||||
|
"comments": {
|
||||||
|
"title": "Comments",
|
||||||
|
"your": "Your comment",
|
||||||
|
"new": "New comment",
|
||||||
|
"newInstruction": "In the content area, click where you want to position the comment",
|
||||||
|
"reply": "reply",
|
||||||
|
"replies": "replies",
|
||||||
|
"edit": "Edit",
|
||||||
|
"inProgress": "In progress",
|
||||||
|
"emptyMessage": "Share your ideas by adding comments"
|
||||||
|
},
|
||||||
|
"dates": {
|
||||||
|
"today": "Today",
|
||||||
|
"yesterday": "yesterday",
|
||||||
|
"updatedOn": "Last updated on"
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"none": "No projects at the moment",
|
||||||
|
"current": "Current projects",
|
||||||
|
"archived": "Archived projects"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Notifications",
|
||||||
|
"none": "You have no new notifications",
|
||||||
|
"noneUnread": "You have no unread notifications",
|
||||||
|
"all": "All",
|
||||||
|
"unread": "Unread",
|
||||||
|
"projectRequest": "Project creation request",
|
||||||
|
"meetingRequest": "Meeting request",
|
||||||
|
"content": "Content",
|
||||||
|
"draft": "(draft)",
|
||||||
|
"from": "From",
|
||||||
|
"author": "Author:",
|
||||||
|
"goToContent": "Go to content"
|
||||||
|
},
|
||||||
|
"meetings": {
|
||||||
|
"none": "You have no scheduled meetings",
|
||||||
|
"upcoming": "Upcoming",
|
||||||
|
"past": "Past"
|
||||||
|
},
|
||||||
|
"inspirations": {
|
||||||
|
"title": "Inspirations",
|
||||||
|
"favorites": "My Favorites",
|
||||||
|
"addToFavorites": "Add to favorites",
|
||||||
|
"removeFromFavorites": "Remove from favorites",
|
||||||
|
"new": "New"
|
||||||
|
},
|
||||||
|
"dialogs": {
|
||||||
|
"requestProject": "Request project creation",
|
||||||
|
"requestMeeting": "Request a meeting",
|
||||||
|
"imageDetails": "Image details",
|
||||||
|
"addImages": "Add images",
|
||||||
|
"deleteConfirm": "Are you sure you want to delete this image?",
|
||||||
|
"deleteWarning": "If you delete this image, it will disappear from your brief along with all the information attributed to it.",
|
||||||
|
"pdfTitle": "PDF title",
|
||||||
|
"renderTitle": "Render title",
|
||||||
|
"createWithDTL": "Create with Design to Light",
|
||||||
|
"learnMore": "Learn more",
|
||||||
|
"dtlDescription": "Discover the environmental score of your project..."
|
||||||
|
},
|
||||||
|
"virtualSample": {
|
||||||
|
"title": "Virtual sample",
|
||||||
|
"dynamicPresentation": "Dynamic presentation",
|
||||||
|
"staticView": "Static view",
|
||||||
|
"noContent": "Content not available for this track",
|
||||||
|
"selectToCompare": "Select the track you want to compare",
|
||||||
|
"otherTracks": "Other tracks"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"saveFailed": "Save failed",
|
||||||
|
"deleteFailed": "Delete failed",
|
||||||
|
"toggleProjectFailed": "Failed to show/hide project",
|
||||||
|
"toggleFavoriteFailed": "Failed to toggle favorite",
|
||||||
|
"readNotificationFailed": "Failed to read notification:",
|
||||||
|
"readAllNotificationsFailed": "Could not read all notifications",
|
||||||
|
"markNotificationFailed": "Could not mark notification as read"
|
||||||
|
}
|
||||||
|
}
|
||||||
201
src/locales/fr.json
Normal file
201
src/locales/fr.json
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
{
|
||||||
|
"menu": {
|
||||||
|
"home": "Home",
|
||||||
|
"notifications": "Notifications",
|
||||||
|
"meetings": "Réunions",
|
||||||
|
"designToLight": "Design to Light",
|
||||||
|
"inspirations": "Inspirations",
|
||||||
|
"profile": "Profil",
|
||||||
|
"logout": "Déconnexion",
|
||||||
|
"currentProjects": "Projets en cours",
|
||||||
|
"archivedProjects": "Projets archivés",
|
||||||
|
"news": "Nouveautés",
|
||||||
|
"show": "Afficher le menu",
|
||||||
|
"hide": "Masquer le menu",
|
||||||
|
"newModifications": "Nouvelles modifications"
|
||||||
|
},
|
||||||
|
"steps": {
|
||||||
|
"clientBrief": "Brief client",
|
||||||
|
"proposal": "Proposition commerciale",
|
||||||
|
"extendedBrief": "Brief enrichi",
|
||||||
|
"industrialIdeation": "Idéation industrielle",
|
||||||
|
"virtualSample": "Échantillon virtuel",
|
||||||
|
"physicalSample": "Échantillon physique"
|
||||||
|
},
|
||||||
|
"dtl": {
|
||||||
|
"dynamicTrack": "Échantillon virtuel - piste dynamique",
|
||||||
|
"staticTrack": "Échantillon virtuel - piste statique",
|
||||||
|
"title": "Design to Light",
|
||||||
|
"grade": "Design to Light: {grade}",
|
||||||
|
"globalScore": "Note globale",
|
||||||
|
"positioning": "Positionnement",
|
||||||
|
"design": "Design",
|
||||||
|
"weight": "Poids",
|
||||||
|
"indicators": "Indicateur des composants impliqués",
|
||||||
|
"requestOptimization": "Demander une expertise d'optimisation",
|
||||||
|
"requestPending": "Demande d'expertise en cours de traitement…",
|
||||||
|
"initialProposal": "Proposition initiale",
|
||||||
|
"alternative": "Alternative {index}",
|
||||||
|
"proposalBasedOn": "Données basées sur la proposition"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"edit": "Modifier",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"save": "Sauvegarder",
|
||||||
|
"delete": "Supprimer",
|
||||||
|
"submit": "Soumettre",
|
||||||
|
"retry": "Réessayer",
|
||||||
|
"add": "Ajouter",
|
||||||
|
"close": "Fermer",
|
||||||
|
"validate": "Valider et envoyer le brief",
|
||||||
|
"validateMinimum": "Ajoutez au moins une image",
|
||||||
|
"showPassword": "Afficher le mot de passe",
|
||||||
|
"hidePassword": "Masquer le mot de passe",
|
||||||
|
"showProject": "Afficher le projet",
|
||||||
|
"hideProject": "Masquer le projet",
|
||||||
|
"showComments": "Afficher les commentaires",
|
||||||
|
"hideComments": "Masquer les commentaires",
|
||||||
|
"backToProject": "Retour au projet",
|
||||||
|
"backToList": "Retour à la liste",
|
||||||
|
"addComment": "Ajouter un commentaire",
|
||||||
|
"reply": "Répondre…",
|
||||||
|
"seeAll": "Voir tout",
|
||||||
|
"deleteImage": "Supprimer cette image",
|
||||||
|
"downloadImage": "Télécharger l'image",
|
||||||
|
"downloadImages": "Télécharger les images",
|
||||||
|
"downloadPdf": "Télécharger le PDF",
|
||||||
|
"update": "Mettre à jour",
|
||||||
|
"requestMeeting": "Demander un RDV",
|
||||||
|
"markAllAsRead": "Marquer tout comme lu",
|
||||||
|
"loopAnimation": "Animation en boucle",
|
||||||
|
"stopAnimation": "Arrêter l'animation",
|
||||||
|
"compareTracks": "Comparer les pistes",
|
||||||
|
"exitCompare": "Quitter le mode comparer",
|
||||||
|
"addSelectedImages": "Ajouter les images sélectionnées"
|
||||||
|
},
|
||||||
|
"forms": {
|
||||||
|
"email": "Email",
|
||||||
|
"emailPlaceholder": "mail{'@'}exemple.com",
|
||||||
|
"password": "Mot de passe",
|
||||||
|
"passwordPlaceholder": "Minimum 8 caractères",
|
||||||
|
"newPassword": "Nouveau mot de passe",
|
||||||
|
"confirmPassword": "Confirmez le nouveau mot de passe…",
|
||||||
|
"projectName": "Nom du projet",
|
||||||
|
"projectDetails": "Détails du projet",
|
||||||
|
"projectDetailsPlaceholder": "Détails du projet…",
|
||||||
|
"meetingSubject": "Objet du rendez-vous",
|
||||||
|
"meetingDetails": "Détails du projet",
|
||||||
|
"meetingDetailsPlaceholder": "Décrivez votre demande…",
|
||||||
|
"description": "Description du projet",
|
||||||
|
"descriptionPlaceholder": "Ajoutez une description générale de votre projet…",
|
||||||
|
"imageDescription": "Description de l'image",
|
||||||
|
"imageDescriptionPlaceholder": "Ajoutez une description à l'image…",
|
||||||
|
"commentPlaceholder": "Ajouter un commentaire…",
|
||||||
|
"filterByTags": "Filtrer par tags",
|
||||||
|
"tags": "Tags",
|
||||||
|
"selectVariation": "Sélectionnez une déclinaison",
|
||||||
|
"uploadedFiles": "Fichiers importés",
|
||||||
|
"addImages": "Ajouter une ou plusieurs images",
|
||||||
|
"selectTags": "Sélectionner un ou plusieurs tags"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"login": "Connexion",
|
||||||
|
"passwordShown": "Mot de passe affiché",
|
||||||
|
"passwordHidden": "Mot de passe masqué",
|
||||||
|
"fillFields": "Veuillez remplir les champs.",
|
||||||
|
"inProgress": "En cours…",
|
||||||
|
"updateSuccess": "Mise à jour réussie",
|
||||||
|
"updateInProgress": "en cours…"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"comments": "Commentaires",
|
||||||
|
"noClient": "Pas de client associé",
|
||||||
|
"managedProjects": "Projets managés",
|
||||||
|
"myProjects": "Mes projets",
|
||||||
|
"currentStep": "Étape en cours :",
|
||||||
|
"projectCount": "Nombre de projets",
|
||||||
|
"clientLogo": "logo {clientName}"
|
||||||
|
},
|
||||||
|
"brief": {
|
||||||
|
"addPlatform": "Ajouter un brief via la plateforme",
|
||||||
|
"addPdf": "Ajouter un brief PDF",
|
||||||
|
"projects": "Projets"
|
||||||
|
},
|
||||||
|
"comments": {
|
||||||
|
"title": "Commentaires",
|
||||||
|
"your": "Votre commentaire",
|
||||||
|
"new": "Nouveau commentaire",
|
||||||
|
"newInstruction": "Dans la zone du contenu, cliquez où vous souhaitez positionner le commentaire",
|
||||||
|
"reply": "réponse",
|
||||||
|
"replies": "réponses",
|
||||||
|
"edit": "Éditer",
|
||||||
|
"inProgress": "En cours",
|
||||||
|
"emptyMessage": "Partagez vos idées en ajoutant des commentaires"
|
||||||
|
},
|
||||||
|
"dates": {
|
||||||
|
"today": "Aujourd'hui",
|
||||||
|
"yesterday": "hier",
|
||||||
|
"updatedOn": "Dernière mise à jour le"
|
||||||
|
},
|
||||||
|
"projects": {
|
||||||
|
"none": "Aucun projet pour le moment",
|
||||||
|
"current": "Projets en cours",
|
||||||
|
"archived": "Projets archivés"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Notifications",
|
||||||
|
"none": "Vous n'avez pas de nouvelles notifications",
|
||||||
|
"noneUnread": "Vous n'avez pas de notifications non lues",
|
||||||
|
"all": "Tout",
|
||||||
|
"unread": "Non lu",
|
||||||
|
"projectRequest": "Demande de création de projet",
|
||||||
|
"meetingRequest": "Demande de rendez-vous",
|
||||||
|
"content": "Contenu",
|
||||||
|
"draft": "(brouillon)",
|
||||||
|
"from": "De la part de",
|
||||||
|
"author": "Auteur :",
|
||||||
|
"goToContent": "Aller au contenu"
|
||||||
|
},
|
||||||
|
"meetings": {
|
||||||
|
"none": "Vous n'avez aucune réunion programmée",
|
||||||
|
"upcoming": "À venir",
|
||||||
|
"past": "Passées"
|
||||||
|
},
|
||||||
|
"inspirations": {
|
||||||
|
"title": "Les Inspirations",
|
||||||
|
"favorites": "Mes Favoris",
|
||||||
|
"addToFavorites": "Ajouter aux favoris",
|
||||||
|
"removeFromFavorites": "Retirer des favoris",
|
||||||
|
"new": "Nouveauté"
|
||||||
|
},
|
||||||
|
"dialogs": {
|
||||||
|
"requestProject": "Demander la création d'un projet",
|
||||||
|
"requestMeeting": "Demander un rendez-vous",
|
||||||
|
"imageDetails": "Détails de l'image",
|
||||||
|
"addImages": "Ajouter des images",
|
||||||
|
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer cette image ?",
|
||||||
|
"deleteWarning": "Si vous supprimez cette image, celle-ci disparaîtra de votre brief ainsi que toutes les informations qui lui sont attribuées.",
|
||||||
|
"pdfTitle": "Titre du PDF",
|
||||||
|
"renderTitle": "Titre du rendu",
|
||||||
|
"createWithDTL": "Créer avec Design to Light",
|
||||||
|
"learnMore": "En savoir plus",
|
||||||
|
"dtlDescription": "Découvrez la note environnementale de votre projet..."
|
||||||
|
},
|
||||||
|
"virtualSample": {
|
||||||
|
"title": "Échantillon virtuel",
|
||||||
|
"dynamicPresentation": "Présentation dynamique",
|
||||||
|
"staticView": "Vue statique",
|
||||||
|
"noContent": "Contenu non disponible pour cette piste",
|
||||||
|
"selectToCompare": "Sélectionnez sur la piste que vous souhaitez comparer",
|
||||||
|
"otherTracks": "Autres pistes"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"saveFailed": "Erreur lors de la sauvegarde",
|
||||||
|
"deleteFailed": "Erreur lors de la suppression",
|
||||||
|
"toggleProjectFailed": "Erreur lors du masquage/affichage du projet",
|
||||||
|
"toggleFavoriteFailed": "Failed to toggle favorite",
|
||||||
|
"readNotificationFailed": "Erreur lors de la lecture de la notification :",
|
||||||
|
"readAllNotificationsFailed": "Could not read all notifications",
|
||||||
|
"markNotificationFailed": "Could not mark notification as read"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/main.js
23
src/main.js
|
|
@ -6,12 +6,30 @@ import PrimeVue from 'primevue/config';
|
||||||
import ToastService from 'primevue/toastservice';
|
import ToastService from 'primevue/toastservice';
|
||||||
import Select from 'primevue/select';
|
import Select from 'primevue/select';
|
||||||
import MultiSelect from 'primevue/multiselect';
|
import MultiSelect from 'primevue/multiselect';
|
||||||
import { router } from './router/router.js';
|
import { router, setI18nLocale } from './router/router.js';
|
||||||
|
import { createI18n } from 'vue-i18n';
|
||||||
|
import fr from './locales/fr.json';
|
||||||
|
import en from './locales/en.json';
|
||||||
|
import { setI18nInstance } from './stores/locale';
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'fr',
|
||||||
|
fallbackLocale: 'fr',
|
||||||
|
messages: {
|
||||||
|
fr,
|
||||||
|
en,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Donner l'instance i18n au store locale pour synchronisation immédiate
|
||||||
|
setI18nInstance(i18n);
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
|
|
||||||
app.use(pinia);
|
app.use(pinia);
|
||||||
|
app.use(i18n);
|
||||||
app.use(PrimeVue, {
|
app.use(PrimeVue, {
|
||||||
unstyled: true,
|
unstyled: true,
|
||||||
});
|
});
|
||||||
|
|
@ -19,4 +37,7 @@ app.use(ToastService);
|
||||||
app.use(router);
|
app.use(router);
|
||||||
app.component('Select', Select);
|
app.component('Select', Select);
|
||||||
app.component('MultiSelect', MultiSelect);
|
app.component('MultiSelect', MultiSelect);
|
||||||
|
|
||||||
|
setI18nLocale(i18n);
|
||||||
|
|
||||||
app.mount('#app');
|
app.mount('#app');
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue