Initial local backup snapshot
This commit is contained in:
commit
acd9e14ba5
367 changed files with 118038 additions and 0 deletions
BIN
.codex-admin-directory-desktop.png
Normal file
BIN
.codex-admin-directory-desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
10
.dockerignore
Normal file
10
.dockerignore
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
coverage
|
||||||
|
uploads
|
||||||
114
.gitignore
vendored
Normal file
114
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
# Dependencies
|
||||||
|
**/node_modules
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.dist
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage/
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Storybook build outputs
|
||||||
|
.out
|
||||||
|
.storybook-out
|
||||||
|
|
||||||
|
# Temporary folders
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Local services
|
||||||
|
local-services/forgejo/data/
|
||||||
|
local-services/forgejo/db/
|
||||||
|
|
||||||
|
# Local tools
|
||||||
|
local-services/forgejo/.env.local
|
||||||
0
.gitkeep
Normal file
0
.gitkeep
Normal file
28
.manus/db/db-query-1769818203014.json
Normal file
28
.manus/db/db-query-1769818203014.json
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"query": "SELECT id, name, email, role, isActive, createdAt, lastSignedIn FROM users ORDER BY createdAt;",
|
||||||
|
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway03.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 2fRWrTVEDLYRs9G.c8049d3e27ba --database EAGCgSPXcL4ryFq9XLBKvW --execute SELECT id, name, email, role, isActive, createdAt, lastSignedIn FROM users ORDER BY createdAt;",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"name": "Selecta Keke",
|
||||||
|
"email": "selectakeke973@gmail.com",
|
||||||
|
"role": "admin",
|
||||||
|
"isActive": "1",
|
||||||
|
"createdAt": "2026-01-30 21:56:11",
|
||||||
|
"lastSignedIn": "2026-01-31 00:03:40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "90004",
|
||||||
|
"name": "William",
|
||||||
|
"email": "william.meri@gmail.com",
|
||||||
|
"role": "user",
|
||||||
|
"isActive": "1",
|
||||||
|
"createdAt": "2026-01-30 23:26:14",
|
||||||
|
"lastSignedIn": "2026-01-31 00:08:06"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"messages": [],
|
||||||
|
"stdout": "id\tname\temail\trole\tisActive\tcreatedAt\tlastSignedIn\n1\tSelecta Keke\tselectakeke973@gmail.com\tadmin\t1\t2026-01-30 21:56:11\t2026-01-31 00:03:40\n90004\tWilliam\twilliam.meri@gmail.com\tuser\t1\t2026-01-30 23:26:14\t2026-01-31 00:08:06\n",
|
||||||
|
"stderr": "",
|
||||||
|
"execution_time_ms": 214
|
||||||
|
}
|
||||||
34
.manus/db/db-query-1769818206932.json
Normal file
34
.manus/db/db-query-1769818206932.json
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"query": "SELECT * FROM users;",
|
||||||
|
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway03.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 2fRWrTVEDLYRs9G.c8049d3e27ba --database EAGCgSPXcL4ryFq9XLBKvW --execute SELECT * FROM users;",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"openId": "Pzgrt3DbY8fVWA3UaMwk39",
|
||||||
|
"name": "Selecta Keke",
|
||||||
|
"email": "selectakeke973@gmail.com",
|
||||||
|
"loginMethod": "google",
|
||||||
|
"role": "admin",
|
||||||
|
"createdAt": "2026-01-30 21:56:11",
|
||||||
|
"updatedAt": "2026-01-31 00:03:40",
|
||||||
|
"lastSignedIn": "2026-01-31 00:03:40",
|
||||||
|
"isActive": "1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "90004",
|
||||||
|
"openId": "ijeVp4mua5wFXDoHd43yfm",
|
||||||
|
"name": "William",
|
||||||
|
"email": "william.meri@gmail.com",
|
||||||
|
"loginMethod": "google",
|
||||||
|
"role": "user",
|
||||||
|
"createdAt": "2026-01-30 23:26:14",
|
||||||
|
"updatedAt": "2026-01-31 00:08:05",
|
||||||
|
"lastSignedIn": "2026-01-31 00:08:06",
|
||||||
|
"isActive": "1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"messages": [],
|
||||||
|
"stdout": "id\topenId\tname\temail\tloginMethod\trole\tcreatedAt\tupdatedAt\tlastSignedIn\tisActive\n1\tPzgrt3DbY8fVWA3UaMwk39\tSelecta Keke\tselectakeke973@gmail.com\tgoogle\tadmin\t2026-01-30 21:56:11\t2026-01-31 00:03:40\t2026-01-31 00:03:40\t1\n90004\tijeVp4mua5wFXDoHd43yfm\tWilliam\twilliam.meri@gmail.com\tgoogle\tuser\t2026-01-30 23:26:14\t2026-01-31 00:08:05\t2026-01-31 00:08:06\t1\n",
|
||||||
|
"stderr": "",
|
||||||
|
"execution_time_ms": 62
|
||||||
|
}
|
||||||
9
.manus/db/db-query-1769818255994.json
Normal file
9
.manus/db/db-query-1769818255994.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"query": "UPDATE users SET role = 'super_admin' WHERE id = 1; UPDATE users SET role = 'admin' WHERE id = 90004;",
|
||||||
|
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway03.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 2fRWrTVEDLYRs9G.c8049d3e27ba --database EAGCgSPXcL4ryFq9XLBKvW --execute UPDATE users SET role = 'super_admin' WHERE id = 1; UPDATE users SET role = 'admin' WHERE id = 90004;",
|
||||||
|
"rows": [],
|
||||||
|
"messages": [],
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": "",
|
||||||
|
"execution_time_ms": 65
|
||||||
|
}
|
||||||
35
.prettierignore
Normal file
35
.prettierignore
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.dist
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
*.tsbuildinfo
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Package files
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
15
.prettierrc
Normal file
15
.prettierrc
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"singleQuote": false,
|
||||||
|
"printWidth": 80,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"quoteProps": "as-needed",
|
||||||
|
"jsxSingleQuote": false,
|
||||||
|
"proseWrap": "preserve"
|
||||||
|
}
|
||||||
BIN
BDD ACTEURS SAVANES 2024 1.xlsx
Normal file
BIN
BDD ACTEURS SAVANES 2024 1.xlsx
Normal file
Binary file not shown.
BIN
CCDS_Organigramme_2022.png
Normal file
BIN
CCDS_Organigramme_2022.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
21
Dockerfile
Normal file
21
Dockerfile
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
FROM node:22-alpine AS base
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV PNPM_HOME="/pnpm"
|
||||||
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
|
||||||
|
RUN corepack enable && corepack prepare pnpm@10.4.1 --activate
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
COPY patches ./patches
|
||||||
|
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["pnpm", "start"]
|
||||||
70
MAIL_PROVIDERS.md
Normal file
70
MAIL_PROVIDERS.md
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
# Configuration mail
|
||||||
|
|
||||||
|
Le portail supporte maintenant un preset simple par fournisseur avec `SMTP_PROVIDER`.
|
||||||
|
|
||||||
|
## 1. OVH
|
||||||
|
|
||||||
|
```env
|
||||||
|
SMTP_PROVIDER=ovh
|
||||||
|
SMTP_FROM=notifications@votre-domaine.fr
|
||||||
|
SMTP_USER=notifications@votre-domaine.fr
|
||||||
|
SMTP_PASS=votre-mot-de-passe-ou-mot-de-passe-app
|
||||||
|
```
|
||||||
|
|
||||||
|
Valeurs automatiques appliquées :
|
||||||
|
|
||||||
|
- `SMTP_HOST=smtp.mail.ovh.net`
|
||||||
|
- `SMTP_PORT=465`
|
||||||
|
- `SMTP_SECURE=true`
|
||||||
|
|
||||||
|
## 2. Gmail
|
||||||
|
|
||||||
|
```env
|
||||||
|
SMTP_PROVIDER=gmail
|
||||||
|
SMTP_FROM=votre-adresse@gmail.com
|
||||||
|
SMTP_USER=votre-adresse@gmail.com
|
||||||
|
SMTP_PASS=votre-mot-de-passe-app
|
||||||
|
```
|
||||||
|
|
||||||
|
Valeurs automatiques appliquées :
|
||||||
|
|
||||||
|
- `SMTP_HOST=smtp.gmail.com`
|
||||||
|
- `SMTP_PORT=465`
|
||||||
|
- `SMTP_SECURE=true`
|
||||||
|
|
||||||
|
Important : pour Gmail, utilise un **mot de passe d'application**.
|
||||||
|
|
||||||
|
## 3. Outlook / Microsoft 365
|
||||||
|
|
||||||
|
```env
|
||||||
|
SMTP_PROVIDER=outlook
|
||||||
|
SMTP_FROM=notifications@votre-domaine.fr
|
||||||
|
SMTP_USER=notifications@votre-domaine.fr
|
||||||
|
SMTP_PASS=votre-mot-de-passe-ou-mot-de-passe-app
|
||||||
|
```
|
||||||
|
|
||||||
|
Valeurs automatiques appliquées :
|
||||||
|
|
||||||
|
- `SMTP_HOST=smtp.office365.com`
|
||||||
|
- `SMTP_PORT=587`
|
||||||
|
- `SMTP_REQUIRE_TLS=true`
|
||||||
|
|
||||||
|
## 4. SMTP personnalisé
|
||||||
|
|
||||||
|
```env
|
||||||
|
SMTP_PROVIDER=custom
|
||||||
|
SMTP_HOST=mail.votre-serveur.fr
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_FROM=notifications@votre-domaine.fr
|
||||||
|
SMTP_USER=notifications@votre-domaine.fr
|
||||||
|
SMTP_PASS=votre-mot-de-passe
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_REQUIRE_TLS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes utiles
|
||||||
|
|
||||||
|
- Un seul fournisseur est actif par environnement à la fois.
|
||||||
|
- `SMTP_FROM` reste obligatoire.
|
||||||
|
- Si `SMTP_USER` est renseigné, `SMTP_PASS` doit l'être aussi.
|
||||||
|
- Le portail n'enverra pas d'email tant que la configuration restera incomplète.
|
||||||
359
cahier-des-charges-techniques-ccds.md
Normal file
359
cahier-des-charges-techniques-ccds.md
Normal file
|
|
@ -0,0 +1,359 @@
|
||||||
|
# Cahier des Charges Techniques & Specifications Fonctionnelles
|
||||||
|
|
||||||
|
## Projet
|
||||||
|
|
||||||
|
**Systeme d'Information et de Logistique Evenementielle Interconnecte**
|
||||||
|
**Client :** Communaute de Communes Des Savanes (CCDS)
|
||||||
|
|
||||||
|
## 1. Architecture technique & configuration globale
|
||||||
|
|
||||||
|
### 1.1 Exigences environnementales & compatibilite
|
||||||
|
|
||||||
|
Le systeme doit fonctionner comme une plateforme web complete, sans imposer l'installation d'une application native externe aux agents techniques ou aux usagers.
|
||||||
|
|
||||||
|
Exigences :
|
||||||
|
|
||||||
|
- aucune application PWA, APK ou iOS dediee ne doit etre obligatoire ;
|
||||||
|
- toutes les interactions doivent etre possibles depuis un navigateur web standard ;
|
||||||
|
- l'interface d'administration doit etre optimisee pour bureau et tablette ;
|
||||||
|
- l'interface terrain / restitution doit etre concue en approche mobile-first pour des ecrans de 4 a 7 pouces ;
|
||||||
|
- les emails emis par la plateforme doivent etre compatibles avec Outlook, Gmail, Apple Mail, Yahoo et les principaux webmails.
|
||||||
|
|
||||||
|
### 1.2 Gestion de la securite, roles et permissions (RBAC)
|
||||||
|
|
||||||
|
La base de donnees et la logique applicative doivent implementer strictement trois niveaux d'acces.
|
||||||
|
|
||||||
|
#### Super-administrateur
|
||||||
|
|
||||||
|
- acces complet en lecture / ecriture sur l'ensemble du systeme ;
|
||||||
|
- seul role autorise a creer, suspendre ou supprimer des comptes ;
|
||||||
|
- seul role autorise a configurer l'inventaire materiel global ;
|
||||||
|
- seul role autorise a modifier les variables de pilotage du systeme ;
|
||||||
|
- seul role autorise a definir les stocks plafonds, les valeurs de remplacement et les regles sensibles.
|
||||||
|
|
||||||
|
#### Administrateur
|
||||||
|
|
||||||
|
- instruction complete des demandes associations ;
|
||||||
|
- validation / refus des dossiers ;
|
||||||
|
- affectation des services et agents techniques ;
|
||||||
|
- pilotage du calendrier, de la logistique et des retours ;
|
||||||
|
- traitement des dossiers de cautionnement et d'arbitrage en cas de litige ;
|
||||||
|
- impossibilite de modifier la structure de base de l'inventaire ou les comptes utilisateurs.
|
||||||
|
|
||||||
|
#### Utilisateur simple / agent de terrain
|
||||||
|
|
||||||
|
- acces limite a ses missions affectees ;
|
||||||
|
- interface mobile epuree ;
|
||||||
|
- consultation et cloture de ses seules missions logistiques ;
|
||||||
|
- absence d'acces aux modules globaux d'administration, stock, comptes, statistiques et reglage.
|
||||||
|
|
||||||
|
### 1.3 Journal d'audit inviolable
|
||||||
|
|
||||||
|
Le systeme doit integrer un journal d'audit en mode **append-only** :
|
||||||
|
|
||||||
|
- aucune modification d'une ligne d'audit ne doit etre possible ;
|
||||||
|
- aucune suppression d'une ligne d'audit ne doit etre possible, y compris pour le super-administrateur ;
|
||||||
|
- les ecritures doivent etre ajoutees chronologiquement uniquement.
|
||||||
|
|
||||||
|
Chaque enregistrement doit contenir au minimum :
|
||||||
|
|
||||||
|
- `timestamp` : date et heure issues exclusivement de l'horloge serveur ;
|
||||||
|
- `user_id` et `user_role` ;
|
||||||
|
- `action_type` ;
|
||||||
|
- `payload` lisible en langage clair ;
|
||||||
|
- `ip_address`.
|
||||||
|
|
||||||
|
Exemple attendu :
|
||||||
|
|
||||||
|
`Le 18/05 à 14h22, l'Admin X a accepté la demande #412`
|
||||||
|
|
||||||
|
## 2. Specifications des modules back-office
|
||||||
|
|
||||||
|
### 2.1 Tableau de bord operationnel
|
||||||
|
|
||||||
|
Le tableau de bord est l'ecran d'accueil par defaut apres authentification administrative.
|
||||||
|
|
||||||
|
Il doit fonctionner comme un centre de decision et se mettre a jour de facon dynamique sans rechargement complet de page.
|
||||||
|
|
||||||
|
Composants attendus :
|
||||||
|
|
||||||
|
#### Bandeau d'alertes logistiques
|
||||||
|
|
||||||
|
- **Alerte rouge** : nombre de restitutions en retard
|
||||||
|
Condition : `date actuelle > date restitution + 3 jours`
|
||||||
|
- **Alerte orange** : nombre de litiges / degradations non arbitres
|
||||||
|
- **Badge bleu** : nouvelles demandes en attente d'instruction
|
||||||
|
|
||||||
|
Chaque indicateur doit etre cliquable et appliquer un filtre direct sur les dossiers concernes.
|
||||||
|
|
||||||
|
#### Flux operationnel journalier
|
||||||
|
|
||||||
|
Deux colonnes :
|
||||||
|
|
||||||
|
- **Departs / matin** : mises a disposition ou livraisons du jour
|
||||||
|
- **Retours / apres-midi** : restitutions attendues au jour J
|
||||||
|
|
||||||
|
Chaque ligne doit faire apparaitre :
|
||||||
|
|
||||||
|
- l'association ;
|
||||||
|
- le type de mission ;
|
||||||
|
- le statut d'assignation ;
|
||||||
|
- un acces rapide au dossier.
|
||||||
|
|
||||||
|
#### Composant de visualisation des stocks
|
||||||
|
|
||||||
|
- graphique donut ou barres ;
|
||||||
|
- quantites disponibles / engagees / bloquees ;
|
||||||
|
- lecture simple par categorie de materiel.
|
||||||
|
|
||||||
|
#### Fil d'activite
|
||||||
|
|
||||||
|
- affichage des 5 derniers evenements du journal d'audit ;
|
||||||
|
- lecture chronologique claire ;
|
||||||
|
- acces au journal complet.
|
||||||
|
|
||||||
|
### 2.2 Module unifie "Dossier & assignation logistique"
|
||||||
|
|
||||||
|
Le systeme doit supprimer les blocages de navigation entre consultation et action.
|
||||||
|
|
||||||
|
#### Depuis la fiche dossier
|
||||||
|
|
||||||
|
La fiche complete d'une demande acceptee doit integrer un bloc :
|
||||||
|
|
||||||
|
`Assigner un service recuperateur`
|
||||||
|
|
||||||
|
Ce bloc doit permettre :
|
||||||
|
|
||||||
|
- de choisir un service ou agent via un menu relie a l'annuaire interne ;
|
||||||
|
- de valider l'assignation sans quitter la fiche ;
|
||||||
|
- de declencher le workflow de terrain.
|
||||||
|
|
||||||
|
#### Depuis l'ecran d'assignation
|
||||||
|
|
||||||
|
Un bouton secondaire :
|
||||||
|
|
||||||
|
`Voir la demande`
|
||||||
|
|
||||||
|
doit ouvrir un panneau lateral ou une drawer affichant :
|
||||||
|
|
||||||
|
- details de l'association ;
|
||||||
|
- liste complete du materiel ;
|
||||||
|
- dates ;
|
||||||
|
- lieu / manifestation ;
|
||||||
|
- informations utiles a la decision.
|
||||||
|
|
||||||
|
### 2.3 Calendrier logistique predictif
|
||||||
|
|
||||||
|
Le calendrier doit etre relie a l'inventaire reel.
|
||||||
|
|
||||||
|
#### Regle d'indexation stock
|
||||||
|
|
||||||
|
Lorsqu'une demande est validee sur une periode donnee :
|
||||||
|
|
||||||
|
- les quantites reservees doivent etre deduites du stock global ;
|
||||||
|
- cette deduction ne s'applique que sur la plage temporelle concernee.
|
||||||
|
|
||||||
|
#### Affichage visuel attendu
|
||||||
|
|
||||||
|
Chaque dossier doit produire trois phases visibles :
|
||||||
|
|
||||||
|
- **Sortie / livraison** : bleu
|
||||||
|
- **Manifestation / indisponibilite stock** : phase d'utilisation
|
||||||
|
- **Alerte restitution** : jaune
|
||||||
|
|
||||||
|
#### Interactions attendues
|
||||||
|
|
||||||
|
Au clic sur un evenement du calendrier :
|
||||||
|
|
||||||
|
- consulter l'agent ou service affecte ;
|
||||||
|
- reassigner la mission ;
|
||||||
|
- telecharger le PDF finalise si disponible.
|
||||||
|
|
||||||
|
### 2.4 Cartographie interactive & implantation territoriale
|
||||||
|
|
||||||
|
La cartographie doit etre connectee a la base de donnees et servir a la fois le grand public et l'administration.
|
||||||
|
|
||||||
|
Technologies recommandees :
|
||||||
|
|
||||||
|
- Leaflet.js
|
||||||
|
- ou Mapbox GL JS
|
||||||
|
|
||||||
|
Traitements attendus :
|
||||||
|
|
||||||
|
- geocodage automatique des adresses via `api-adresse.data.gouv.fr` ou `Nominatim`
|
||||||
|
- stockage des coordonnees geographiques
|
||||||
|
- clustering obligatoire pour eviter la surcharge visuelle
|
||||||
|
|
||||||
|
Filtres attendus :
|
||||||
|
|
||||||
|
- annuaire public par thematique ;
|
||||||
|
- vue logistique admin ;
|
||||||
|
- heatmap / densite ;
|
||||||
|
- filtres territoriaux sur Iracoubo, Kourou, Saint-Elie, Sinnamary.
|
||||||
|
|
||||||
|
## 3. Specifications du workflow mobile terrain
|
||||||
|
|
||||||
|
### 3.1 Declenchement & routage
|
||||||
|
|
||||||
|
Un traitement planifie quotidien doit verifier les dossiers.
|
||||||
|
|
||||||
|
Pour tout dossier dont la restitution theorique est a **J-4** :
|
||||||
|
|
||||||
|
- generer un jeton d'acces securise temporaire ;
|
||||||
|
- construire une URL unique ;
|
||||||
|
- injecter cette URL dans l'email du service ou agent affecte.
|
||||||
|
|
||||||
|
Exemple de forme attendue :
|
||||||
|
|
||||||
|
`https://ccds.fr/restitution/v1?token=sha256_unique_hash`
|
||||||
|
|
||||||
|
### 3.2 Interface mobile & gestion non bloquante
|
||||||
|
|
||||||
|
Lors de l'ouverture sur smartphone :
|
||||||
|
|
||||||
|
- les donnees de mise a disposition sont pre-remplies et figees ;
|
||||||
|
- l'agent ne modifie pas les donnees source de la demande ;
|
||||||
|
- le constat terrain se fait sur l'etat reel de restitution.
|
||||||
|
|
||||||
|
#### Bloc "Restitution de materiel"
|
||||||
|
|
||||||
|
Boutons radio exclusifs :
|
||||||
|
|
||||||
|
- `Conforme`
|
||||||
|
- `Non conforme / Partiel`
|
||||||
|
|
||||||
|
Comportement attendu :
|
||||||
|
|
||||||
|
- `Conforme` valide globalement le retour ;
|
||||||
|
- `Non conforme / Partiel` active obligatoirement le bloc observations.
|
||||||
|
|
||||||
|
#### Bloc "Observations contradictoires"
|
||||||
|
|
||||||
|
Cases a cocher :
|
||||||
|
|
||||||
|
- equipement manquant
|
||||||
|
- accessoires / visserie manquants
|
||||||
|
- armature deformee
|
||||||
|
- toile / bache dechiree
|
||||||
|
- impact / choc majeur
|
||||||
|
- restitution sale
|
||||||
|
- materiel humide
|
||||||
|
|
||||||
|
Des qu'une case est cochee :
|
||||||
|
|
||||||
|
- le champ texte libre devient obligatoire.
|
||||||
|
|
||||||
|
### 3.3 Double signature, horodatage et resilience GPS
|
||||||
|
|
||||||
|
#### Horodatage certifie
|
||||||
|
|
||||||
|
Le backend doit ecraser toute date / heure issue de l'appareil.
|
||||||
|
|
||||||
|
La date officielle est generee exclusivement par le serveur au moment de la soumission.
|
||||||
|
|
||||||
|
#### Gestion robuste de la geolocalisation
|
||||||
|
|
||||||
|
Le script `navigator.geolocation` doit :
|
||||||
|
|
||||||
|
- fonctionner en asynchrone ;
|
||||||
|
- comporter un timeout strict de `5000 ms` ;
|
||||||
|
- ne jamais bloquer la validation finale.
|
||||||
|
|
||||||
|
Scenarios :
|
||||||
|
|
||||||
|
- **Succes** : les coordonnees sont jointes a la soumission
|
||||||
|
- **Echec** : la valeur `Non disponible` est enregistree, mais la validation continue normalement
|
||||||
|
|
||||||
|
#### Signature tactile
|
||||||
|
|
||||||
|
Le systeme doit integrer :
|
||||||
|
|
||||||
|
- une zone de signature pour l'agent ;
|
||||||
|
- une zone de signature pour l'emprunteur ;
|
||||||
|
- les qualites associees ;
|
||||||
|
- un rendu suffisamment fluide pour un usage doigt / stylet.
|
||||||
|
|
||||||
|
### 3.4 Generation dynamique du PDF & archivage
|
||||||
|
|
||||||
|
Apres validation finale :
|
||||||
|
|
||||||
|
- le serveur compile le document officiel ;
|
||||||
|
- fusionne les donnees, signatures et metadonnees ;
|
||||||
|
- applique le logo CCDS ;
|
||||||
|
- produit un PDF A4 sur une page si le contenu le permet.
|
||||||
|
|
||||||
|
Contraintes de sortie :
|
||||||
|
|
||||||
|
- CSS imprimee calibree ;
|
||||||
|
- signatures visibles ;
|
||||||
|
- mise en page stable ;
|
||||||
|
- envoi automatique en piece jointe :
|
||||||
|
- a l'association ;
|
||||||
|
- a la DSU ;
|
||||||
|
- mise a jour du statut en base.
|
||||||
|
|
||||||
|
## 4. Protocole expeditif automatise : gestion des litiges
|
||||||
|
|
||||||
|
Si la restitution est saisie comme `Non conforme / Partiel` ou `Mauvais etat` :
|
||||||
|
|
||||||
|
### 4.1 Rupture de flux & gel logistique
|
||||||
|
|
||||||
|
Le dossier passe en :
|
||||||
|
|
||||||
|
`LITIGE / ATTENTE ARBITRAGE`
|
||||||
|
|
||||||
|
Le materiel concerne doit etre marque indisponible pour :
|
||||||
|
|
||||||
|
- bloquer son retour dans le calendrier ;
|
||||||
|
- empecher sa relocation par erreur.
|
||||||
|
|
||||||
|
### 4.2 Interface d'arbitrage financier
|
||||||
|
|
||||||
|
La fiche admin du dossier doit permettre :
|
||||||
|
|
||||||
|
- de saisir le cout d'un devis ;
|
||||||
|
- de saisir un cout interne ;
|
||||||
|
- ou d'appliquer la valeur de remplacement a neuf issue de l'inventaire.
|
||||||
|
|
||||||
|
### 4.3 Notification & cloture
|
||||||
|
|
||||||
|
Le systeme doit proposer :
|
||||||
|
|
||||||
|
- un bouton `Generer courrier de litige`
|
||||||
|
- un document pre-rempli pour notifier l'association
|
||||||
|
|
||||||
|
Le dossier ne peut etre cloture definitivement qu'apres arbitrage explicite parmi :
|
||||||
|
|
||||||
|
- retenue partielle sur caution
|
||||||
|
- encaissement total
|
||||||
|
- classement sans suite
|
||||||
|
|
||||||
|
La decision doit :
|
||||||
|
|
||||||
|
- mettre a jour le stock ;
|
||||||
|
- liberer ou non le materiel ;
|
||||||
|
- etre ecrite dans le journal d'audit.
|
||||||
|
|
||||||
|
## 5. Vision d'ensemble
|
||||||
|
|
||||||
|
La plateforme doit etre comprise comme un **ecosysteme logistique connecte et predictif**.
|
||||||
|
|
||||||
|
Elle doit permettre :
|
||||||
|
|
||||||
|
- de centraliser la gestion associative ;
|
||||||
|
- de piloter le materiel dans le temps ;
|
||||||
|
- de fluidifier les workflows terrain ;
|
||||||
|
- de proteger juridiquement et financierement la collectivite ;
|
||||||
|
- d'offrir a la direction un vrai centre de controle.
|
||||||
|
|
||||||
|
## 6. Resultat attendu
|
||||||
|
|
||||||
|
Le systeme final ne doit pas etre un simple outil de gestion administrative.
|
||||||
|
|
||||||
|
Il doit devenir :
|
||||||
|
|
||||||
|
- une plateforme de service public numerique ;
|
||||||
|
- un centre de pilotage logistique ;
|
||||||
|
- un outil de tracabilite ;
|
||||||
|
- un support de decision ;
|
||||||
|
- et un cadre fiable pour l'arbitrage, le stock et les operations terrain.
|
||||||
23
client/index.html
Normal file
23
client/index.html
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="fr" translate="no" class="notranslate">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, maximum-scale=1" />
|
||||||
|
<meta name="google" content="notranslate" />
|
||||||
|
<title>Portail Associations</title>
|
||||||
|
<!-- THIS IS THE START OF A COMMENT BLOCK, BLOCK TO BE DELETED: Google Fonts here, example:
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
THIS IS THE END OF A COMMENT BLOCK, BLOCK TO BE DELETED -->
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="notranslate" translate="no">
|
||||||
|
<div id="root" class="notranslate" translate="no"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
0
client/public/.gitkeep
Normal file
0
client/public/.gitkeep
Normal file
821
client/public/__manus__/debug-collector.js
Normal file
821
client/public/__manus__/debug-collector.js
Normal file
|
|
@ -0,0 +1,821 @@
|
||||||
|
/**
|
||||||
|
* Manus Debug Collector (agent-friendly)
|
||||||
|
*
|
||||||
|
* Captures:
|
||||||
|
* 1) Console logs
|
||||||
|
* 2) Network requests (fetch + XHR)
|
||||||
|
* 3) User interactions (semantic uiEvents: click/type/submit/nav/scroll/etc.)
|
||||||
|
*
|
||||||
|
* Data is periodically sent to /__manus__/logs
|
||||||
|
* Note: uiEvents are mirrored to sessionEvents for sessionReplay.log
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Prevent double initialization
|
||||||
|
if (window.__MANUS_DEBUG_COLLECTOR__) return;
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Configuration
|
||||||
|
// ==========================================================================
|
||||||
|
const CONFIG = {
|
||||||
|
reportEndpoint: "/__manus__/logs",
|
||||||
|
bufferSize: {
|
||||||
|
console: 500,
|
||||||
|
network: 200,
|
||||||
|
// semantic, agent-friendly UI events
|
||||||
|
ui: 500,
|
||||||
|
},
|
||||||
|
reportInterval: 2000,
|
||||||
|
sensitiveFields: [
|
||||||
|
"password",
|
||||||
|
"token",
|
||||||
|
"secret",
|
||||||
|
"key",
|
||||||
|
"authorization",
|
||||||
|
"cookie",
|
||||||
|
"session",
|
||||||
|
],
|
||||||
|
maxBodyLength: 10240,
|
||||||
|
// UI event logging privacy policy:
|
||||||
|
// - inputs matching sensitiveFields or type=password are masked by default
|
||||||
|
// - non-sensitive inputs log up to 200 chars
|
||||||
|
uiInputMaxLen: 200,
|
||||||
|
uiTextMaxLen: 80,
|
||||||
|
// Scroll throttling: minimum ms between scroll events
|
||||||
|
scrollThrottleMs: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Storage
|
||||||
|
// ==========================================================================
|
||||||
|
const store = {
|
||||||
|
consoleLogs: [],
|
||||||
|
networkRequests: [],
|
||||||
|
uiEvents: [],
|
||||||
|
lastReportTime: Date.now(),
|
||||||
|
lastScrollTime: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Utility Functions
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
function sanitizeValue(value, depth) {
|
||||||
|
if (depth === void 0) depth = 0;
|
||||||
|
if (depth > 5) return "[Max Depth]";
|
||||||
|
if (value === null) return null;
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.length > 1000 ? value.slice(0, 1000) + "...[truncated]" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== "object") return value;
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.slice(0, 100).map(function (v) {
|
||||||
|
return sanitizeValue(v, depth + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var sanitized = {};
|
||||||
|
for (var k in value) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(value, k)) {
|
||||||
|
var isSensitive = CONFIG.sensitiveFields.some(function (f) {
|
||||||
|
return k.toLowerCase().indexOf(f) !== -1;
|
||||||
|
});
|
||||||
|
if (isSensitive) {
|
||||||
|
sanitized[k] = "[REDACTED]";
|
||||||
|
} else {
|
||||||
|
sanitized[k] = sanitizeValue(value[k], depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatArg(arg) {
|
||||||
|
try {
|
||||||
|
if (arg instanceof Error) {
|
||||||
|
return { type: "Error", message: arg.message, stack: arg.stack };
|
||||||
|
}
|
||||||
|
if (typeof arg === "object") return sanitizeValue(arg);
|
||||||
|
return String(arg);
|
||||||
|
} catch (e) {
|
||||||
|
return "[Unserializable]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatArgs(args) {
|
||||||
|
var result = [];
|
||||||
|
for (var i = 0; i < args.length; i++) result.push(formatArg(args[i]));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneBuffer(buffer, maxSize) {
|
||||||
|
if (buffer.length > maxSize) buffer.splice(0, buffer.length - maxSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseJson(str) {
|
||||||
|
if (typeof str !== "string") return str;
|
||||||
|
try {
|
||||||
|
return JSON.parse(str);
|
||||||
|
} catch (e) {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Semantic UI Event Logging (agent-friendly)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
function shouldIgnoreTarget(target) {
|
||||||
|
try {
|
||||||
|
if (!target || !(target instanceof Element)) return false;
|
||||||
|
return !!target.closest(".manus-no-record");
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactText(s, maxLen) {
|
||||||
|
try {
|
||||||
|
var t = (s || "").trim().replace(/\s+/g, " ");
|
||||||
|
if (!t) return "";
|
||||||
|
return t.length > maxLen ? t.slice(0, maxLen) + "…" : t;
|
||||||
|
} catch (e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function elText(el) {
|
||||||
|
try {
|
||||||
|
var t = el.innerText || el.textContent || "";
|
||||||
|
return compactText(t, CONFIG.uiTextMaxLen);
|
||||||
|
} catch (e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeElement(el) {
|
||||||
|
if (!el || !(el instanceof Element)) return null;
|
||||||
|
|
||||||
|
var getAttr = function (name) {
|
||||||
|
return el.getAttribute(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
var tag = el.tagName ? el.tagName.toLowerCase() : null;
|
||||||
|
var id = el.id || null;
|
||||||
|
var name = getAttr("name") || null;
|
||||||
|
var role = getAttr("role") || null;
|
||||||
|
var ariaLabel = getAttr("aria-label") || null;
|
||||||
|
|
||||||
|
var dataLoc = getAttr("data-loc") || null;
|
||||||
|
var testId =
|
||||||
|
getAttr("data-testid") ||
|
||||||
|
getAttr("data-test-id") ||
|
||||||
|
getAttr("data-test") ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
var type = tag === "input" ? (getAttr("type") || "text") : null;
|
||||||
|
var href = tag === "a" ? getAttr("href") || null : null;
|
||||||
|
|
||||||
|
// a small, stable hint for agents (avoid building full CSS paths)
|
||||||
|
var selectorHint = null;
|
||||||
|
if (testId) selectorHint = '[data-testid="' + testId + '"]';
|
||||||
|
else if (dataLoc) selectorHint = '[data-loc="' + dataLoc + '"]';
|
||||||
|
else if (id) selectorHint = "#" + id;
|
||||||
|
else selectorHint = tag || "unknown";
|
||||||
|
|
||||||
|
return {
|
||||||
|
tag: tag,
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
type: type,
|
||||||
|
role: role,
|
||||||
|
ariaLabel: ariaLabel,
|
||||||
|
testId: testId,
|
||||||
|
dataLoc: dataLoc,
|
||||||
|
href: href,
|
||||||
|
text: elText(el),
|
||||||
|
selectorHint: selectorHint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSensitiveField(el) {
|
||||||
|
if (!el || !(el instanceof Element)) return false;
|
||||||
|
var tag = el.tagName ? el.tagName.toLowerCase() : "";
|
||||||
|
if (tag !== "input" && tag !== "textarea") return false;
|
||||||
|
|
||||||
|
var type = (el.getAttribute("type") || "").toLowerCase();
|
||||||
|
if (type === "password") return true;
|
||||||
|
|
||||||
|
var name = (el.getAttribute("name") || "").toLowerCase();
|
||||||
|
var id = (el.id || "").toLowerCase();
|
||||||
|
|
||||||
|
return CONFIG.sensitiveFields.some(function (f) {
|
||||||
|
return name.indexOf(f) !== -1 || id.indexOf(f) !== -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInputValueSafe(el) {
|
||||||
|
if (!el || !(el instanceof Element)) return null;
|
||||||
|
var tag = el.tagName ? el.tagName.toLowerCase() : "";
|
||||||
|
if (tag !== "input" && tag !== "textarea" && tag !== "select") return null;
|
||||||
|
|
||||||
|
var v = "";
|
||||||
|
try {
|
||||||
|
v = el.value != null ? String(el.value) : "";
|
||||||
|
} catch (e) {
|
||||||
|
v = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSensitiveField(el)) return { masked: true, length: v.length };
|
||||||
|
|
||||||
|
if (v.length > CONFIG.uiInputMaxLen) v = v.slice(0, CONFIG.uiInputMaxLen) + "…";
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logUiEvent(kind, payload) {
|
||||||
|
var entry = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
kind: kind,
|
||||||
|
url: location.href,
|
||||||
|
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||||
|
payload: sanitizeValue(payload),
|
||||||
|
};
|
||||||
|
store.uiEvents.push(entry);
|
||||||
|
pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
function installUiEventListeners() {
|
||||||
|
// Clicks
|
||||||
|
document.addEventListener(
|
||||||
|
"click",
|
||||||
|
function (e) {
|
||||||
|
var t = e.target;
|
||||||
|
if (shouldIgnoreTarget(t)) return;
|
||||||
|
logUiEvent("click", {
|
||||||
|
target: describeElement(t),
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Typing "commit" events
|
||||||
|
document.addEventListener(
|
||||||
|
"change",
|
||||||
|
function (e) {
|
||||||
|
var t = e.target;
|
||||||
|
if (shouldIgnoreTarget(t)) return;
|
||||||
|
logUiEvent("change", {
|
||||||
|
target: describeElement(t),
|
||||||
|
value: getInputValueSafe(t),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
"focusin",
|
||||||
|
function (e) {
|
||||||
|
var t = e.target;
|
||||||
|
if (shouldIgnoreTarget(t)) return;
|
||||||
|
logUiEvent("focusin", { target: describeElement(t) });
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
"focusout",
|
||||||
|
function (e) {
|
||||||
|
var t = e.target;
|
||||||
|
if (shouldIgnoreTarget(t)) return;
|
||||||
|
logUiEvent("focusout", {
|
||||||
|
target: describeElement(t),
|
||||||
|
value: getInputValueSafe(t),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enter/Escape are useful for form flows & modals
|
||||||
|
document.addEventListener(
|
||||||
|
"keydown",
|
||||||
|
function (e) {
|
||||||
|
if (e.key !== "Enter" && e.key !== "Escape") return;
|
||||||
|
var t = e.target;
|
||||||
|
if (shouldIgnoreTarget(t)) return;
|
||||||
|
logUiEvent("keydown", { key: e.key, target: describeElement(t) });
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Form submissions
|
||||||
|
document.addEventListener(
|
||||||
|
"submit",
|
||||||
|
function (e) {
|
||||||
|
var t = e.target;
|
||||||
|
if (shouldIgnoreTarget(t)) return;
|
||||||
|
logUiEvent("submit", { target: describeElement(t) });
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Throttled scroll events
|
||||||
|
window.addEventListener(
|
||||||
|
"scroll",
|
||||||
|
function () {
|
||||||
|
var now = Date.now();
|
||||||
|
if (now - store.lastScrollTime < CONFIG.scrollThrottleMs) return;
|
||||||
|
store.lastScrollTime = now;
|
||||||
|
|
||||||
|
logUiEvent("scroll", {
|
||||||
|
scrollX: window.scrollX,
|
||||||
|
scrollY: window.scrollY,
|
||||||
|
documentHeight: document.documentElement.scrollHeight,
|
||||||
|
viewportHeight: window.innerHeight,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ passive: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigation tracking for SPAs
|
||||||
|
function nav(reason) {
|
||||||
|
logUiEvent("navigate", { reason: reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
var origPush = history.pushState;
|
||||||
|
history.pushState = function () {
|
||||||
|
origPush.apply(this, arguments);
|
||||||
|
nav("pushState");
|
||||||
|
};
|
||||||
|
|
||||||
|
var origReplace = history.replaceState;
|
||||||
|
history.replaceState = function () {
|
||||||
|
origReplace.apply(this, arguments);
|
||||||
|
nav("replaceState");
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("popstate", function () {
|
||||||
|
nav("popstate");
|
||||||
|
});
|
||||||
|
window.addEventListener("hashchange", function () {
|
||||||
|
nav("hashchange");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Console Interception
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
var originalConsole = {
|
||||||
|
log: console.log.bind(console),
|
||||||
|
debug: console.debug.bind(console),
|
||||||
|
info: console.info.bind(console),
|
||||||
|
warn: console.warn.bind(console),
|
||||||
|
error: console.error.bind(console),
|
||||||
|
};
|
||||||
|
|
||||||
|
["log", "debug", "info", "warn", "error"].forEach(function (method) {
|
||||||
|
console[method] = function () {
|
||||||
|
var args = Array.prototype.slice.call(arguments);
|
||||||
|
|
||||||
|
var entry = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
level: method.toUpperCase(),
|
||||||
|
args: formatArgs(args),
|
||||||
|
stack: method === "error" ? new Error().stack : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
store.consoleLogs.push(entry);
|
||||||
|
pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console);
|
||||||
|
|
||||||
|
originalConsole[method].apply(console, args);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("error", function (event) {
|
||||||
|
store.consoleLogs.push({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
level: "ERROR",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
type: "UncaughtError",
|
||||||
|
message: event.message,
|
||||||
|
filename: event.filename,
|
||||||
|
lineno: event.lineno,
|
||||||
|
colno: event.colno,
|
||||||
|
stack: event.error ? event.error.stack : null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stack: event.error ? event.error.stack : null,
|
||||||
|
});
|
||||||
|
pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console);
|
||||||
|
|
||||||
|
// Mark an error moment in UI event stream for agents
|
||||||
|
logUiEvent("error", {
|
||||||
|
message: event.message,
|
||||||
|
filename: event.filename,
|
||||||
|
lineno: event.lineno,
|
||||||
|
colno: event.colno,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("unhandledrejection", function (event) {
|
||||||
|
var reason = event.reason;
|
||||||
|
store.consoleLogs.push({
|
||||||
|
timestamp: Date.now(),
|
||||||
|
level: "ERROR",
|
||||||
|
args: [
|
||||||
|
{
|
||||||
|
type: "UnhandledRejection",
|
||||||
|
reason: reason && reason.message ? reason.message : String(reason),
|
||||||
|
stack: reason && reason.stack ? reason.stack : null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stack: reason && reason.stack ? reason.stack : null,
|
||||||
|
});
|
||||||
|
pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console);
|
||||||
|
|
||||||
|
logUiEvent("unhandledrejection", {
|
||||||
|
reason: reason && reason.message ? reason.message : String(reason),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Fetch Interception
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
var originalFetch = window.fetch.bind(window);
|
||||||
|
|
||||||
|
window.fetch = function (input, init) {
|
||||||
|
init = init || {};
|
||||||
|
var startTime = Date.now();
|
||||||
|
// Handle string, Request object, or URL object
|
||||||
|
var url = typeof input === "string"
|
||||||
|
? input
|
||||||
|
: (input && (input.url || input.href || String(input))) || "";
|
||||||
|
var method = init.method || (input && input.method) || "GET";
|
||||||
|
|
||||||
|
// Don't intercept internal requests
|
||||||
|
if (url.indexOf("/__manus__/") === 0) {
|
||||||
|
return originalFetch(input, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safely parse headers (avoid breaking if headers format is invalid)
|
||||||
|
var requestHeaders = {};
|
||||||
|
try {
|
||||||
|
if (init.headers) {
|
||||||
|
requestHeaders = Object.fromEntries(new Headers(init.headers).entries());
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
requestHeaders = { _parseError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = {
|
||||||
|
timestamp: startTime,
|
||||||
|
type: "fetch",
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
url: url,
|
||||||
|
request: {
|
||||||
|
headers: requestHeaders,
|
||||||
|
body: init.body ? sanitizeValue(tryParseJson(init.body)) : null,
|
||||||
|
},
|
||||||
|
response: null,
|
||||||
|
duration: null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return originalFetch(input, init)
|
||||||
|
.then(function (response) {
|
||||||
|
entry.duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
var contentType = (response.headers.get("content-type") || "").toLowerCase();
|
||||||
|
var contentLength = response.headers.get("content-length");
|
||||||
|
|
||||||
|
entry.response = {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: Object.fromEntries(response.headers.entries()),
|
||||||
|
body: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Semantic network hint for agents on failures (sync, no need to wait for body)
|
||||||
|
if (response.status >= 400) {
|
||||||
|
logUiEvent("network_error", {
|
||||||
|
kind: "fetch",
|
||||||
|
method: entry.method,
|
||||||
|
url: entry.url,
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip body capture for streaming responses (SSE, etc.) to avoid memory leaks
|
||||||
|
var isStreaming = contentType.indexOf("text/event-stream") !== -1 ||
|
||||||
|
contentType.indexOf("application/stream") !== -1 ||
|
||||||
|
contentType.indexOf("application/x-ndjson") !== -1;
|
||||||
|
if (isStreaming) {
|
||||||
|
entry.response.body = "[Streaming response - not captured]";
|
||||||
|
store.networkRequests.push(entry);
|
||||||
|
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip body capture for large responses to avoid memory issues
|
||||||
|
if (contentLength && parseInt(contentLength, 10) > CONFIG.maxBodyLength) {
|
||||||
|
entry.response.body = "[Response too large: " + contentLength + " bytes]";
|
||||||
|
store.networkRequests.push(entry);
|
||||||
|
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip body capture for binary content types
|
||||||
|
var isBinary = contentType.indexOf("image/") !== -1 ||
|
||||||
|
contentType.indexOf("video/") !== -1 ||
|
||||||
|
contentType.indexOf("audio/") !== -1 ||
|
||||||
|
contentType.indexOf("application/octet-stream") !== -1 ||
|
||||||
|
contentType.indexOf("application/pdf") !== -1 ||
|
||||||
|
contentType.indexOf("application/zip") !== -1;
|
||||||
|
if (isBinary) {
|
||||||
|
entry.response.body = "[Binary content: " + contentType + "]";
|
||||||
|
store.networkRequests.push(entry);
|
||||||
|
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For text responses, clone and read body in background
|
||||||
|
var clonedResponse = response.clone();
|
||||||
|
|
||||||
|
// Async: read body in background, don't block the response
|
||||||
|
clonedResponse
|
||||||
|
.text()
|
||||||
|
.then(function (text) {
|
||||||
|
if (text.length <= CONFIG.maxBodyLength) {
|
||||||
|
entry.response.body = sanitizeValue(tryParseJson(text));
|
||||||
|
} else {
|
||||||
|
entry.response.body = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
entry.response.body = "[Unable to read body]";
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
store.networkRequests.push(entry);
|
||||||
|
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return response immediately, don't wait for body reading
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
entry.duration = Date.now() - startTime;
|
||||||
|
entry.error = { message: error.message, stack: error.stack };
|
||||||
|
|
||||||
|
store.networkRequests.push(entry);
|
||||||
|
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
|
||||||
|
|
||||||
|
logUiEvent("network_error", {
|
||||||
|
kind: "fetch",
|
||||||
|
method: entry.method,
|
||||||
|
url: entry.url,
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// XHR Interception
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
var originalXHROpen = XMLHttpRequest.prototype.open;
|
||||||
|
var originalXHRSend = XMLHttpRequest.prototype.send;
|
||||||
|
|
||||||
|
XMLHttpRequest.prototype.open = function (method, url) {
|
||||||
|
this._manusData = {
|
||||||
|
method: (method || "GET").toUpperCase(),
|
||||||
|
url: url,
|
||||||
|
startTime: null,
|
||||||
|
};
|
||||||
|
return originalXHROpen.apply(this, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
XMLHttpRequest.prototype.send = function (body) {
|
||||||
|
var xhr = this;
|
||||||
|
|
||||||
|
if (
|
||||||
|
xhr._manusData &&
|
||||||
|
xhr._manusData.url &&
|
||||||
|
xhr._manusData.url.indexOf("/__manus__/") !== 0
|
||||||
|
) {
|
||||||
|
xhr._manusData.startTime = Date.now();
|
||||||
|
xhr._manusData.requestBody = body ? sanitizeValue(tryParseJson(body)) : null;
|
||||||
|
|
||||||
|
xhr.addEventListener("load", function () {
|
||||||
|
var contentType = (xhr.getResponseHeader("content-type") || "").toLowerCase();
|
||||||
|
var responseBody = null;
|
||||||
|
|
||||||
|
// Skip body capture for streaming responses
|
||||||
|
var isStreaming = contentType.indexOf("text/event-stream") !== -1 ||
|
||||||
|
contentType.indexOf("application/stream") !== -1 ||
|
||||||
|
contentType.indexOf("application/x-ndjson") !== -1;
|
||||||
|
|
||||||
|
// Skip body capture for binary content types
|
||||||
|
var isBinary = contentType.indexOf("image/") !== -1 ||
|
||||||
|
contentType.indexOf("video/") !== -1 ||
|
||||||
|
contentType.indexOf("audio/") !== -1 ||
|
||||||
|
contentType.indexOf("application/octet-stream") !== -1 ||
|
||||||
|
contentType.indexOf("application/pdf") !== -1 ||
|
||||||
|
contentType.indexOf("application/zip") !== -1;
|
||||||
|
|
||||||
|
if (isStreaming) {
|
||||||
|
responseBody = "[Streaming response - not captured]";
|
||||||
|
} else if (isBinary) {
|
||||||
|
responseBody = "[Binary content: " + contentType + "]";
|
||||||
|
} else {
|
||||||
|
// Safe to read responseText for text responses
|
||||||
|
try {
|
||||||
|
var text = xhr.responseText || "";
|
||||||
|
if (text.length > CONFIG.maxBodyLength) {
|
||||||
|
responseBody = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]";
|
||||||
|
} else {
|
||||||
|
responseBody = sanitizeValue(tryParseJson(text));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// responseText may throw for non-text responses
|
||||||
|
responseBody = "[Unable to read response: " + e.message + "]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = {
|
||||||
|
timestamp: xhr._manusData.startTime,
|
||||||
|
type: "xhr",
|
||||||
|
method: xhr._manusData.method,
|
||||||
|
url: xhr._manusData.url,
|
||||||
|
request: { body: xhr._manusData.requestBody },
|
||||||
|
response: {
|
||||||
|
status: xhr.status,
|
||||||
|
statusText: xhr.statusText,
|
||||||
|
body: responseBody,
|
||||||
|
},
|
||||||
|
duration: Date.now() - xhr._manusData.startTime,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
store.networkRequests.push(entry);
|
||||||
|
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
|
||||||
|
|
||||||
|
if (entry.response && entry.response.status >= 400) {
|
||||||
|
logUiEvent("network_error", {
|
||||||
|
kind: "xhr",
|
||||||
|
method: entry.method,
|
||||||
|
url: entry.url,
|
||||||
|
status: entry.response.status,
|
||||||
|
statusText: entry.response.statusText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener("error", function () {
|
||||||
|
var entry = {
|
||||||
|
timestamp: xhr._manusData.startTime,
|
||||||
|
type: "xhr",
|
||||||
|
method: xhr._manusData.method,
|
||||||
|
url: xhr._manusData.url,
|
||||||
|
request: { body: xhr._manusData.requestBody },
|
||||||
|
response: null,
|
||||||
|
duration: Date.now() - xhr._manusData.startTime,
|
||||||
|
error: { message: "Network error" },
|
||||||
|
};
|
||||||
|
|
||||||
|
store.networkRequests.push(entry);
|
||||||
|
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
|
||||||
|
|
||||||
|
logUiEvent("network_error", {
|
||||||
|
kind: "xhr",
|
||||||
|
method: entry.method,
|
||||||
|
url: entry.url,
|
||||||
|
message: "Network error",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalXHRSend.apply(this, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Data Reporting
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
function reportLogs() {
|
||||||
|
var consoleLogs = store.consoleLogs.splice(0);
|
||||||
|
var networkRequests = store.networkRequests.splice(0);
|
||||||
|
var uiEvents = store.uiEvents.splice(0);
|
||||||
|
|
||||||
|
// Skip if no new data
|
||||||
|
if (
|
||||||
|
consoleLogs.length === 0 &&
|
||||||
|
networkRequests.length === 0 &&
|
||||||
|
uiEvents.length === 0
|
||||||
|
) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
consoleLogs: consoleLogs,
|
||||||
|
networkRequests: networkRequests,
|
||||||
|
// Mirror uiEvents to sessionEvents for sessionReplay.log
|
||||||
|
sessionEvents: uiEvents,
|
||||||
|
// agent-friendly semantic events
|
||||||
|
uiEvents: uiEvents,
|
||||||
|
};
|
||||||
|
|
||||||
|
return originalFetch(CONFIG.reportEndpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}).catch(function () {
|
||||||
|
// Put data back on failure (but respect limits)
|
||||||
|
store.consoleLogs = consoleLogs.concat(store.consoleLogs);
|
||||||
|
store.networkRequests = networkRequests.concat(store.networkRequests);
|
||||||
|
store.uiEvents = uiEvents.concat(store.uiEvents);
|
||||||
|
|
||||||
|
pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console);
|
||||||
|
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
|
||||||
|
pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodic reporting
|
||||||
|
setInterval(reportLogs, CONFIG.reportInterval);
|
||||||
|
|
||||||
|
// Report on page unload
|
||||||
|
window.addEventListener("beforeunload", function () {
|
||||||
|
var consoleLogs = store.consoleLogs;
|
||||||
|
var networkRequests = store.networkRequests;
|
||||||
|
var uiEvents = store.uiEvents;
|
||||||
|
|
||||||
|
if (
|
||||||
|
consoleLogs.length === 0 &&
|
||||||
|
networkRequests.length === 0 &&
|
||||||
|
uiEvents.length === 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
consoleLogs: consoleLogs,
|
||||||
|
networkRequests: networkRequests,
|
||||||
|
// Mirror uiEvents to sessionEvents for sessionReplay.log
|
||||||
|
sessionEvents: uiEvents,
|
||||||
|
uiEvents: uiEvents,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (navigator.sendBeacon) {
|
||||||
|
var payloadStr = JSON.stringify(payload);
|
||||||
|
// sendBeacon has ~64KB limit, truncate if too large
|
||||||
|
var MAX_BEACON_SIZE = 60000; // Leave some margin
|
||||||
|
if (payloadStr.length > MAX_BEACON_SIZE) {
|
||||||
|
// Prioritize: keep recent events, drop older logs
|
||||||
|
var truncatedPayload = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
consoleLogs: consoleLogs.slice(-50),
|
||||||
|
networkRequests: networkRequests.slice(-20),
|
||||||
|
sessionEvents: uiEvents.slice(-100),
|
||||||
|
uiEvents: uiEvents.slice(-100),
|
||||||
|
_truncated: true,
|
||||||
|
};
|
||||||
|
payloadStr = JSON.stringify(truncatedPayload);
|
||||||
|
}
|
||||||
|
navigator.sendBeacon(CONFIG.reportEndpoint, payloadStr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Initialization
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// Install semantic UI listeners ASAP
|
||||||
|
try {
|
||||||
|
installUiEventListeners();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[Manus] Failed to install UI listeners:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as initialized
|
||||||
|
window.__MANUS_DEBUG_COLLECTOR__ = {
|
||||||
|
version: "2.0-no-rrweb",
|
||||||
|
store: store,
|
||||||
|
forceReport: reportLogs,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.debug("[Manus] Debug collector initialized (no rrweb, UI events only)");
|
||||||
|
})();
|
||||||
4
client/public/__manus__/version.json
Normal file
4
client/public/__manus__/version.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"version": "9ae06d72",
|
||||||
|
"timestamp": 1775163491691
|
||||||
|
}
|
||||||
2375
client/public/map-styles/openmaptiles-positron-admin/style.json
Normal file
2375
client/public/map-styles/openmaptiles-positron-admin/style.json
Normal file
File diff suppressed because it is too large
Load diff
16
client/public/map-styles/openmaptiles-positron/sprite.json
Normal file
16
client/public/map-styles/openmaptiles-positron/sprite.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"circle-11": {
|
||||||
|
"height": 17,
|
||||||
|
"pixelRatio": 1,
|
||||||
|
"width": 17,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"star-11": {
|
||||||
|
"height": 17,
|
||||||
|
"pixelRatio": 1,
|
||||||
|
"width": 17,
|
||||||
|
"x": 17,
|
||||||
|
"y": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
client/public/map-styles/openmaptiles-positron/sprite.png
Normal file
BIN
client/public/map-styles/openmaptiles-positron/sprite.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 529 B |
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"circle-11": {
|
||||||
|
"height": 34,
|
||||||
|
"pixelRatio": 2,
|
||||||
|
"width": 34,
|
||||||
|
"x": 0,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"star-11": {
|
||||||
|
"height": 34,
|
||||||
|
"pixelRatio": 2,
|
||||||
|
"width": 34,
|
||||||
|
"x": 34,
|
||||||
|
"y": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
client/public/map-styles/openmaptiles-positron/sprite@2x.png
Normal file
BIN
client/public/map-styles/openmaptiles-positron/sprite@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 921 B |
2375
client/public/map-styles/openmaptiles-positron/style.json
Normal file
2375
client/public/map-styles/openmaptiles-positron/style.json
Normal file
File diff suppressed because it is too large
Load diff
266
client/src/App.tsx
Normal file
266
client/src/App.tsx
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
import { Suspense, lazy, type ComponentType, useEffect } from "react";
|
||||||
|
import { Route, Switch, useLocation } from "wouter";
|
||||||
|
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
|
import DashboardLayout from "./components/DashboardLayout";
|
||||||
|
import { ThemeProvider, useTheme } from "./contexts/ThemeContext";
|
||||||
|
import {
|
||||||
|
getBackgroundScopeAliases,
|
||||||
|
getBackgroundScopeForPath,
|
||||||
|
isDecorationScopeActive,
|
||||||
|
resolveBackgroundBlurForScope,
|
||||||
|
resolveBackgroundImageForScope,
|
||||||
|
resolveBackgroundOverlayOpacityForScope,
|
||||||
|
resolveBackgroundPositionForScope,
|
||||||
|
resolveBackgroundPresentationModeForScope,
|
||||||
|
} from "./lib/appearanceScopes";
|
||||||
|
|
||||||
|
const UserOperatingTour = lazy(() => import("@/components/UserOperatingTour"));
|
||||||
|
const NotFound = lazy(() => import("@/pages/NotFound"));
|
||||||
|
const Home = lazy(() => import("@/pages/Home"));
|
||||||
|
const Dashboard = lazy(() => import("@/pages/Dashboard"));
|
||||||
|
const Profile = lazy(() => import("@/pages/Profile"));
|
||||||
|
const Documents = lazy(() => import("@/pages/Documents"));
|
||||||
|
const NewRequest = lazy(() => import("@/pages/NewRequest"));
|
||||||
|
const Requests = lazy(() => import("@/pages/Requests"));
|
||||||
|
const RequestDetail = lazy(() => import("@/pages/RequestDetail"));
|
||||||
|
const EditRequest = lazy(() => import("@/pages/EditRequest"));
|
||||||
|
const Admin = lazy(() => import("@/pages/Admin"));
|
||||||
|
const FAQ = lazy(() => import("@/pages/FAQ"));
|
||||||
|
const ReservationSalle = lazy(() => import("@/pages/ReservationSalle"));
|
||||||
|
const Login = lazy(() => import("@/pages/Login"));
|
||||||
|
const DemandeMaterielEvenementiel = lazy(() => import("@/pages/DemandeMaterielEvenementiel"));
|
||||||
|
const AssociationDirectoryPortal = lazy(() => import("@/pages/AssociationDirectoryPortal"));
|
||||||
|
const AssociationInvitation = lazy(() => import("@/pages/AssociationInvitation"));
|
||||||
|
const AssociationMapPublic = lazy(() => import("@/pages/AssociationMapPublic"));
|
||||||
|
const AssociationPublicProfile = lazy(() => import("@/pages/AssociationPublicProfile"));
|
||||||
|
const MaterialReturnUpload = lazy(() => import("@/pages/MaterialReturnUpload"));
|
||||||
|
const MaterialReturnFinalPdfView = lazy(() => import("@/pages/MaterialReturnFinalPdfView"));
|
||||||
|
const MaterialConventionDownload = lazy(() => import("@/pages/MaterialConventionDownload"));
|
||||||
|
const SalleWorkflowDocumentDownload = lazy(() => import("@/pages/SalleWorkflowDocumentDownload"));
|
||||||
|
const StatsReportArchiveView = lazy(() => import("@/pages/StatsReportArchiveView"));
|
||||||
|
const TerrainMissions = lazy(() => import("@/pages/TerrainMissions"));
|
||||||
|
const PrivacyPolicy = lazy(() => import("@/pages/PrivacyPolicy"));
|
||||||
|
const LegalNotice = lazy(() => import("@/pages/LegalNotice"));
|
||||||
|
const CookiesPolicy = lazy(() => import("@/pages/CookiesPolicy"));
|
||||||
|
const TermsOfService = lazy(() => import("@/pages/TermsOfService"));
|
||||||
|
|
||||||
|
function PageLoader({ compact = false }: { compact?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className={compact ? "flex min-h-[40vh] items-center justify-center p-6" : "flex min-h-screen items-center justify-center p-6"}>
|
||||||
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
|
<div className="h-10 w-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||||||
|
<p className="text-sm text-muted-foreground">Chargement de l’interface...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withRouteSuspense<T extends object>(Component: ComponentType<T>) {
|
||||||
|
return function SuspendedRoute(props: T) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<Component {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withDashboardLayout<T extends object>(Component: ComponentType<T>) {
|
||||||
|
return function DashboardRoute(props: T) {
|
||||||
|
return (
|
||||||
|
<DashboardLayout>
|
||||||
|
<Suspense fallback={<PageLoader compact />}>
|
||||||
|
<Component {...props} />
|
||||||
|
</Suspense>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const HomeRoute = withRouteSuspense(Home);
|
||||||
|
const LoginRoute = withRouteSuspense(Login);
|
||||||
|
const InvitationRoute = withRouteSuspense(AssociationInvitation);
|
||||||
|
const MaterialReturnFinalPdfRoute = withRouteSuspense(MaterialReturnFinalPdfView);
|
||||||
|
const MaterialReturnUploadRoute = withRouteSuspense(MaterialReturnUpload);
|
||||||
|
const TerrainMissionsRoute = withRouteSuspense(TerrainMissions);
|
||||||
|
const AssociationDirectoryRoute = withRouteSuspense(AssociationDirectoryPortal);
|
||||||
|
const AssociationMapRoute = withRouteSuspense(AssociationMapPublic);
|
||||||
|
const AssociationPublicProfileRoute = withRouteSuspense(AssociationPublicProfile);
|
||||||
|
const DashboardHomeRoute = withRouteSuspense(Dashboard);
|
||||||
|
const DashboardProfileRoute = withDashboardLayout(Profile);
|
||||||
|
const DashboardDocumentsRoute = withDashboardLayout(Documents);
|
||||||
|
const DashboardNewRequestRoute = withDashboardLayout(NewRequest);
|
||||||
|
const DashboardReservationSalleRoute = withDashboardLayout(ReservationSalle);
|
||||||
|
const DashboardMaterialRoute = withDashboardLayout(DemandeMaterielEvenementiel);
|
||||||
|
const DashboardDirectoryRoute = withDashboardLayout(AssociationDirectoryPortal);
|
||||||
|
const DashboardRequestsRoute = withDashboardLayout(Requests);
|
||||||
|
const DashboardRequestDetailRoute = withDashboardLayout(RequestDetail);
|
||||||
|
const DashboardEditRequestRoute = withDashboardLayout(EditRequest);
|
||||||
|
const MaterialConventionRoute = withRouteSuspense(MaterialConventionDownload);
|
||||||
|
const SalleWorkflowDocumentRoute = withRouteSuspense(SalleWorkflowDocumentDownload);
|
||||||
|
const StatsReportArchiveRoute = withRouteSuspense(StatsReportArchiveView);
|
||||||
|
const AdminRoute = withDashboardLayout(Admin);
|
||||||
|
const FAQRoute = withRouteSuspense(FAQ);
|
||||||
|
const PrivacyPolicyRoute = withRouteSuspense(PrivacyPolicy);
|
||||||
|
const LegalNoticeRoute = withRouteSuspense(LegalNotice);
|
||||||
|
const CookiesPolicyRoute = withRouteSuspense(CookiesPolicy);
|
||||||
|
const TermsOfServiceRoute = withRouteSuspense(TermsOfService);
|
||||||
|
const NotFoundRoute = withRouteSuspense(NotFound);
|
||||||
|
|
||||||
|
function AppearanceDecorationLayer() {
|
||||||
|
const [location] = useLocation();
|
||||||
|
const { appearance } = useTheme();
|
||||||
|
const scope = getBackgroundScopeForPath(location);
|
||||||
|
const decorativeElements = (appearance.decorativeElements || []).filter(
|
||||||
|
(entry) => entry.imageUrl && entry.layer === "background" && isDecorationScopeActive(entry.scope, scope),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!appearance.backgroundEnabled || decorativeElements.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="portal-decorations-layer" aria-hidden="true">
|
||||||
|
{decorativeElements.map((entry) => (
|
||||||
|
<img
|
||||||
|
key={entry.id}
|
||||||
|
src={entry.imageUrl}
|
||||||
|
alt=""
|
||||||
|
className="portal-decoration-item"
|
||||||
|
style={{
|
||||||
|
width: `${entry.widthPercent}%`,
|
||||||
|
top: `${entry.topPercent}%`,
|
||||||
|
left: `${entry.leftPercent}%`,
|
||||||
|
opacity: entry.opacity / 100,
|
||||||
|
filter: `blur(${entry.blurRadius}px)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppearanceScopeBridge() {
|
||||||
|
const [location] = useLocation();
|
||||||
|
const { appearance, resolvedTheme } = useTheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
const scope = getBackgroundScopeForPath(location);
|
||||||
|
const resolvedBackgroundImage = resolveBackgroundImageForScope(scope, appearance);
|
||||||
|
const resolvedBackgroundOverlayOpacity = resolveBackgroundOverlayOpacityForScope(scope, appearance);
|
||||||
|
const resolvedBackgroundPosition = resolveBackgroundPositionForScope(scope, appearance);
|
||||||
|
const resolvedBackgroundBlur = resolveBackgroundBlurForScope(scope, appearance);
|
||||||
|
const resolvedBackgroundPresentationMode = resolveBackgroundPresentationModeForScope(scope, appearance);
|
||||||
|
const applies =
|
||||||
|
appearance.backgroundEnabled &&
|
||||||
|
getBackgroundScopeAliases(scope).some((candidate) =>
|
||||||
|
appearance.backgroundTargets.includes(candidate as typeof appearance.backgroundTargets[number])
|
||||||
|
);
|
||||||
|
root.style.setProperty(
|
||||||
|
"--portal-background-image",
|
||||||
|
applies && resolvedBackgroundImage ? `url("${resolvedBackgroundImage}")` : "none"
|
||||||
|
);
|
||||||
|
root.style.setProperty(
|
||||||
|
"--portal-background-overlay",
|
||||||
|
resolvedTheme === "dark"
|
||||||
|
? `rgba(6, 12, 18, ${resolvedBackgroundOverlayOpacity / 100})`
|
||||||
|
: `rgba(247, 250, 252, ${resolvedBackgroundOverlayOpacity / 100})`
|
||||||
|
);
|
||||||
|
root.style.setProperty("--portal-background-position", resolvedBackgroundPosition);
|
||||||
|
root.style.setProperty("--portal-background-blur", `${resolvedBackgroundBlur}px`);
|
||||||
|
root.style.setProperty("--portal-background-presentation-mode", resolvedBackgroundPresentationMode);
|
||||||
|
root.style.setProperty(
|
||||||
|
"--portal-background-center-overlay",
|
||||||
|
resolvedBackgroundPresentationMode === "edge_frame"
|
||||||
|
? (resolvedTheme === "dark" ? "rgba(6, 12, 18, 0.84)" : "rgba(255, 255, 255, 0.88)")
|
||||||
|
: (resolvedTheme === "dark" ? "rgba(6, 12, 18, 0.16)" : "rgba(255, 255, 255, 0.12)")
|
||||||
|
);
|
||||||
|
root.style.setProperty(
|
||||||
|
"--portal-background-edge-strength",
|
||||||
|
resolvedBackgroundPresentationMode === "edge_frame"
|
||||||
|
? (resolvedTheme === "dark" ? "0.98" : "0.94")
|
||||||
|
: (resolvedTheme === "dark" ? "0.28" : "0.22")
|
||||||
|
);
|
||||||
|
root.dataset.portalBackgroundScope = scope;
|
||||||
|
root.dataset.portalBackgroundActive = applies ? "true" : "false";
|
||||||
|
root.dataset.portalBackgroundPresentationMode = resolvedBackgroundPresentationMode;
|
||||||
|
}, [
|
||||||
|
appearance.backgroundEnabled,
|
||||||
|
appearance.backgroundImageUrl,
|
||||||
|
appearance.backgroundMode,
|
||||||
|
appearance.backgroundOverlayOpacity,
|
||||||
|
appearance.backgroundBlurRadius,
|
||||||
|
appearance.backgroundPresentationMode,
|
||||||
|
appearance.backgroundTargetImageUrls,
|
||||||
|
appearance.backgroundTargetBlurRadii,
|
||||||
|
appearance.backgroundTargetOverlayOpacities,
|
||||||
|
appearance.backgroundTargetPositions,
|
||||||
|
appearance.backgroundTargetPresentationModes,
|
||||||
|
appearance.backgroundTargets,
|
||||||
|
location,
|
||||||
|
resolvedTheme,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Router() {
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Route path="/" component={HomeRoute} />
|
||||||
|
<Route path="/login" component={LoginRoute} />
|
||||||
|
<Route path="/invite/:token" component={InvitationRoute} />
|
||||||
|
<Route path="/materiel/restitution/final-pdf" component={MaterialReturnFinalPdfRoute} />
|
||||||
|
<Route path="/materiel/restitution/:token" component={MaterialReturnUploadRoute} />
|
||||||
|
<Route path="/terrain" component={TerrainMissionsRoute} />
|
||||||
|
<Route path="/associations" component={AssociationDirectoryRoute} />
|
||||||
|
<Route path="/associations/carte" component={AssociationMapRoute} />
|
||||||
|
<Route path="/associations/:id" component={AssociationPublicProfileRoute} />
|
||||||
|
<Route path="/dashboard" component={DashboardHomeRoute} />
|
||||||
|
<Route path="/dashboard/profile" component={DashboardProfileRoute} />
|
||||||
|
<Route path="/dashboard/documents" component={DashboardDocumentsRoute} />
|
||||||
|
<Route path="/dashboard/requests/new" component={DashboardNewRequestRoute} />
|
||||||
|
<Route path="/dashboard/reservation-salle" component={DashboardReservationSalleRoute} />
|
||||||
|
<Route path="/dashboard/materiel-evenementiel" component={DashboardMaterialRoute} />
|
||||||
|
<Route path="/dashboard/associations" component={DashboardDirectoryRoute} />
|
||||||
|
<Route path="/dashboard/requests/:id/edit" component={DashboardEditRequestRoute} />
|
||||||
|
<Route path="/dashboard/requests/:id/material-convention" component={MaterialConventionRoute} />
|
||||||
|
<Route path="/dashboard/requests/:id/salle-document/:documentType" component={SalleWorkflowDocumentRoute} />
|
||||||
|
<Route path="/admin/stats-report" component={StatsReportArchiveRoute} />
|
||||||
|
<Route path="/dashboard/requests/:id" component={DashboardRequestDetailRoute} />
|
||||||
|
<Route path="/dashboard/requests" component={DashboardRequestsRoute} />
|
||||||
|
<Route path="/admin" component={AdminRoute} />
|
||||||
|
<Route path="/faq" component={FAQRoute} />
|
||||||
|
<Route path="/confidentialite" component={PrivacyPolicyRoute} />
|
||||||
|
<Route path="/mentions-legales" component={LegalNoticeRoute} />
|
||||||
|
<Route path="/cookies" component={CookiesPolicyRoute} />
|
||||||
|
<Route path="/cgu" component={TermsOfServiceRoute} />
|
||||||
|
<Route path="/404" component={NotFoundRoute} />
|
||||||
|
<Route component={NotFoundRoute} />
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ThemeProvider defaultTheme="light">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Toaster />
|
||||||
|
<AppearanceScopeBridge />
|
||||||
|
<AppearanceDecorationLayer />
|
||||||
|
<Router />
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<UserOperatingTour />
|
||||||
|
</Suspense>
|
||||||
|
</TooltipProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
111
client/src/_core/hooks/useAuth.ts
Normal file
111
client/src/_core/hooks/useAuth.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { getLoginUrl } from "@/const";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import { TRPCClientError } from "@trpc/client";
|
||||||
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
|
|
||||||
|
type UseAuthOptions = {
|
||||||
|
redirectOnUnauthenticated?: boolean;
|
||||||
|
redirectPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAuth(options?: UseAuthOptions) {
|
||||||
|
const { redirectOnUnauthenticated = false, redirectPath = getLoginUrl() } =
|
||||||
|
options ?? {};
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
const meQuery = trpc.auth.me.useQuery(undefined, {
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
const providersQuery = trpc.auth.providers.useQuery(undefined, {
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const logoutMutation = trpc.auth.logout.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.auth.me.setData(undefined, null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginMutation = trpc.auth.login.useMutation({
|
||||||
|
onSuccess: result => {
|
||||||
|
if (!result.mfaRequired) {
|
||||||
|
utils.auth.me.setData(undefined, result.user);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const registerMutation = trpc.auth.register.useMutation({
|
||||||
|
onSuccess: user => {
|
||||||
|
utils.auth.me.setData(undefined, user);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await logoutMutation.mutateAsync();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (
|
||||||
|
error instanceof TRPCClientError &&
|
||||||
|
error.data?.code === "UNAUTHORIZED"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
utils.auth.me.setData(undefined, null);
|
||||||
|
await utils.auth.me.invalidate();
|
||||||
|
}
|
||||||
|
}, [logoutMutation, utils]);
|
||||||
|
|
||||||
|
const state = useMemo(() => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"local-auth-user-info",
|
||||||
|
JSON.stringify(meQuery.data)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
user: meQuery.data ?? null,
|
||||||
|
loading: meQuery.isLoading || logoutMutation.isPending || loginMutation.isPending || registerMutation.isPending,
|
||||||
|
error: meQuery.error ?? logoutMutation.error ?? loginMutation.error ?? registerMutation.error ?? null,
|
||||||
|
isAuthenticated: Boolean(meQuery.data),
|
||||||
|
providers: providersQuery.data ?? { google: false, facebook: false },
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
meQuery.data,
|
||||||
|
meQuery.error,
|
||||||
|
meQuery.isLoading,
|
||||||
|
providersQuery.data,
|
||||||
|
logoutMutation.error,
|
||||||
|
logoutMutation.isPending,
|
||||||
|
loginMutation.error,
|
||||||
|
loginMutation.isPending,
|
||||||
|
registerMutation.error,
|
||||||
|
registerMutation.isPending,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!redirectOnUnauthenticated) return;
|
||||||
|
if (meQuery.isLoading || logoutMutation.isPending) return;
|
||||||
|
if (state.user) return;
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (window.location.pathname === redirectPath) return;
|
||||||
|
|
||||||
|
window.location.href = redirectPath
|
||||||
|
}, [
|
||||||
|
redirectOnUnauthenticated,
|
||||||
|
redirectPath,
|
||||||
|
logoutMutation.isPending,
|
||||||
|
meQuery.isLoading,
|
||||||
|
state.user,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
refresh: () => meQuery.refetch(),
|
||||||
|
login: loginMutation.mutateAsync,
|
||||||
|
register: registerMutation.mutateAsync,
|
||||||
|
providersLoading: providersQuery.isLoading,
|
||||||
|
logout,
|
||||||
|
};
|
||||||
|
}
|
||||||
BIN
client/src/assets/ccds.png
Normal file
BIN
client/src/assets/ccds.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
58
client/src/assets/hero-ccds-illustration.svg
Normal file
58
client/src/assets/hero-ccds-illustration.svg
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
<svg width="1600" height="900" viewBox="0 0 1600 900" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="1600" height="900" fill="#0C2D83"/>
|
||||||
|
<g opacity="0.2">
|
||||||
|
<path d="M1225 120C1282 63 1388 45 1471 78C1528 100 1562 144 1594 195V0H971C1032 37 1102 88 1176 154L1225 120Z" fill="#F0C94B"/>
|
||||||
|
<path d="M1001 737C1090 635 1205 553 1348 504C1459 466 1538 467 1600 479V900H911C933 841 961 786 1001 737Z" fill="#F0C94B"/>
|
||||||
|
<path d="M1261 264C1314 243 1380 252 1420 291C1461 332 1475 404 1456 465L1430 549L1340 519L1368 425C1376 399 1370 371 1351 351C1333 333 1305 326 1281 333L1200 357L1174 268L1261 264Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.22" stroke="white" stroke-width="2">
|
||||||
|
<path d="M1134 236C1190 193 1257 164 1333 151C1419 137 1492 147 1557 178"/>
|
||||||
|
<path d="M1057 694C1126 613 1206 558 1298 528C1399 494 1492 495 1577 523"/>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.14">
|
||||||
|
<circle cx="1230" cy="314" r="205" fill="white"/>
|
||||||
|
<circle cx="1290" cy="632" r="150" fill="white"/>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.9">
|
||||||
|
<path d="M0 759C118 704 228 672 330 663C503 648 617 709 782 697C934 686 1028 611 1184 600C1328 590 1451 632 1600 715V900H0V759Z" fill="url(#paint0_linear_201_2)"/>
|
||||||
|
<path d="M0 806C153 756 279 739 377 741C518 744 654 791 798 791C951 791 1040 740 1179 731C1306 723 1430 745 1600 822V900H0V806Z" fill="url(#paint1_linear_201_2)"/>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.16" stroke="white" stroke-width="2">
|
||||||
|
<path d="M0 752C153 702 279 685 377 687C518 690 654 737 798 737C951 737 1040 686 1179 677C1306 669 1430 691 1600 768"/>
|
||||||
|
<path d="M0 799C153 749 279 732 377 734C518 737 654 784 798 784C951 784 1040 733 1179 724C1306 716 1430 738 1600 815"/>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.9">
|
||||||
|
<rect x="950" y="212" width="112" height="140" rx="22" fill="white" fill-opacity="0.08"/>
|
||||||
|
<rect x="972" y="244" width="67" height="10" rx="5" fill="white" fill-opacity="0.65"/>
|
||||||
|
<rect x="972" y="268" width="67" height="10" rx="5" fill="white" fill-opacity="0.35"/>
|
||||||
|
<rect x="972" y="292" width="47" height="10" rx="5" fill="white" fill-opacity="0.35"/>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.95">
|
||||||
|
<path d="M1081 454H1208L1253 495V641H1036V495L1081 454Z" fill="white" fill-opacity="0.09"/>
|
||||||
|
<path d="M1144 502V641" stroke="white" stroke-opacity="0.4" stroke-width="3"/>
|
||||||
|
<path d="M1036 640H1252" stroke="white" stroke-opacity="0.4" stroke-width="3"/>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.9">
|
||||||
|
<path d="M1287 397C1287 378 1302 363 1321 363C1340 363 1355 378 1355 397V409H1287V397Z" fill="white" fill-opacity="0.16"/>
|
||||||
|
<rect x="1275" y="409" width="91" height="63" rx="22" fill="white" fill-opacity="0.1"/>
|
||||||
|
<path d="M1321 431V450" stroke="white" stroke-opacity="0.55" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
<g opacity="0.86">
|
||||||
|
<circle cx="1044" cy="541" r="22" fill="white" fill-opacity="0.11"/>
|
||||||
|
<circle cx="1113" cy="541" r="22" fill="white" fill-opacity="0.11"/>
|
||||||
|
<circle cx="1182" cy="541" r="22" fill="white" fill-opacity="0.11"/>
|
||||||
|
<path d="M1014 605C1017 571 1030 554 1044 554C1058 554 1071 571 1074 605" stroke="white" stroke-opacity="0.45" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<path d="M1083 605C1086 571 1099 554 1113 554C1127 554 1140 571 1143 605" stroke="white" stroke-opacity="0.45" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<path d="M1152 605C1155 571 1168 554 1182 554C1196 554 1209 571 1212 605" stroke="white" stroke-opacity="0.45" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="paint0_linear_201_2" x1="800" y1="600" x2="800" y2="900" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#2C7BFF" stop-opacity="0.75"/>
|
||||||
|
<stop offset="1" stop-color="#10358F"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="paint1_linear_201_2" x1="800" y1="731" x2="800" y2="900" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#1C61E7" stop-opacity="0.55"/>
|
||||||
|
<stop offset="1" stop-color="#081B59"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
335
client/src/components/AIChatBox.tsx
Normal file
335
client/src/components/AIChatBox.tsx
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Loader2, Send, User, Sparkles } from "lucide-react";
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { Streamdown } from "streamdown";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message type matching server-side LLM Message interface
|
||||||
|
*/
|
||||||
|
export type Message = {
|
||||||
|
role: "system" | "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AIChatBoxProps = {
|
||||||
|
/**
|
||||||
|
* Messages array to display in the chat.
|
||||||
|
* Should match the format used by invokeLLM on the server.
|
||||||
|
*/
|
||||||
|
messages: Message[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when user sends a message.
|
||||||
|
* Typically you'll call a tRPC mutation here to invoke the LLM.
|
||||||
|
*/
|
||||||
|
onSendMessage: (content: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the AI is currently generating a response
|
||||||
|
*/
|
||||||
|
isLoading?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder text for the input field
|
||||||
|
*/
|
||||||
|
placeholder?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom className for the container
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Height of the chat box (default: 600px)
|
||||||
|
*/
|
||||||
|
height?: string | number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty state message to display when no messages
|
||||||
|
*/
|
||||||
|
emptyStateMessage?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggested prompts to display in empty state
|
||||||
|
* Click to send directly
|
||||||
|
*/
|
||||||
|
suggestedPrompts?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A ready-to-use AI chat box component that integrates with the LLM system.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Matches server-side Message interface for seamless integration
|
||||||
|
* - Markdown rendering with Streamdown
|
||||||
|
* - Auto-scrolls to latest message
|
||||||
|
* - Loading states
|
||||||
|
* - Uses global theme colors from index.css
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const ChatPage = () => {
|
||||||
|
* const [messages, setMessages] = useState<Message[]>([
|
||||||
|
* { role: "system", content: "You are a helpful assistant." }
|
||||||
|
* ]);
|
||||||
|
*
|
||||||
|
* const chatMutation = trpc.ai.chat.useMutation({
|
||||||
|
* onSuccess: (response) => {
|
||||||
|
* // Assuming your tRPC endpoint returns the AI response as a string
|
||||||
|
* setMessages(prev => [...prev, {
|
||||||
|
* role: "assistant",
|
||||||
|
* content: response
|
||||||
|
* }]);
|
||||||
|
* },
|
||||||
|
* onError: (error) => {
|
||||||
|
* console.error("Chat error:", error);
|
||||||
|
* // Optionally show error message to user
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* const handleSend = (content: string) => {
|
||||||
|
* const newMessages = [...messages, { role: "user", content }];
|
||||||
|
* setMessages(newMessages);
|
||||||
|
* chatMutation.mutate({ messages: newMessages });
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <AIChatBox
|
||||||
|
* messages={messages}
|
||||||
|
* onSendMessage={handleSend}
|
||||||
|
* isLoading={chatMutation.isPending}
|
||||||
|
* suggestedPrompts={[
|
||||||
|
* "Explain quantum computing",
|
||||||
|
* "Write a hello world in Python"
|
||||||
|
* ]}
|
||||||
|
* />
|
||||||
|
* );
|
||||||
|
* };
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function AIChatBox({
|
||||||
|
messages,
|
||||||
|
onSendMessage,
|
||||||
|
isLoading = false,
|
||||||
|
placeholder = "Type your message...",
|
||||||
|
className,
|
||||||
|
height = "600px",
|
||||||
|
emptyStateMessage = "Start a conversation with AI",
|
||||||
|
suggestedPrompts,
|
||||||
|
}: AIChatBoxProps) {
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputAreaRef = useRef<HTMLFormElement>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// Filter out system messages
|
||||||
|
const displayMessages = messages.filter((msg) => msg.role !== "system");
|
||||||
|
|
||||||
|
// Calculate min-height for last assistant message to push user message to top
|
||||||
|
const [minHeightForLastMessage, setMinHeightForLastMessage] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (containerRef.current && inputAreaRef.current) {
|
||||||
|
const containerHeight = containerRef.current.offsetHeight;
|
||||||
|
const inputHeight = inputAreaRef.current.offsetHeight;
|
||||||
|
const scrollAreaHeight = containerHeight - inputHeight;
|
||||||
|
|
||||||
|
// Reserve space for:
|
||||||
|
// - padding (p-4 = 32px top+bottom)
|
||||||
|
// - user message: 40px (item height) + 16px (margin-top from space-y-4) = 56px
|
||||||
|
// Note: margin-bottom is not counted because it naturally pushes the assistant message down
|
||||||
|
const userMessageReservedHeight = 56;
|
||||||
|
const calculatedHeight = scrollAreaHeight - 32 - userMessageReservedHeight;
|
||||||
|
|
||||||
|
setMinHeightForLastMessage(Math.max(0, calculatedHeight));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Scroll to bottom helper function with smooth animation
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
const viewport = scrollAreaRef.current?.querySelector(
|
||||||
|
'[data-radix-scroll-area-viewport]'
|
||||||
|
) as HTMLDivElement;
|
||||||
|
|
||||||
|
if (viewport) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
viewport.scrollTo({
|
||||||
|
top: viewport.scrollHeight,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmedInput = input.trim();
|
||||||
|
if (!trimmedInput || isLoading) return;
|
||||||
|
|
||||||
|
onSendMessage(trimmedInput);
|
||||||
|
setInput("");
|
||||||
|
|
||||||
|
// Scroll immediately after sending
|
||||||
|
scrollToBottom();
|
||||||
|
|
||||||
|
// Keep focus on input
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col bg-card text-card-foreground rounded-lg border shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
{/* Messages Area */}
|
||||||
|
<div ref={scrollAreaRef} className="flex-1 overflow-hidden">
|
||||||
|
{displayMessages.length === 0 ? (
|
||||||
|
<div className="flex h-full flex-col p-4">
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-6 text-muted-foreground">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Sparkles className="size-12 opacity-20" />
|
||||||
|
<p className="text-sm">{emptyStateMessage}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{suggestedPrompts && suggestedPrompts.length > 0 && (
|
||||||
|
<div className="flex max-w-2xl flex-wrap justify-center gap-2">
|
||||||
|
{suggestedPrompts.map((prompt, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => onSendMessage(prompt)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="rounded-lg border border-border bg-card px-4 py-2 text-sm transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{prompt}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="flex flex-col space-y-4 p-4">
|
||||||
|
{displayMessages.map((message, index) => {
|
||||||
|
// Apply min-height to last message only if NOT loading (when loading, the loading indicator gets it)
|
||||||
|
const isLastMessage = index === displayMessages.length - 1;
|
||||||
|
const shouldApplyMinHeight =
|
||||||
|
isLastMessage && !isLoading && minHeightForLastMessage > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"flex gap-3",
|
||||||
|
message.role === "user"
|
||||||
|
? "justify-end items-start"
|
||||||
|
: "justify-start items-start"
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
shouldApplyMinHeight
|
||||||
|
? { minHeight: `${minHeightForLastMessage}px` }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{message.role === "assistant" && (
|
||||||
|
<div className="size-8 shrink-0 mt-1 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
<Sparkles className="size-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"max-w-[80%] rounded-lg px-4 py-2.5",
|
||||||
|
message.role === "user"
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{message.role === "assistant" ? (
|
||||||
|
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||||
|
<Streamdown>{message.content}</Streamdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="whitespace-pre-wrap text-sm">
|
||||||
|
{message.content}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message.role === "user" && (
|
||||||
|
<div className="size-8 shrink-0 mt-1 rounded-full bg-secondary flex items-center justify-center">
|
||||||
|
<User className="size-4 text-secondary-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div
|
||||||
|
className="flex items-start gap-3"
|
||||||
|
style={
|
||||||
|
minHeightForLastMessage > 0
|
||||||
|
? { minHeight: `${minHeightForLastMessage}px` }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="size-8 shrink-0 mt-1 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
<Sparkles className="size-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted px-4 py-2.5">
|
||||||
|
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input Area */}
|
||||||
|
<form
|
||||||
|
ref={inputAreaRef}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex gap-2 p-4 border-t bg-background/50 items-end"
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="flex-1 max-h-32 resize-none min-h-9"
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="icon"
|
||||||
|
disabled={!input.trim() || isLoading}
|
||||||
|
className="shrink-0 h-[38px] w-[38px]"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
428
client/src/components/AccountSecurityPanel.tsx
Normal file
428
client/src/components/AccountSecurityPanel.tsx
Normal file
|
|
@ -0,0 +1,428 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Copy, Download, Shield, Smartphone, Trash2 } from "lucide-react";
|
||||||
|
import QRCode from "qrcode";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
type AccountSecurityPanelProps = {
|
||||||
|
compact?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AccountSecurityPanel({ compact = false }: AccountSecurityPanelProps) {
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
const { data: securitySettings } = trpc.auth.getSecuritySettings.useQuery();
|
||||||
|
const [authenticatorSetup, setAuthenticatorSetup] = useState<null | {
|
||||||
|
manualEntryKey: string;
|
||||||
|
otpauthUrl: string;
|
||||||
|
}>(null);
|
||||||
|
const [authenticatorCode, setAuthenticatorCode] = useState("");
|
||||||
|
const [authenticatorQrCode, setAuthenticatorQrCode] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const setMfaMutation = trpc.auth.setMfaEnabled.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.auth.getSecuritySettings.invalidate();
|
||||||
|
toast.success("Réglage MFA mis à jour");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message || "Impossible de modifier le MFA"),
|
||||||
|
});
|
||||||
|
const startAuthenticatorMutation = trpc.auth.startAuthenticatorSetup.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setAuthenticatorSetup(data);
|
||||||
|
setAuthenticatorCode("");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message || "Impossible de démarrer Authenticator"),
|
||||||
|
});
|
||||||
|
const confirmAuthenticatorMutation = trpc.auth.confirmAuthenticatorSetup.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.auth.getSecuritySettings.invalidate();
|
||||||
|
setAuthenticatorSetup(null);
|
||||||
|
setAuthenticatorCode("");
|
||||||
|
toast.success("Authenticator activé");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message || "Code Authenticator invalide"),
|
||||||
|
});
|
||||||
|
const cancelAuthenticatorMutation = trpc.auth.cancelAuthenticatorSetup.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.auth.getSecuritySettings.invalidate();
|
||||||
|
setAuthenticatorSetup(null);
|
||||||
|
setAuthenticatorCode("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deleteAccountMutation = trpc.auth.deleteMyAccount.useMutation({
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
await utils.auth.getSecuritySettings.invalidate();
|
||||||
|
toast.success(
|
||||||
|
`Demande enregistrée. Purge prévue le ${new Date(data.purgeScheduledAt).toLocaleDateString("fr-FR")}.`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message || "Impossible de planifier la suppression du compte"),
|
||||||
|
});
|
||||||
|
const cancelDeletionMutation = trpc.auth.cancelAccountDeletion.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.auth.getSecuritySettings.invalidate();
|
||||||
|
toast.success("Suppression du compte annulée");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message || "Impossible d'annuler la suppression"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deletionPending = Boolean(securitySettings?.purgeScheduledAt);
|
||||||
|
const canUseAuthenticator = securitySettings?.loginMethod === "local_jwt" && Boolean(securitySettings?.email);
|
||||||
|
const emailMfaAllowed = securitySettings?.mfaPolicy?.emailFallbackAllowed !== false;
|
||||||
|
const mfaModeLabel = useMemo(() => {
|
||||||
|
if (securitySettings?.mfaMethod === "authenticator_app") return "Authenticator";
|
||||||
|
if (securitySettings?.mfaEnabled) return "Code email";
|
||||||
|
return "Inactif";
|
||||||
|
}, [securitySettings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function buildQrCode() {
|
||||||
|
if (!authenticatorSetup?.otpauthUrl) {
|
||||||
|
setAuthenticatorQrCode(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dataUrl = await QRCode.toDataURL(authenticatorSetup.otpauthUrl, {
|
||||||
|
margin: 1,
|
||||||
|
width: 192,
|
||||||
|
});
|
||||||
|
if (!cancelled) {
|
||||||
|
setAuthenticatorQrCode(dataUrl);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setAuthenticatorQrCode(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildQrCode();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [authenticatorSetup]);
|
||||||
|
|
||||||
|
const handleExportData = async () => {
|
||||||
|
const payload = await utils.auth.exportMyData.fetch();
|
||||||
|
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `mes-donnees-portail-associations-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyValue = async (value: string, label: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
toast.success(`${label} copié`);
|
||||||
|
} catch {
|
||||||
|
toast.error(`Impossible de copier ${label.toLowerCase()}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Shield className="h-5 w-5 text-primary" />
|
||||||
|
Sécurité du compte
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{compact
|
||||||
|
? "MFA, export des données et état de suppression du compte."
|
||||||
|
: "Pilote le MFA, l’export RGPD et la suppression différée à 30 jours."}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className={`grid gap-4 ${compact ? "md:grid-cols-2" : "md:grid-cols-3"}`}>
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<p className="text-sm text-muted-foreground">Mode de connexion</p>
|
||||||
|
<p className="mt-2 font-medium">
|
||||||
|
{securitySettings?.loginMethod === "local_jwt"
|
||||||
|
? "Compte local"
|
||||||
|
: securitySettings?.loginMethod?.includes("oauth")
|
||||||
|
? "Compte social"
|
||||||
|
: securitySettings?.loginMethod || "Non déterminé"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<p className="text-sm text-muted-foreground">MFA actif</p>
|
||||||
|
<p className="mt-2 font-medium">{mfaModeLabel}</p>
|
||||||
|
{securitySettings?.mfaRequiredByRole ? (
|
||||||
|
<p className="mt-2 text-xs text-amber-700">Obligatoire pour ce rôle interne.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<p className="text-sm text-muted-foreground">Verrouillage anti-bruteforce</p>
|
||||||
|
<p className="mt-2 font-medium">
|
||||||
|
{securitySettings
|
||||||
|
? `${securitySettings.lockoutPolicy.maxAttempts} tentatives puis ${securitySettings.lockoutPolicy.lockMinutes} min`
|
||||||
|
: "Chargement..."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deletionPending ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
Suppression demandée le{" "}
|
||||||
|
{securitySettings?.deletionRequestedAt ? new Date(securitySettings.deletionRequestedAt).toLocaleDateString("fr-FR") : "-"}
|
||||||
|
{" "}• purge prévue le{" "}
|
||||||
|
{securitySettings?.purgeScheduledAt ? new Date(securitySettings.purgeScheduledAt).toLocaleDateString("fr-FR") : "-"}.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<div className="rounded-lg border p-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">MFA par email</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Fonctionne immédiatement pour les comptes locaux avec une adresse email valide.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{securitySettings?.mfaRequiredByRole ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
{emailMfaAllowed
|
||||||
|
? "Disponible en solution de secours, mais l’application Authenticator est recommandée pour les comptes internes."
|
||||||
|
: "Cette méthode n'est plus autorisée comme mode normal pour ce rôle. L'application Authenticator est requise."}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={securitySettings?.mfaMethod === "email_otp" || (securitySettings?.mfaEnabled && securitySettings?.mfaMethod !== "authenticator_app") ? "default" : "outline"}
|
||||||
|
disabled={!securitySettings?.smtpReady || !canUseAuthenticator || setMfaMutation.isPending || !emailMfaAllowed}
|
||||||
|
onClick={() => setMfaMutation.mutate({ enabled: true })}
|
||||||
|
>
|
||||||
|
Utiliser le code email
|
||||||
|
</Button>
|
||||||
|
{!securitySettings?.mfaRequiredByRole ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={setMfaMutation.isPending}
|
||||||
|
onClick={() => setMfaMutation.mutate({ enabled: false })}
|
||||||
|
>
|
||||||
|
Désactiver
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{!securitySettings?.smtpReady ? (
|
||||||
|
<p className="text-sm text-amber-700">Le SMTP doit être configuré pour utiliser le MFA email.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-4 space-y-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Smartphone className="mt-0.5 h-5 w-5 text-primary" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Appli Authenticator</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Compatible Google Authenticator, Microsoft Authenticator et applications TOTP équivalentes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{securitySettings?.mfaRequiredByRole ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
{securitySettings?.mfaPolicy?.enforcedMethod === "authenticator_app"
|
||||||
|
? "Méthode exigée pour ce rôle : scannez le QR code puis validez le code à 6 chiffres pour aligner le compte."
|
||||||
|
: "Méthode cible pour les rôles internes : scannez le QR code puis validez le code à 6 chiffres pour activer la protection."}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={securitySettings?.mfaMethod === "authenticator_app" ? "default" : "outline"}
|
||||||
|
disabled={!canUseAuthenticator || startAuthenticatorMutation.isPending}
|
||||||
|
onClick={() => startAuthenticatorMutation.mutate()}
|
||||||
|
>
|
||||||
|
Configurer Authenticator
|
||||||
|
</Button>
|
||||||
|
{securitySettings?.mfaMethod === "authenticator_app" ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={setMfaMutation.isPending}
|
||||||
|
onClick={() => setMfaMutation.mutate({ enabled: true })}
|
||||||
|
>
|
||||||
|
Revenir au code email
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{authenticatorSetup ? (
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-4 space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-[192px_minmax(0,1fr)] md:items-start">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>QR code à scanner</Label>
|
||||||
|
<div className="flex h-48 w-48 items-center justify-center rounded-lg border bg-white">
|
||||||
|
{authenticatorQrCode ? (
|
||||||
|
<img
|
||||||
|
src={authenticatorQrCode}
|
||||||
|
alt="QR code Authenticator"
|
||||||
|
className="h-44 w-44"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="px-4 text-center text-xs text-muted-foreground">
|
||||||
|
QR code indisponible. Utilisez la clé manuelle.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 min-w-0">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Clé manuelle</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input value={authenticatorSetup.manualEntryKey} readOnly />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => copyValue(authenticatorSetup.manualEntryKey, "Clé manuelle")}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Code de vérification</Label>
|
||||||
|
<InputOTP maxLength={6} value={authenticatorCode} onChange={setAuthenticatorCode}>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={0} />
|
||||||
|
<InputOTPSlot index={1} />
|
||||||
|
<InputOTPSlot index={2} />
|
||||||
|
<InputOTPSlot index={3} />
|
||||||
|
<InputOTPSlot index={4} />
|
||||||
|
<InputOTPSlot index={5} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Scannez le QR code dans votre application, puis saisissez le code temporaire affiché.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>URL OTP avancée</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input value={authenticatorSetup.otpauthUrl} readOnly />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => copyValue(authenticatorSetup.otpauthUrl, "URL OTP")}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={authenticatorCode.length !== 6 || confirmAuthenticatorMutation.isPending}
|
||||||
|
onClick={() => confirmAuthenticatorMutation.mutate({ code: authenticatorCode })}
|
||||||
|
>
|
||||||
|
Valider Authenticator
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => cancelAuthenticatorMutation.mutate()}
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`grid gap-4 ${compact ? "md:grid-cols-2" : "md:grid-cols-2"}`}>
|
||||||
|
<div className="rounded-lg border p-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Exporter mes données</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Télécharge un JSON contenant le compte, la fiche, les documents et les demandes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" onClick={handleExportData}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Télécharger mes données
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{securitySettings?.role === "user" ? (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50/40 p-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-red-700">Suppression du compte</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Le compte est désactivé immédiatement, puis les données personnelles sont purgées au bout de 30 jours.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{!deletionPending ? (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button type="button" variant="destructive">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Demander la suppression
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Planifier la suppression du compte ?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Le compte sera désactivé maintenant. La purge définitive et l’anonymisation des données personnelles interviendront dans 30 jours.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => deleteAccountMutation.mutate({ confirm: true })}>
|
||||||
|
Confirmer
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
) : (
|
||||||
|
<Button type="button" variant="outline" onClick={() => cancelDeletionMutation.mutate()}>
|
||||||
|
Annuler la suppression planifiée
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
client/src/components/AddressAutocompleteInput.tsx
Normal file
183
client/src/components/AddressAutocompleteInput.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { fetchAddressSuggestions, type AddressSuggestion } from "@/lib/addressAutocomplete";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Loader2, MapPinned } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
type AddressAutocompleteInputProps = {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
onValueChange: (value: string) => void;
|
||||||
|
onSuggestionSelected: (suggestion: AddressSuggestion) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AddressAutocompleteInput({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
onSuggestionSelected,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
}: AddressAutocompleteInputProps) {
|
||||||
|
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([]);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [hasSearched, setHasSearched] = useState(false);
|
||||||
|
|
||||||
|
const closeTimeoutRef = useRef<number | null>(null);
|
||||||
|
const skipNextFetchRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmedValue = value.trim();
|
||||||
|
|
||||||
|
if (skipNextFetchRef.current) {
|
||||||
|
skipNextFetchRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedValue.length < 3) {
|
||||||
|
setSuggestions([]);
|
||||||
|
setIsOpen(false);
|
||||||
|
setIsLoading(false);
|
||||||
|
setHasSearched(false);
|
||||||
|
setErrorMessage(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = window.setTimeout(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setErrorMessage(null);
|
||||||
|
setIsOpen(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextSuggestions = await fetchAddressSuggestions(trimmedValue, controller.signal);
|
||||||
|
setSuggestions(nextSuggestions);
|
||||||
|
setHasSearched(true);
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(error);
|
||||||
|
setSuggestions([]);
|
||||||
|
setHasSearched(true);
|
||||||
|
setIsOpen(true);
|
||||||
|
setErrorMessage("Impossible de charger les suggestions pour le moment.");
|
||||||
|
} finally {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (closeTimeoutRef.current) {
|
||||||
|
window.clearTimeout(closeTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showSuggestionsPanel = isOpen && value.trim().length >= 3;
|
||||||
|
const showEmptyState = showSuggestionsPanel && hasSearched && !isLoading && !errorMessage && suggestions.length === 0;
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
closeTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
}, 120);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
if (closeTimeoutRef.current) {
|
||||||
|
window.clearTimeout(closeTimeoutRef.current);
|
||||||
|
closeTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.trim().length >= 3 && (suggestions.length > 0 || errorMessage || hasSearched)) {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectSuggestion = (suggestion: AddressSuggestion) => {
|
||||||
|
skipNextFetchRef.current = true;
|
||||||
|
onValueChange(suggestion.addressLine);
|
||||||
|
onSuggestionSelected(suggestion);
|
||||||
|
setSuggestions([]);
|
||||||
|
setHasSearched(false);
|
||||||
|
setErrorMessage(null);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("relative", className)}>
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onValueChange(event.target.value)}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoComplete="street-address"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
Commencez a saisir une adresse pour recevoir des propositions automatiques.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{showSuggestionsPanel ? (
|
||||||
|
<div className="bg-popover text-popover-foreground absolute left-0 right-0 top-full z-30 mt-2 overflow-hidden rounded-md border shadow-lg">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-muted-foreground flex items-center gap-2 px-3 py-3 text-sm">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Recherche d'adresses...
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLoading && errorMessage ? (
|
||||||
|
<div className="text-muted-foreground px-3 py-3 text-sm">{errorMessage}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showEmptyState ? (
|
||||||
|
<div className="text-muted-foreground px-3 py-3 text-sm">
|
||||||
|
Aucune adresse n'a ete trouvee pour cette saisie.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isLoading && !errorMessage && suggestions.length > 0 ? (
|
||||||
|
<div className="max-h-72 overflow-y-auto py-1">
|
||||||
|
{suggestions.map((suggestion) => (
|
||||||
|
<button
|
||||||
|
key={suggestion.label}
|
||||||
|
type="button"
|
||||||
|
className="hover:bg-accent hover:text-accent-foreground flex w-full items-start gap-3 px-3 py-3 text-left transition-colors"
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => handleSelectSuggestion(suggestion)}
|
||||||
|
>
|
||||||
|
<MapPinned className="text-primary mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium">{suggestion.addressLine}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{suggestion.postcode} {suggestion.city}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
client/src/components/AssociationPublicMap.lazy.ts
Normal file
1
client/src/components/AssociationPublicMap.lazy.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { AssociationPublicMap as default } from "./AssociationPublicMap";
|
||||||
934
client/src/components/AssociationPublicMap.tsx
Normal file
934
client/src/components/AssociationPublicMap.tsx
Normal file
|
|
@ -0,0 +1,934 @@
|
||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import maplibregl, { type GeoJSONSource, type LngLatBoundsLike, type Map as MapLibreMap, type MapLayerMouseEvent } from "maplibre-gl";
|
||||||
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
|
import { buildDirectionsOptions } from "@/lib/mapDirections";
|
||||||
|
import { buildMaterialReturnFinalPdfViewerHref } from "@/lib/materialReturnFinalPdf";
|
||||||
|
import { getAssociationThematicLabel } from "@shared/associationThematics";
|
||||||
|
|
||||||
|
type PointFeature = {
|
||||||
|
type: "Feature";
|
||||||
|
properties: FeatureProperties;
|
||||||
|
geometry: {
|
||||||
|
type: "Point";
|
||||||
|
coordinates: [number, number];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type PolygonFeature = {
|
||||||
|
type: "Feature";
|
||||||
|
properties: FeatureProperties;
|
||||||
|
geometry: {
|
||||||
|
type: "Polygon";
|
||||||
|
coordinates: [Array<[number, number]>];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type SimpleFeatureCollection = {
|
||||||
|
type: "FeatureCollection";
|
||||||
|
features: Array<PointFeature | PolygonFeature>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MapAssociationEntry = {
|
||||||
|
id: number;
|
||||||
|
nomAssociation: string;
|
||||||
|
ville: string | null;
|
||||||
|
thematiques?: string[] | null;
|
||||||
|
objetAssociation: string | null;
|
||||||
|
siteWeb?: string | null;
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
registered: boolean;
|
||||||
|
publicUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LogisticsEvent = {
|
||||||
|
requestId: number;
|
||||||
|
title: string;
|
||||||
|
associationName: string;
|
||||||
|
commune: string;
|
||||||
|
manifestationStart: string;
|
||||||
|
manifestationEnd: string;
|
||||||
|
pickupDate: string;
|
||||||
|
restitutionDate: string;
|
||||||
|
useStartDate: string;
|
||||||
|
useEndDate: string;
|
||||||
|
requestedItems: Array<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
quantity: number;
|
||||||
|
granted: boolean;
|
||||||
|
requested: boolean;
|
||||||
|
extra: string;
|
||||||
|
}>;
|
||||||
|
boardState: string;
|
||||||
|
boardStateLabel: string;
|
||||||
|
logisticServiceLabel: string;
|
||||||
|
issueFlag: boolean;
|
||||||
|
finalPdfUrl: string | null;
|
||||||
|
uploadLink: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AssociationPublicMapProps = {
|
||||||
|
associations: MapAssociationEntry[];
|
||||||
|
mode: "annuaire" | "densite" | "logistique";
|
||||||
|
selectedAssociationId?: number | null;
|
||||||
|
onSelectAssociation?: (id: number) => void;
|
||||||
|
logisticsEvents?: LogisticsEvent[];
|
||||||
|
selectedLogisticsRequestId?: number | null;
|
||||||
|
onSelectLogistics?: (requestId: number) => void;
|
||||||
|
onOpenLogisticsRequest?: (requestId: number) => void;
|
||||||
|
logisticsMarkerClickMode?: "select-first" | "open-direct";
|
||||||
|
radiusCenter?: { latitude: number; longitude: number } | null;
|
||||||
|
radiusKm?: number;
|
||||||
|
styleUrl?: string;
|
||||||
|
heightClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FeatureProperties = Record<string, string | number | boolean | null>;
|
||||||
|
|
||||||
|
const logisticsStateColors: Record<string, string> = {
|
||||||
|
a_attribuer: "#64748b",
|
||||||
|
planifie: "#2563eb",
|
||||||
|
terrain: "#d97706",
|
||||||
|
retard: "#dc2626",
|
||||||
|
conforme: "#16a34a",
|
||||||
|
litige: "#ea580c",
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeHtml(value: string | null | undefined) {
|
||||||
|
return String(value || "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThematicColor(thematique?: string | null) {
|
||||||
|
switch (thematique) {
|
||||||
|
case "culture_loisirs":
|
||||||
|
return "#7c3aed";
|
||||||
|
case "social_sante":
|
||||||
|
return "#dc2626";
|
||||||
|
case "education_formation":
|
||||||
|
return "#2563eb";
|
||||||
|
case "economie_territoire":
|
||||||
|
return "#b45309";
|
||||||
|
case "environnement_patrimoine":
|
||||||
|
return "#15803d";
|
||||||
|
case "institutions_divers":
|
||||||
|
return "#475569";
|
||||||
|
default:
|
||||||
|
return "#0f5c89";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHeatColor(count: number, maxCount: number) {
|
||||||
|
if (maxCount <= 1) return "#0f5c89";
|
||||||
|
const ratio = count / maxCount;
|
||||||
|
if (ratio > 0.75) return "#b91c1c";
|
||||||
|
if (ratio > 0.5) return "#ea580c";
|
||||||
|
if (ratio > 0.25) return "#f59e0b";
|
||||||
|
return "#2563eb";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateFr(value?: string | null) {
|
||||||
|
if (!value) return "-";
|
||||||
|
try {
|
||||||
|
return new Date(value).toLocaleDateString("fr-FR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDateWithinTodayRange(start?: string | null, end?: string | null) {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const normalizedStart = String(start || "").slice(0, 10);
|
||||||
|
const normalizedEnd = String(end || normalizedStart).slice(0, 10);
|
||||||
|
if (!normalizedStart || !normalizedEnd) return false;
|
||||||
|
return normalizedStart <= today && today <= normalizedEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFeature(properties: FeatureProperties, longitude: number, latitude: number) {
|
||||||
|
return {
|
||||||
|
type: "Feature" as const,
|
||||||
|
properties,
|
||||||
|
geometry: {
|
||||||
|
type: "Point" as const,
|
||||||
|
coordinates: [longitude, latitude] as [number, number],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGeoJsonSource(map: MapLibreMap, sourceId: string, data: SimpleFeatureCollection) {
|
||||||
|
const source = map.getSource(sourceId) as GeoJSONSource | undefined;
|
||||||
|
if (source) {
|
||||||
|
source.setData(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBoundsFromFeatures(features: PointFeature[]) {
|
||||||
|
if (features.length === 0) return null;
|
||||||
|
const first = features[0].geometry.coordinates;
|
||||||
|
const bounds = new maplibregl.LngLatBounds(first as [number, number], first as [number, number]);
|
||||||
|
for (const feature of features.slice(1)) {
|
||||||
|
bounds.extend(feature.geometry.coordinates as [number, number]);
|
||||||
|
}
|
||||||
|
return bounds as LngLatBoundsLike;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAssociationPopup(properties: FeatureProperties) {
|
||||||
|
const thematiques = String(properties.thematiques || "")
|
||||||
|
.split("||")
|
||||||
|
.filter(Boolean);
|
||||||
|
const directionsLinks = buildDirectionsOptions({
|
||||||
|
latitude: typeof properties.latitude === "number" ? properties.latitude : null,
|
||||||
|
longitude: typeof properties.longitude === "number" ? properties.longitude : null,
|
||||||
|
destinationLabel: [properties.nomAssociation, properties.ville].filter(Boolean).join(", "),
|
||||||
|
})
|
||||||
|
.map((option) => `<a href="${escapeHtml(option.href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(option.label)}</a>`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return `<div class="association-map-popup-card">
|
||||||
|
<div class="association-map-popup-eyebrow">Association</div>
|
||||||
|
<strong class="association-map-popup-title">${escapeHtml(String(properties.nomAssociation || ""))}</strong>
|
||||||
|
<span class="association-map-popup-meta">${escapeHtml(String(properties.ville || "Commune non renseignée"))}</span>
|
||||||
|
${thematiques.length ? `<span class="association-map-popup-tags">${escapeHtml(thematiques.join(" • "))}</span>` : ""}
|
||||||
|
${properties.objetAssociation ? `<span class="association-map-popup-body">${escapeHtml(String(properties.objetAssociation))}</span>` : ""}
|
||||||
|
<div class="association-map-popup-actions">
|
||||||
|
${directionsLinks}
|
||||||
|
${properties.siteWeb ? `<a href="${escapeHtml(String(properties.siteWeb))}" target="_blank" rel="noopener noreferrer">Site web</a>` : ""}
|
||||||
|
${properties.publicUrl ? `<a href="${escapeHtml(String(properties.publicUrl))}">Voir la fiche</a>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDensityPopup(properties: FeatureProperties) {
|
||||||
|
return `<div class="association-map-popup-card">
|
||||||
|
<div class="association-map-popup-eyebrow">Lecture territoriale</div>
|
||||||
|
<strong class="association-map-popup-title">${escapeHtml(String(properties.ville || "Commune non renseignée"))}</strong>
|
||||||
|
<span class="association-map-popup-meta">${properties.count} association(s)</span>
|
||||||
|
${properties.dominantThematique ? `<span class="association-map-popup-body">Thématique dominante: ${escapeHtml(String(properties.dominantThematique))}</span>` : ""}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLogisticsPopup(properties: FeatureProperties) {
|
||||||
|
const directionsLinks = buildDirectionsOptions({
|
||||||
|
latitude: typeof properties.latitude === "number" ? properties.latitude : null,
|
||||||
|
longitude: typeof properties.longitude === "number" ? properties.longitude : null,
|
||||||
|
destinationLabel: [properties.associationName, properties.commune].filter(Boolean).join(", "),
|
||||||
|
})
|
||||||
|
.map((option) => `<a href="${escapeHtml(option.href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(option.label)}</a>`)
|
||||||
|
.join("");
|
||||||
|
const finalPdfViewerHref = properties.finalPdfUrl
|
||||||
|
? buildMaterialReturnFinalPdfViewerHref({
|
||||||
|
url: String(properties.finalPdfUrl),
|
||||||
|
returnTo: "/associations/carte",
|
||||||
|
title: "Consulter le PDF final de restitution",
|
||||||
|
downloadName: `fiche-finale-restitution-${String(properties.requestId || "materiel")}.pdf`,
|
||||||
|
})
|
||||||
|
: "";
|
||||||
|
const requestUrl = properties.requestId ? `/dashboard/requests/${String(properties.requestId)}` : "";
|
||||||
|
|
||||||
|
return `<div class="association-map-popup-card">
|
||||||
|
<div class="association-map-popup-eyebrow">Logistique</div>
|
||||||
|
<strong class="association-map-popup-title">${escapeHtml(String(properties.associationName || ""))}</strong>
|
||||||
|
<span class="association-map-popup-meta">${escapeHtml(String(properties.commune || "Commune non renseignée"))}</span>
|
||||||
|
<span class="association-map-popup-state">${escapeHtml(String(properties.boardStateLabel || ""))}</span>
|
||||||
|
<span class="association-map-popup-body">Usage: ${escapeHtml(String(properties.manifestationStartLabel || "-"))} au ${escapeHtml(String(properties.manifestationEndLabel || "-"))}</span>
|
||||||
|
<span class="association-map-popup-body">Matériel: ${escapeHtml(String(properties.requestedItemsLabel || "Non renseigné"))}</span>
|
||||||
|
${properties.logisticServiceLabel ? `<span class="association-map-popup-body">Service: ${escapeHtml(String(properties.logisticServiceLabel))}</span>` : ""}
|
||||||
|
<div class="association-map-popup-actions">
|
||||||
|
${requestUrl ? `<a href="${escapeHtml(requestUrl)}">Ouvrir dossier</a>` : ""}
|
||||||
|
${directionsLinks}
|
||||||
|
${properties.finalPdfUrl ? `<a href="${escapeHtml(finalPdfViewerHref)}">PDF final</a>` : ""}
|
||||||
|
${properties.uploadLink ? `<a href="${escapeHtml(String(properties.uploadLink))}" target="_blank" rel="noopener noreferrer">Lien terrain</a>` : ""}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMapStyleUrl(styleUrl: string) {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return styleUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLocalHost = ["localhost", "127.0.0.1"].includes(window.location.hostname);
|
||||||
|
if (!isLocalHost) {
|
||||||
|
return styleUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(styleUrl);
|
||||||
|
if (
|
||||||
|
["www.portail-association973.com", "preprod.portail-association973.com"].includes(parsed.hostname)
|
||||||
|
&& (parsed.pathname.startsWith("/map-styles/") || parsed.pathname.startsWith("/map-tiles/") || parsed.pathname.startsWith("/data/"))
|
||||||
|
) {
|
||||||
|
return `${parsed.pathname}${parsed.search}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return styleUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return styleUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssociationPublicMap({
|
||||||
|
associations,
|
||||||
|
mode,
|
||||||
|
selectedAssociationId,
|
||||||
|
onSelectAssociation,
|
||||||
|
logisticsEvents = [],
|
||||||
|
selectedLogisticsRequestId,
|
||||||
|
onSelectLogistics,
|
||||||
|
onOpenLogisticsRequest,
|
||||||
|
logisticsMarkerClickMode = "select-first",
|
||||||
|
radiusCenter = null,
|
||||||
|
radiusKm = 0,
|
||||||
|
styleUrl = "/map-styles/openmaptiles-positron/style.json",
|
||||||
|
heightClassName = "h-[620px]",
|
||||||
|
}: AssociationPublicMapProps) {
|
||||||
|
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const mapRef = useRef<MapLibreMap | null>(null);
|
||||||
|
const popupRef = useRef<maplibregl.Popup | null>(null);
|
||||||
|
const interactiveBoundRef = useRef(false);
|
||||||
|
const modeRef = useRef(mode);
|
||||||
|
const selectedLogisticsRequestIdRef = useRef<number | null | undefined>(selectedLogisticsRequestId);
|
||||||
|
const onSelectLogisticsRef = useRef(onSelectLogistics);
|
||||||
|
const onOpenLogisticsRequestRef = useRef(onOpenLogisticsRequest);
|
||||||
|
const logisticsMarkerClickModeRef = useRef(logisticsMarkerClickMode);
|
||||||
|
modeRef.current = mode;
|
||||||
|
selectedLogisticsRequestIdRef.current = selectedLogisticsRequestId;
|
||||||
|
onSelectLogisticsRef.current = onSelectLogistics;
|
||||||
|
onOpenLogisticsRequestRef.current = onOpenLogisticsRequest;
|
||||||
|
logisticsMarkerClickModeRef.current = logisticsMarkerClickMode;
|
||||||
|
const resolvedStyleUrl = useMemo(() => normalizeMapStyleUrl(styleUrl), [styleUrl]);
|
||||||
|
|
||||||
|
const validAssociations = useMemo(
|
||||||
|
() => associations.filter((item) => item.latitude !== null && item.longitude !== null),
|
||||||
|
[associations]
|
||||||
|
);
|
||||||
|
|
||||||
|
const associationLookup = useMemo(() => {
|
||||||
|
const lookup = new Map<string, MapAssociationEntry>();
|
||||||
|
validAssociations.forEach((entry) => {
|
||||||
|
const baseKey = entry.nomAssociation.trim().toLowerCase();
|
||||||
|
lookup.set(baseKey, entry);
|
||||||
|
if (entry.ville) {
|
||||||
|
lookup.set(`${baseKey}::${entry.ville.trim().toLowerCase()}`, entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return lookup;
|
||||||
|
}, [validAssociations]);
|
||||||
|
|
||||||
|
const communeDensity = useMemo(() => {
|
||||||
|
const groups = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
ville: string;
|
||||||
|
count: number;
|
||||||
|
latitudeTotal: number;
|
||||||
|
longitudeTotal: number;
|
||||||
|
thematiques: string[];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
validAssociations.forEach((entry) => {
|
||||||
|
const key = entry.ville || `association-${entry.id}`;
|
||||||
|
const group = groups.get(key) || {
|
||||||
|
ville: entry.ville || "Commune non renseignée",
|
||||||
|
count: 0,
|
||||||
|
latitudeTotal: 0,
|
||||||
|
longitudeTotal: 0,
|
||||||
|
thematiques: [],
|
||||||
|
};
|
||||||
|
group.count += 1;
|
||||||
|
group.latitudeTotal += entry.latitude as number;
|
||||||
|
group.longitudeTotal += entry.longitude as number;
|
||||||
|
group.thematiques.push(...(entry.thematiques || []));
|
||||||
|
groups.set(key, group);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(groups.values()).map((group) => {
|
||||||
|
const counts = group.thematiques.reduce<Record<string, number>>((acc, value) => {
|
||||||
|
acc[value] = (acc[value] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] || null;
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
dominantThematique: dominant ? getAssociationThematicLabel(dominant) : null,
|
||||||
|
latitude: group.latitudeTotal / group.count,
|
||||||
|
longitude: group.longitudeTotal / group.count,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [validAssociations]);
|
||||||
|
|
||||||
|
const maxDensityCount = useMemo(
|
||||||
|
() => communeDensity.reduce((max, group) => Math.max(max, group.count), 0),
|
||||||
|
[communeDensity]
|
||||||
|
);
|
||||||
|
|
||||||
|
const logisticsFeatures = useMemo(() => {
|
||||||
|
return logisticsEvents.flatMap((event) => {
|
||||||
|
const associationNameKey = event.associationName.trim().toLowerCase();
|
||||||
|
const communeKey = event.commune?.trim().toLowerCase();
|
||||||
|
const associationMatch = (communeKey && associationLookup.get(`${associationNameKey}::${communeKey}`))
|
||||||
|
|| associationLookup.get(associationNameKey);
|
||||||
|
if (!associationMatch?.latitude || !associationMatch?.longitude) return [];
|
||||||
|
|
||||||
|
const active = isDateWithinTodayRange(event.useStartDate, event.useEndDate);
|
||||||
|
return [
|
||||||
|
toFeature(
|
||||||
|
{
|
||||||
|
requestId: event.requestId,
|
||||||
|
associationName: event.associationName,
|
||||||
|
commune: event.commune || associationMatch.ville,
|
||||||
|
latitude: associationMatch.latitude,
|
||||||
|
longitude: associationMatch.longitude,
|
||||||
|
boardStateLabel: event.boardStateLabel,
|
||||||
|
logisticServiceLabel: event.logisticServiceLabel,
|
||||||
|
finalPdfUrl: event.finalPdfUrl,
|
||||||
|
uploadLink: event.uploadLink,
|
||||||
|
manifestationStartLabel: formatDateFr(event.manifestationStart),
|
||||||
|
manifestationEndLabel: formatDateFr(event.manifestationEnd),
|
||||||
|
requestedItemsLabel: event.requestedItems.map((item) => `${item.label} x${item.quantity}`).join(" • ") || "Non renseigné",
|
||||||
|
color: logisticsStateColors[event.boardState] || "#0f5c89",
|
||||||
|
radius: selectedLogisticsRequestId === event.requestId ? 13 : active ? 11 : 9,
|
||||||
|
},
|
||||||
|
associationMatch.longitude,
|
||||||
|
associationMatch.latitude
|
||||||
|
),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}, [associationLookup, logisticsEvents, selectedLogisticsRequestId]);
|
||||||
|
|
||||||
|
const associationFeatureCollection = useMemo<SimpleFeatureCollection>(() => ({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: validAssociations.map((entry) =>
|
||||||
|
toFeature(
|
||||||
|
{
|
||||||
|
id: entry.id,
|
||||||
|
nomAssociation: entry.nomAssociation,
|
||||||
|
ville: entry.ville,
|
||||||
|
thematiques: (entry.thematiques || []).map((value) => getAssociationThematicLabel(value)).join("||"),
|
||||||
|
objetAssociation: entry.objetAssociation,
|
||||||
|
siteWeb: entry.siteWeb || "",
|
||||||
|
publicUrl: entry.publicUrl,
|
||||||
|
latitude: entry.latitude,
|
||||||
|
longitude: entry.longitude,
|
||||||
|
color: getThematicColor(entry.thematiques?.[0]),
|
||||||
|
radius: selectedAssociationId === entry.id ? 11 : 8,
|
||||||
|
},
|
||||||
|
entry.longitude as number,
|
||||||
|
entry.latitude as number
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}), [selectedAssociationId, validAssociations]);
|
||||||
|
|
||||||
|
const densityFeatureCollection = useMemo<SimpleFeatureCollection>(() => ({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: communeDensity.map((group) =>
|
||||||
|
toFeature(
|
||||||
|
{
|
||||||
|
ville: group.ville,
|
||||||
|
count: group.count,
|
||||||
|
dominantThematique: group.dominantThematique || "",
|
||||||
|
color: getHeatColor(group.count, maxDensityCount),
|
||||||
|
radiusMeters: Math.max(6000, 4000 + group.count * 1800),
|
||||||
|
label: String(group.count),
|
||||||
|
},
|
||||||
|
group.longitude,
|
||||||
|
group.latitude
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}), [communeDensity, maxDensityCount]);
|
||||||
|
|
||||||
|
const logisticsFeatureCollection = useMemo<SimpleFeatureCollection>(() => ({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: logisticsFeatures,
|
||||||
|
}), [logisticsFeatures]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapContainerRef.current) return;
|
||||||
|
|
||||||
|
const map = new maplibregl.Map({
|
||||||
|
container: mapContainerRef.current,
|
||||||
|
style: resolvedStyleUrl,
|
||||||
|
center: [-52.75, 4.95],
|
||||||
|
zoom: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addControl(new maplibregl.NavigationControl(), "top-right");
|
||||||
|
mapRef.current = map;
|
||||||
|
popupRef.current = new maplibregl.Popup({
|
||||||
|
closeButton: false,
|
||||||
|
closeOnClick: false,
|
||||||
|
maxWidth: "340px",
|
||||||
|
className: "association-public-map-popup",
|
||||||
|
});
|
||||||
|
|
||||||
|
const bindInteractivity = () => {
|
||||||
|
if (interactiveBoundRef.current) return;
|
||||||
|
interactiveBoundRef.current = true;
|
||||||
|
|
||||||
|
map.on("click", "association-unclustered", (event: MapLayerMouseEvent) => {
|
||||||
|
const feature = event.features?.[0];
|
||||||
|
if (!feature || feature.geometry.type !== "Point") return;
|
||||||
|
const props = feature.properties || {};
|
||||||
|
onSelectAssociation?.(Number(props.id));
|
||||||
|
popupRef.current
|
||||||
|
?.setLngLat(feature.geometry.coordinates as [number, number])
|
||||||
|
.setHTML(buildAssociationPopup(props as FeatureProperties))
|
||||||
|
.addTo(map);
|
||||||
|
});
|
||||||
|
|
||||||
|
map.on("click", "association-clusters", (event: MapLayerMouseEvent) => {
|
||||||
|
const feature = event.features?.[0];
|
||||||
|
if (!feature || feature.geometry.type !== "Point") return;
|
||||||
|
const pointFeature = feature as unknown as PointFeature;
|
||||||
|
const clusterId = pointFeature.properties?.cluster_id;
|
||||||
|
const source = map.getSource("associations") as (GeoJSONSource & {
|
||||||
|
getClusterExpansionZoom?: (clusterId: number, cb: (error: Error | null, zoom: number) => void) => void;
|
||||||
|
}) | undefined;
|
||||||
|
if (!source || clusterId === undefined) return;
|
||||||
|
source.getClusterExpansionZoom?.(Number(clusterId), (error: Error | null, zoom: number) => {
|
||||||
|
if (error) return;
|
||||||
|
map.easeTo({
|
||||||
|
center: pointFeature.geometry.coordinates,
|
||||||
|
zoom,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
map.on("click", "density-circles", (event: MapLayerMouseEvent) => {
|
||||||
|
const feature = event.features?.[0];
|
||||||
|
if (!feature || feature.geometry.type !== "Point") return;
|
||||||
|
popupRef.current
|
||||||
|
?.setLngLat(feature.geometry.coordinates as [number, number])
|
||||||
|
.setHTML(buildDensityPopup(feature.properties as FeatureProperties))
|
||||||
|
.addTo(map);
|
||||||
|
});
|
||||||
|
|
||||||
|
map.on("click", "logistics-circles", (event: MapLayerMouseEvent) => {
|
||||||
|
const feature = event.features?.[0];
|
||||||
|
if (!feature || feature.geometry.type !== "Point") return;
|
||||||
|
const requestId = Number(feature.properties?.requestId);
|
||||||
|
if (logisticsMarkerClickModeRef.current === "open-direct") {
|
||||||
|
onSelectLogisticsRef.current?.(requestId);
|
||||||
|
onOpenLogisticsRequestRef.current?.(requestId);
|
||||||
|
} else if (selectedLogisticsRequestIdRef.current === requestId) {
|
||||||
|
onOpenLogisticsRequestRef.current?.(requestId);
|
||||||
|
} else {
|
||||||
|
onSelectLogisticsRef.current?.(requestId);
|
||||||
|
}
|
||||||
|
popupRef.current
|
||||||
|
?.setLngLat(feature.geometry.coordinates as [number, number])
|
||||||
|
.setHTML(buildLogisticsPopup(feature.properties as FeatureProperties))
|
||||||
|
.addTo(map);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const layerId of ["association-unclustered", "association-clusters", "density-circles", "logistics-circles"]) {
|
||||||
|
map.on("mouseenter", layerId, () => {
|
||||||
|
map.getCanvas().style.cursor = "pointer";
|
||||||
|
});
|
||||||
|
map.on("mouseleave", layerId, () => {
|
||||||
|
map.getCanvas().style.cursor = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureLayers = () => {
|
||||||
|
if (!map.getSource("associations")) {
|
||||||
|
map.addSource("associations", {
|
||||||
|
type: "geojson",
|
||||||
|
data: associationFeatureCollection,
|
||||||
|
cluster: true,
|
||||||
|
clusterMaxZoom: 10,
|
||||||
|
clusterRadius: 50,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!map.getLayer("association-clusters")) {
|
||||||
|
map.addLayer({
|
||||||
|
id: "association-clusters",
|
||||||
|
type: "circle",
|
||||||
|
source: "associations",
|
||||||
|
filter: ["has", "point_count"],
|
||||||
|
paint: {
|
||||||
|
"circle-color": "#0f5c89",
|
||||||
|
"circle-radius": [
|
||||||
|
"step",
|
||||||
|
["get", "point_count"],
|
||||||
|
18,
|
||||||
|
10,
|
||||||
|
24,
|
||||||
|
30,
|
||||||
|
30,
|
||||||
|
],
|
||||||
|
"circle-stroke-width": 3,
|
||||||
|
"circle-stroke-color": "#ffffff",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!map.getLayer("association-cluster-count")) {
|
||||||
|
map.addLayer({
|
||||||
|
id: "association-cluster-count",
|
||||||
|
type: "symbol",
|
||||||
|
source: "associations",
|
||||||
|
filter: ["has", "point_count"],
|
||||||
|
layout: {
|
||||||
|
"text-field": ["get", "point_count_abbreviated"],
|
||||||
|
"text-size": 12,
|
||||||
|
"text-font": ["Open Sans Bold"],
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
"text-color": "#ffffff",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!map.getLayer("association-unclustered")) {
|
||||||
|
map.addLayer({
|
||||||
|
id: "association-unclustered",
|
||||||
|
type: "circle",
|
||||||
|
source: "associations",
|
||||||
|
filter: ["!", ["has", "point_count"]],
|
||||||
|
paint: {
|
||||||
|
"circle-color": ["coalesce", ["get", "color"], "#0f5c89"],
|
||||||
|
"circle-radius": ["coalesce", ["get", "radius"], 8],
|
||||||
|
"circle-stroke-width": 2,
|
||||||
|
"circle-stroke-color": "#ffffff",
|
||||||
|
"circle-opacity": 0.95,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getSource("density")) {
|
||||||
|
map.addSource("density", {
|
||||||
|
type: "geojson",
|
||||||
|
data: densityFeatureCollection,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!map.getLayer("density-circles")) {
|
||||||
|
map.addLayer({
|
||||||
|
id: "density-circles",
|
||||||
|
type: "circle",
|
||||||
|
source: "density",
|
||||||
|
paint: {
|
||||||
|
"circle-color": ["coalesce", ["get", "color"], "#2563eb"],
|
||||||
|
"circle-radius": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
["zoom"],
|
||||||
|
5,
|
||||||
|
14,
|
||||||
|
12,
|
||||||
|
32,
|
||||||
|
],
|
||||||
|
"circle-opacity": 0.28,
|
||||||
|
"circle-stroke-width": 1.5,
|
||||||
|
"circle-stroke-color": ["coalesce", ["get", "color"], "#2563eb"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!map.getLayer("density-labels")) {
|
||||||
|
map.addLayer({
|
||||||
|
id: "density-labels",
|
||||||
|
type: "symbol",
|
||||||
|
source: "density",
|
||||||
|
layout: {
|
||||||
|
"text-field": ["get", "label"],
|
||||||
|
"text-size": 13,
|
||||||
|
"text-font": ["Open Sans Bold"],
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
"text-color": "#0f172a",
|
||||||
|
"text-halo-color": "#ffffff",
|
||||||
|
"text-halo-width": 1.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getSource("logistics")) {
|
||||||
|
map.addSource("logistics", {
|
||||||
|
type: "geojson",
|
||||||
|
data: logisticsFeatureCollection,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!map.getLayer("logistics-circles")) {
|
||||||
|
map.addLayer({
|
||||||
|
id: "logistics-circles",
|
||||||
|
type: "circle",
|
||||||
|
source: "logistics",
|
||||||
|
paint: {
|
||||||
|
"circle-color": ["coalesce", ["get", "color"], "#0f5c89"],
|
||||||
|
"circle-radius": ["coalesce", ["get", "radius"], 9],
|
||||||
|
"circle-stroke-width": 2,
|
||||||
|
"circle-stroke-color": "#ffffff",
|
||||||
|
"circle-opacity": 0.95,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getSource("radius")) {
|
||||||
|
map.addSource("radius", {
|
||||||
|
type: "geojson",
|
||||||
|
data: {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!map.getLayer("radius-fill")) {
|
||||||
|
map.addLayer({
|
||||||
|
id: "radius-fill",
|
||||||
|
type: "fill",
|
||||||
|
source: "radius",
|
||||||
|
paint: {
|
||||||
|
"fill-color": "#93c5fd",
|
||||||
|
"fill-opacity": 0.12,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!map.getLayer("radius-outline")) {
|
||||||
|
map.addLayer({
|
||||||
|
id: "radius-outline",
|
||||||
|
type: "line",
|
||||||
|
source: "radius",
|
||||||
|
paint: {
|
||||||
|
"line-color": "#2563eb",
|
||||||
|
"line-width": 2,
|
||||||
|
"line-opacity": 0.7,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bindInteractivity();
|
||||||
|
};
|
||||||
|
|
||||||
|
map.on("load", ensureLayers);
|
||||||
|
map.on("styledata", ensureLayers);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
popupRef.current?.remove();
|
||||||
|
popupRef.current = null;
|
||||||
|
interactiveBoundRef.current = false;
|
||||||
|
map.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
};
|
||||||
|
}, [resolvedStyleUrl, onSelectAssociation, onSelectLogistics]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !map.isStyleLoaded()) return;
|
||||||
|
|
||||||
|
updateGeoJsonSource(map, "associations", associationFeatureCollection);
|
||||||
|
updateGeoJsonSource(map, "density", densityFeatureCollection);
|
||||||
|
updateGeoJsonSource(map, "logistics", logisticsFeatureCollection);
|
||||||
|
}, [associationFeatureCollection, densityFeatureCollection, logisticsFeatureCollection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !map.isStyleLoaded()) return;
|
||||||
|
|
||||||
|
const radiusFeatureCollection: SimpleFeatureCollection = radiusCenter && radiusKm > 0
|
||||||
|
? {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: "Feature",
|
||||||
|
properties: {},
|
||||||
|
geometry: {
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [Array.from({ length: 65 }, (_, index) => {
|
||||||
|
const angle = (index / 64) * Math.PI * 2;
|
||||||
|
const earthRadius = 6371;
|
||||||
|
const lat = radiusCenter.latitude + (radiusKm / earthRadius) * (180 / Math.PI) * Math.sin(angle);
|
||||||
|
const lng = radiusCenter.longitude + (radiusKm / earthRadius) * (180 / Math.PI) * Math.cos(angle) / Math.cos((radiusCenter.latitude * Math.PI) / 180);
|
||||||
|
return [lng, lat];
|
||||||
|
})],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: { type: "FeatureCollection", features: [] };
|
||||||
|
|
||||||
|
updateGeoJsonSource(map, "radius", radiusFeatureCollection);
|
||||||
|
}, [radiusCenter, radiusKm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !map.isStyleLoaded()) return;
|
||||||
|
const visibilityByMode: Record<typeof mode, { associations: "visible" | "none"; density: "visible" | "none"; logistics: "visible" | "none" }> = {
|
||||||
|
annuaire: { associations: "visible", density: "none", logistics: "none" },
|
||||||
|
densite: { associations: "none", density: "visible", logistics: "none" },
|
||||||
|
logistique: { associations: "none", density: "none", logistics: "visible" },
|
||||||
|
};
|
||||||
|
const visibility = visibilityByMode[mode];
|
||||||
|
|
||||||
|
const setVisibility = (layerId: string, value: "visible" | "none") => {
|
||||||
|
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, "visibility", value);
|
||||||
|
};
|
||||||
|
|
||||||
|
setVisibility("association-clusters", visibility.associations);
|
||||||
|
setVisibility("association-cluster-count", visibility.associations);
|
||||||
|
setVisibility("association-unclustered", visibility.associations);
|
||||||
|
setVisibility("density-circles", visibility.density);
|
||||||
|
setVisibility("density-labels", visibility.density);
|
||||||
|
setVisibility("logistics-circles", visibility.logistics);
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !map.isStyleLoaded()) return;
|
||||||
|
|
||||||
|
let bounds: LngLatBoundsLike | null = null;
|
||||||
|
if (mode === "annuaire") {
|
||||||
|
bounds = buildBoundsFromFeatures(associationFeatureCollection.features as PointFeature[]);
|
||||||
|
} else if (mode === "densite") {
|
||||||
|
bounds = buildBoundsFromFeatures(densityFeatureCollection.features as PointFeature[]);
|
||||||
|
} else if (mode === "logistique") {
|
||||||
|
bounds = buildBoundsFromFeatures(logisticsFeatureCollection.features as PointFeature[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bounds) {
|
||||||
|
map.fitBounds(bounds, { padding: 28, maxZoom: 12 });
|
||||||
|
}
|
||||||
|
}, [mode, associationFeatureCollection, densityFeatureCollection, logisticsFeatureCollection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !map.isStyleLoaded() || !selectedAssociationId) return;
|
||||||
|
const feature = (associationFeatureCollection.features as PointFeature[])
|
||||||
|
.find((entry) => Number(entry.properties.id) === selectedAssociationId);
|
||||||
|
if (!feature) return;
|
||||||
|
map.easeTo({
|
||||||
|
center: feature.geometry.coordinates as [number, number],
|
||||||
|
zoom: Math.max(map.getZoom(), 12),
|
||||||
|
});
|
||||||
|
popupRef.current
|
||||||
|
?.setLngLat(feature.geometry.coordinates as [number, number])
|
||||||
|
.setHTML(buildAssociationPopup(feature.properties))
|
||||||
|
.addTo(map);
|
||||||
|
}, [associationFeatureCollection, selectedAssociationId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !map.isStyleLoaded() || !selectedLogisticsRequestId) return;
|
||||||
|
const feature = (logisticsFeatureCollection.features as PointFeature[])
|
||||||
|
.find((entry) => Number(entry.properties.requestId) === selectedLogisticsRequestId);
|
||||||
|
if (!feature) return;
|
||||||
|
map.easeTo({
|
||||||
|
center: feature.geometry.coordinates as [number, number],
|
||||||
|
zoom: Math.max(map.getZoom(), 12),
|
||||||
|
});
|
||||||
|
popupRef.current
|
||||||
|
?.setLngLat(feature.geometry.coordinates as [number, number])
|
||||||
|
.setHTML(buildLogisticsPopup(feature.properties))
|
||||||
|
.addTo(map);
|
||||||
|
}, [logisticsFeatureCollection, selectedLogisticsRequestId]);
|
||||||
|
|
||||||
|
const modeLabel = mode === "annuaire" ? "Annuaire géographique" : mode === "densite" ? "Densité territoriale" : "Pilotage logistique";
|
||||||
|
const helperLabel =
|
||||||
|
mode === "annuaire"
|
||||||
|
? `${validAssociations.length} repère(s) cartographiques`
|
||||||
|
: mode === "densite"
|
||||||
|
? `${communeDensity.length} pôle(s) de lecture`
|
||||||
|
: `${logisticsFeatures.length} mission(s) cartographiée(s)`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<style>{`
|
||||||
|
.association-public-map-popup .maplibregl-popup-content {
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 18px 38px rgba(15, 23, 42, 0.18);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.association-public-map-popup .maplibregl-popup-tip {
|
||||||
|
border-top-color: rgba(255,255,255,0.98);
|
||||||
|
}
|
||||||
|
.association-map-popup-card {
|
||||||
|
min-width: 240px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: rgba(255,255,255,0.98);
|
||||||
|
}
|
||||||
|
.association-map-popup-eyebrow {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
.association-map-popup-title {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
.association-map-popup-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
.association-map-popup-tags,
|
||||||
|
.association-map-popup-state {
|
||||||
|
display: inline-flex;
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 118, 110, 0.1);
|
||||||
|
color: #115e59;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.association-map-popup-body {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
.association-map-popup-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.association-map-popup-actions a {
|
||||||
|
color: #0f5c89;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.association-map-popup-actions a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div className="pointer-events-none absolute left-4 top-4 z-10 flex max-w-[68%] flex-wrap gap-2">
|
||||||
|
<span className="rounded-full border border-white/60 bg-slate-950/78 px-3 py-1 text-xs font-medium text-white backdrop-blur">
|
||||||
|
{modeLabel}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-white/70 bg-white/88 px-3 py-1 text-xs font-medium text-slate-700 shadow-sm backdrop-blur">
|
||||||
|
{helperLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pointer-events-none absolute bottom-4 left-4 z-10 hidden max-w-[70%] rounded-xl border border-white/70 bg-white/88 px-4 py-3 text-xs text-slate-600 shadow-sm backdrop-blur md:block">
|
||||||
|
{mode === "annuaire"
|
||||||
|
? "Clique sur un point ou une fiche pour centrer l’association et ouvrir sa lecture rapide."
|
||||||
|
: mode === "densite"
|
||||||
|
? "Les cercles plus intenses indiquent les communes où la présence associative est la plus dense."
|
||||||
|
: "La lecture logistique aide à repérer où se concentrent les missions terrain et les restitutions."}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={mapContainerRef}
|
||||||
|
className={`${heightClassName} w-full overflow-hidden rounded-2xl border border-slate-200 bg-slate-100 shadow-[0_18px_40px_rgba(15,23,42,0.08)]`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
client/src/components/AuthenticatedPageSkeleton.tsx
Normal file
72
client/src/components/AuthenticatedPageSkeleton.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
type AuthenticatedPageSkeletonProps = {
|
||||||
|
hero?: boolean;
|
||||||
|
cards?: number;
|
||||||
|
formRows?: number;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuthenticatedPageSkeleton({
|
||||||
|
hero = true,
|
||||||
|
cards = 2,
|
||||||
|
formRows = 4,
|
||||||
|
message = "Chargement de vos informations...",
|
||||||
|
}: AuthenticatedPageSkeletonProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{hero ? (
|
||||||
|
<Card className="border-primary/10 bg-[linear-gradient(135deg,rgba(245,251,247,0.98),rgba(255,255,255,0.98))]">
|
||||||
|
<CardContent className="flex flex-col gap-4 p-6 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-8 w-72 max-w-full" />
|
||||||
|
<Skeleton className="h-4 w-[28rem] max-w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-primary/10 bg-white/85 p-4 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="h-10 w-10 rounded-full" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-3 w-28" />
|
||||||
|
<Skeleton className="h-3 w-40" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-3">
|
||||||
|
<Skeleton className="h-6 w-52" />
|
||||||
|
<Skeleton className="h-4 w-80 max-w-full" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="rounded-xl border border-dashed border-primary/20 bg-primary/5 px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: formRows }).map((_, index) => (
|
||||||
|
<div key={index} className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-10 w-full rounded-md" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{Array.from({ length: cards }).map((_, index) => (
|
||||||
|
<Card key={index}>
|
||||||
|
<CardContent className="space-y-4 p-6">
|
||||||
|
<Skeleton className="h-5 w-40" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-5/6" />
|
||||||
|
<Skeleton className="h-10 w-32 rounded-md" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1604
client/src/components/CCDSInternalDirectory.tsx
Normal file
1604
client/src/components/CCDSInternalDirectory.tsx
Normal file
File diff suppressed because it is too large
Load diff
1652
client/src/components/ComplianceOperationsPanel.tsx
Normal file
1652
client/src/components/ComplianceOperationsPanel.tsx
Normal file
File diff suppressed because it is too large
Load diff
779
client/src/components/DashboardLayout.tsx
Normal file
779
client/src/components/DashboardLayout.tsx
Normal file
|
|
@ -0,0 +1,779 @@
|
||||||
|
import { useAuth } from "@/_core/hooks/useAuth";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarSeparator,
|
||||||
|
SidebarTrigger,
|
||||||
|
useSidebar,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import { getLoginUrl } from "@/const";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { useViewportFlags } from "@/hooks/useMobile";
|
||||||
|
import { buildRoutePrefetchHandlers } from "@/lib/routePrefetch";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import {
|
||||||
|
BarChart3,
|
||||||
|
Bell,
|
||||||
|
Building2,
|
||||||
|
Calendar,
|
||||||
|
CalendarDays,
|
||||||
|
ClipboardList,
|
||||||
|
Database,
|
||||||
|
FileText,
|
||||||
|
FolderOpen,
|
||||||
|
HelpCircle,
|
||||||
|
LayoutDashboard,
|
||||||
|
Loader2,
|
||||||
|
LogOut,
|
||||||
|
Mail,
|
||||||
|
Package2,
|
||||||
|
PanelLeft,
|
||||||
|
Shield,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { CSSProperties, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Link, useLocation, useSearch } from "wouter";
|
||||||
|
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||||
|
|
||||||
|
const adminMenuItems = [{ icon: Shield, label: "Administration", path: "/admin" }];
|
||||||
|
|
||||||
|
type RoleViewMode = "accueil" | "logistique" | "direction" | "full_admin";
|
||||||
|
|
||||||
|
type SidebarNavItem = {
|
||||||
|
icon: typeof LayoutDashboard;
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
accentClass?: string;
|
||||||
|
badgeCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SidebarNavSection = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
accentClass?: string;
|
||||||
|
items: SidebarNavItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function getRoleViewMode(user: { role?: string | null; canSignSalle?: boolean | null }): RoleViewMode | null {
|
||||||
|
if (user?.role === "accueil") return "accueil";
|
||||||
|
if (user?.role === "logistique_controle") return "logistique";
|
||||||
|
if (user?.role === "admin" || user?.role === "super_admin") return "full_admin";
|
||||||
|
if (user?.role === "directrice" || Boolean(user?.canSignSalle)) return "direction";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAdminSidebarItems(user: {
|
||||||
|
role?: string | null;
|
||||||
|
canSignSalle?: boolean | null;
|
||||||
|
canManageLogistics?: boolean | null;
|
||||||
|
}): SidebarNavItem[] {
|
||||||
|
return buildAdminSidebarSections(user).flatMap((section) => section.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAdminSidebarSections(user: {
|
||||||
|
role?: string | null;
|
||||||
|
canSignSalle?: boolean | null;
|
||||||
|
canManageLogistics?: boolean | null;
|
||||||
|
}, notificationBadgeCount?: number): SidebarNavSection[] {
|
||||||
|
const roleViewMode = getRoleViewMode(user);
|
||||||
|
if (!roleViewMode || roleViewMode === "direction") {
|
||||||
|
return [{ id: "admin", label: "Administration", items: adminMenuItems }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const canAccessAnalytics = user?.role === "accueil" || user?.role === "admin" || user?.role === "super_admin";
|
||||||
|
const canAccessInternalDirectory =
|
||||||
|
user?.role === "accueil" || user?.role === "admin" || user?.role === "super_admin";
|
||||||
|
const canManageLogistics = user?.role === "super_admin" || Boolean(user?.canManageLogistics);
|
||||||
|
const isAccueil = roleViewMode === "accueil";
|
||||||
|
const isLogisticsControl = roleViewMode === "logistique";
|
||||||
|
const userRole = user?.role;
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ id: "dashboard", label: "Tableau de bord", icon: BarChart3, visibleFor: ["accueil", "logistique", "full_admin"], isEnabled: () => true },
|
||||||
|
{ id: "analytics", label: "Indicateurs", icon: TrendingUp, visibleFor: ["accueil", "full_admin"], isEnabled: () => canAccessAnalytics },
|
||||||
|
{ id: "requests", label: "Demandes", icon: ClipboardList, visibleFor: ["accueil", "logistique", "full_admin"], isEnabled: () => true },
|
||||||
|
{ id: "associations", label: "Comptes associations", icon: Building2, visibleFor: ["full_admin"], isEnabled: () => true },
|
||||||
|
{ id: "notifications", label: "Alertes", icon: Bell, visibleFor: ["accueil", "logistique", "full_admin"], isEnabled: () => true },
|
||||||
|
{ id: "calendrier", label: "Calendrier", icon: Calendar, visibleFor: ["accueil", "logistique", "full_admin"], isEnabled: () => true },
|
||||||
|
{ id: "ccds-directory", label: "Répertoire CCDS", icon: Users, visibleFor: ["accueil", "full_admin"], isEnabled: () => canAccessInternalDirectory },
|
||||||
|
{ id: "logistique", label: "Suivi logistique", icon: Package2, visibleFor: ["logistique", "full_admin"], isEnabled: () => canManageLogistics && !isAccueil },
|
||||||
|
{ id: "diffusion", label: "Diffusion matériel", icon: Mail, visibleFor: ["logistique", "full_admin"], isEnabled: () => !isAccueil && userRole !== "directrice" },
|
||||||
|
{ id: "directory", label: "Référentiel associations", icon: Database, visibleFor: ["full_admin"], isEnabled: () => !isLogisticsControl && (userRole === "admin" || userRole === "super_admin") },
|
||||||
|
{ id: "conformite", label: "Conformité", icon: Shield, visibleFor: ["full_admin"], isEnabled: () => userRole === "admin" || userRole === "super_admin" },
|
||||||
|
] satisfies Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: typeof LayoutDashboard;
|
||||||
|
visibleFor: RoleViewMode[];
|
||||||
|
isEnabled: () => boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const visibleItems = items
|
||||||
|
.filter((item) => item.visibleFor.some((value) => value === roleViewMode) && item.isEnabled())
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
icon: item.icon,
|
||||||
|
label: item.label,
|
||||||
|
path:
|
||||||
|
item.id === "dashboard"
|
||||||
|
? "/admin"
|
||||||
|
: `/admin?tab=${item.id}`,
|
||||||
|
accentClass:
|
||||||
|
item.id === "dashboard"
|
||||||
|
? "text-emerald-600"
|
||||||
|
: item.id === "analytics"
|
||||||
|
? "text-sky-600"
|
||||||
|
: item.id === "notifications"
|
||||||
|
? "text-rose-600"
|
||||||
|
: item.id === "calendrier"
|
||||||
|
? "text-amber-600"
|
||||||
|
: item.id === "requests"
|
||||||
|
? "text-indigo-600"
|
||||||
|
: item.id === "associations" || item.id === "ccds-directory"
|
||||||
|
? "text-cyan-600"
|
||||||
|
: item.id === "logistique" || item.id === "diffusion" || item.id === "directory"
|
||||||
|
? "text-orange-600"
|
||||||
|
: "text-violet-600",
|
||||||
|
badgeCount: item.id === "notifications" ? notificationBadgeCount : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const has = (id: string) => visibleItems.find((item) => item.id === id);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "pilotage",
|
||||||
|
label: "Pilotage",
|
||||||
|
accentClass: "text-sky-700",
|
||||||
|
items: [has("dashboard"), has("analytics"), has("notifications"), has("calendrier")].filter(Boolean) as SidebarNavItem[],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "demandes",
|
||||||
|
label: "Dossiers & annuaires",
|
||||||
|
accentClass: "text-indigo-700",
|
||||||
|
items: [has("requests"), has("associations"), has("ccds-directory")].filter(Boolean) as SidebarNavItem[],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "materiel",
|
||||||
|
label: "Logistique & référentiels",
|
||||||
|
accentClass: "text-orange-700",
|
||||||
|
items: [has("logistique"), has("diffusion"), has("directory")].filter(Boolean) as SidebarNavItem[],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "conformite",
|
||||||
|
label: "Conformité",
|
||||||
|
accentClass: "text-violet-700",
|
||||||
|
items: [has("conformite")].filter(Boolean) as SidebarNavItem[],
|
||||||
|
},
|
||||||
|
].filter((section) => section.items.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUserSidebarSections(options: {
|
||||||
|
canAccessAdmin?: boolean | null;
|
||||||
|
}): SidebarNavSection[] {
|
||||||
|
const { canAccessAdmin } = options;
|
||||||
|
|
||||||
|
const sections: SidebarNavSection[] = [
|
||||||
|
{
|
||||||
|
id: "espace",
|
||||||
|
label: "Suivi",
|
||||||
|
accentClass: "text-emerald-700",
|
||||||
|
items: [
|
||||||
|
{ icon: LayoutDashboard, label: "Tableau de bord", path: "/dashboard", accentClass: "text-emerald-600" },
|
||||||
|
{ icon: Building2, label: "Mon association", path: "/dashboard/profile", accentClass: "text-sky-600" },
|
||||||
|
{ icon: FolderOpen, label: "Mes documents", path: "/dashboard/documents", accentClass: "text-cyan-600" },
|
||||||
|
{ icon: ClipboardList, label: "Mes demandes", path: "/dashboard/requests", accentClass: "text-indigo-600" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "demarches",
|
||||||
|
label: "Démarches",
|
||||||
|
accentClass: "text-amber-700",
|
||||||
|
items: [
|
||||||
|
{ icon: FileText, label: "Nouvelle demande", path: "/dashboard/requests/new", accentClass: "text-amber-600" },
|
||||||
|
{ icon: CalendarDays, label: "Réservation de salle", path: "/dashboard/reservation-salle", accentClass: "text-blue-600" },
|
||||||
|
{ icon: Package2, label: "Matériel événementiel", path: "/dashboard/materiel-evenementiel", accentClass: "text-orange-600" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ressources",
|
||||||
|
label: "Référentiels",
|
||||||
|
accentClass: "text-violet-700",
|
||||||
|
items: [
|
||||||
|
{ icon: Users, label: "Annuaire des associations", path: "/dashboard/associations", accentClass: "text-violet-600" },
|
||||||
|
{ icon: HelpCircle, label: "Aide", path: "/faq", accentClass: "text-slate-600" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (canAccessAdmin) {
|
||||||
|
sections.push({
|
||||||
|
id: "administration",
|
||||||
|
label: "Administration",
|
||||||
|
accentClass: "text-slate-700",
|
||||||
|
items: [{ icon: Shield, label: "Administration", path: "/admin", accentClass: "text-slate-700" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIDEBAR_WIDTH_KEY = "sidebar-width";
|
||||||
|
const DEFAULT_WIDTH = 280;
|
||||||
|
const MIN_WIDTH = 200;
|
||||||
|
const MAX_WIDTH = 480;
|
||||||
|
|
||||||
|
type UnsavedChangesGuard = {
|
||||||
|
when: boolean;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
onSave?: () => Promise<boolean | void> | boolean | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DashboardNavigationGuardContextValue = {
|
||||||
|
setUnsavedChangesGuard: (guard: UnsavedChangesGuard | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DashboardNavigationGuardContext = createContext<DashboardNavigationGuardContextValue | null>(null);
|
||||||
|
|
||||||
|
export function useDashboardNavigationGuard() {
|
||||||
|
const context = useContext(DashboardNavigationGuardContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useDashboardNavigationGuard must be used within DashboardLayout.");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||||
|
const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY);
|
||||||
|
return saved ? parseInt(saved, 10) : DEFAULT_WIDTH;
|
||||||
|
});
|
||||||
|
const { loading, user } = useAuth();
|
||||||
|
const [guard, setGuard] = useState<UnsavedChangesGuard | null>(null);
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
const guardContextValue = useMemo(
|
||||||
|
() => ({ setUnsavedChangesGuard: setGuard }),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString());
|
||||||
|
}, [sidebarWidth]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <DashboardLayoutSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="flex flex-col items-center gap-8 p-8 max-w-md w-full">
|
||||||
|
<div className="flex flex-col items-center gap-6">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-center">
|
||||||
|
Connexion requise
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
||||||
|
Connectez-vous pour acceder a votre espace association.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
window.location.href = getLoginUrl();
|
||||||
|
}}
|
||||||
|
size="lg"
|
||||||
|
className="w-full shadow-lg hover:shadow-xl transition-all"
|
||||||
|
>
|
||||||
|
Se connecter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardNavigationGuardContext.Provider value={guardContextValue}>
|
||||||
|
<SidebarProvider
|
||||||
|
open={sidebarOpen}
|
||||||
|
onOpenChange={setSidebarOpen}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": `${sidebarWidth}px`,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DashboardLayoutContent setSidebarWidth={setSidebarWidth} guard={guard}>
|
||||||
|
{children}
|
||||||
|
</DashboardLayoutContent>
|
||||||
|
</SidebarProvider>
|
||||||
|
</DashboardNavigationGuardContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type DashboardLayoutContentProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
setSidebarWidth: (width: number) => void;
|
||||||
|
guard: UnsavedChangesGuard | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function DashboardLayoutContent({
|
||||||
|
children,
|
||||||
|
setSidebarWidth,
|
||||||
|
guard,
|
||||||
|
}: DashboardLayoutContentProps) {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const { appearance } = useTheme();
|
||||||
|
const [location, setLocation] = useLocation();
|
||||||
|
const search = useSearch();
|
||||||
|
const { state, open, setOpen, setOpenMobile, toggleSidebar } = useSidebar();
|
||||||
|
const isCollapsed = state === "collapsed";
|
||||||
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { isMobile, isTablet } = useViewportFlags();
|
||||||
|
const [guardDialogOpen, setGuardDialogOpen] = useState(false);
|
||||||
|
const [isSavingBeforeLeave, setIsSavingBeforeLeave] = useState(false);
|
||||||
|
const pendingActionRef = useRef<(() => void) | null>(null);
|
||||||
|
const canAccessAdmin =
|
||||||
|
user?.role === "accueil" ||
|
||||||
|
user?.role === "logistique_controle" ||
|
||||||
|
user?.role === "admin" ||
|
||||||
|
user?.role === "super_admin" ||
|
||||||
|
user?.role === "directrice" ||
|
||||||
|
Boolean(user?.canSignSalle);
|
||||||
|
const { data: unreadCount } = trpc.notifications.getUnreadCount.useQuery(undefined, {
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
});
|
||||||
|
const currentAdminPath = search ? `${location}?${search}` : location;
|
||||||
|
const isAdminRoute = location === "/admin";
|
||||||
|
const isAdminLikeRoute = isAdminRoute;
|
||||||
|
const notificationBadgeCount = unreadCount && unreadCount > 0 ? unreadCount : undefined;
|
||||||
|
const adminSidebarSections = useMemo(
|
||||||
|
() => buildAdminSidebarSections(user || {}, notificationBadgeCount),
|
||||||
|
[notificationBadgeCount, user]
|
||||||
|
);
|
||||||
|
const adminSidebarItems = useMemo(() => buildAdminSidebarItems(user || {}), [user]);
|
||||||
|
const userSidebarSections = useMemo(
|
||||||
|
() =>
|
||||||
|
buildUserSidebarSections({
|
||||||
|
canAccessAdmin,
|
||||||
|
}),
|
||||||
|
[canAccessAdmin]
|
||||||
|
);
|
||||||
|
const allMenuItems = isAdminLikeRoute
|
||||||
|
? adminSidebarItems
|
||||||
|
: userSidebarSections.flatMap((section) => section.items);
|
||||||
|
const isMenuItemActive = useCallback(
|
||||||
|
(path: string) => {
|
||||||
|
if (path === "/admin") {
|
||||||
|
return currentAdminPath === "/admin";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith("/admin?")) {
|
||||||
|
const currentUrl = new URL(currentAdminPath, window.location.origin);
|
||||||
|
const targetUrl = new URL(path, window.location.origin);
|
||||||
|
return (
|
||||||
|
currentUrl.pathname === "/admin" &&
|
||||||
|
(currentUrl.searchParams.get("tab") || "dashboard") ===
|
||||||
|
(targetUrl.searchParams.get("tab") || "dashboard")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/dashboard") {
|
||||||
|
return location === "/dashboard";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === "/dashboard/requests") {
|
||||||
|
return (
|
||||||
|
location === "/dashboard/requests" ||
|
||||||
|
(/^\/dashboard\/requests\/[^/]+/.test(location) && !location.startsWith("/dashboard/requests/new"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return location === path;
|
||||||
|
},
|
||||||
|
[currentAdminPath, location]
|
||||||
|
);
|
||||||
|
const activeMenuItem = allMenuItems.find((item) => isMenuItemActive(item.path));
|
||||||
|
const portalTitle = appearance.portalTitle?.trim() || "Portail Associations 973";
|
||||||
|
const sidebarSections = isAdminLikeRoute ? adminSidebarSections : userSidebarSections;
|
||||||
|
const displayDensity = appearance.displayDensity || "comfortable";
|
||||||
|
const isCompactDensity = displayDensity === "compact";
|
||||||
|
const isDetailedDensity = displayDensity === "detailed";
|
||||||
|
|
||||||
|
const requestNavigation = useCallback(
|
||||||
|
(action: () => void) => {
|
||||||
|
if (guard?.when) {
|
||||||
|
pendingActionRef.current = action;
|
||||||
|
setGuardDialogOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
action();
|
||||||
|
},
|
||||||
|
[guard]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAdminLikeRoute && !open) {
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
}, [isAdminLikeRoute, isMobile, open, setOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCollapsed) {
|
||||||
|
setIsResizing(false);
|
||||||
|
}
|
||||||
|
}, [isCollapsed]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!isResizing) return;
|
||||||
|
|
||||||
|
const sidebarLeft = sidebarRef.current?.getBoundingClientRect().left ?? 0;
|
||||||
|
const newWidth = e.clientX - sidebarLeft;
|
||||||
|
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
|
||||||
|
setSidebarWidth(newWidth);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsResizing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isResizing) {
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
document.body.style.cursor = "col-resize";
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
document.body.style.cursor = "";
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
};
|
||||||
|
}, [isResizing, setSidebarWidth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!guard?.when) {
|
||||||
|
setGuardDialogOpen(false);
|
||||||
|
pendingActionRef.current = null;
|
||||||
|
}
|
||||||
|
}, [guard?.when]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!guard?.when) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
}, [guard?.when]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!guard?.when) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDocumentClick = (event: MouseEvent) => {
|
||||||
|
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.target instanceof Element ? event.target.closest("a[href]") : null;
|
||||||
|
if (!(target instanceof HTMLAnchorElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.target === "_blank" || target.hasAttribute("download")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawHref = target.getAttribute("href");
|
||||||
|
if (!rawHref || rawHref.startsWith("#") || rawHref.startsWith("mailto:") || rawHref.startsWith("tel:")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextUrl = new URL(rawHref, window.location.origin);
|
||||||
|
const currentUrl = new URL(window.location.href);
|
||||||
|
const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
|
||||||
|
const currentPath = `${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}`;
|
||||||
|
|
||||||
|
if (nextPath === currentPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
requestNavigation(() => {
|
||||||
|
if (nextUrl.origin === currentUrl.origin) {
|
||||||
|
setLocation(nextPath);
|
||||||
|
} else {
|
||||||
|
window.location.assign(nextUrl.href);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("click", handleDocumentClick, true);
|
||||||
|
return () => document.removeEventListener("click", handleDocumentClick, true);
|
||||||
|
}, [guard?.when, requestNavigation, setLocation]);
|
||||||
|
|
||||||
|
const handleContinueWithoutSaving = () => {
|
||||||
|
setGuardDialogOpen(false);
|
||||||
|
const action = pendingActionRef.current;
|
||||||
|
pendingActionRef.current = null;
|
||||||
|
action?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelNavigation = () => {
|
||||||
|
setGuardDialogOpen(false);
|
||||||
|
pendingActionRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAndContinue = async () => {
|
||||||
|
if (!guard?.onSave) {
|
||||||
|
handleContinueWithoutSaving();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSavingBeforeLeave(true);
|
||||||
|
const result = await guard.onSave();
|
||||||
|
if (result === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setGuardDialogOpen(false);
|
||||||
|
const action = pendingActionRef.current;
|
||||||
|
pendingActionRef.current = null;
|
||||||
|
action?.();
|
||||||
|
} finally {
|
||||||
|
setIsSavingBeforeLeave(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="relative" ref={sidebarRef}>
|
||||||
|
<Sidebar
|
||||||
|
collapsible="icon"
|
||||||
|
className="border-r-0"
|
||||||
|
disableTransition={isResizing}
|
||||||
|
>
|
||||||
|
<SidebarHeader className={isDetailedDensity ? "h-[4.5rem] justify-center" : isCompactDensity ? "h-[3.8rem] justify-center" : "h-16 justify-center"}>
|
||||||
|
<div className="flex items-center gap-3 px-2 transition-all w-full">
|
||||||
|
<button
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
className="h-8 w-8 flex items-center justify-center hover:bg-accent rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0"
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
>
|
||||||
|
<PanelLeft className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
{!isCollapsed ? (
|
||||||
|
<Link href="/" className="flex items-center gap-2 min-w-0">
|
||||||
|
{appearance.logoUrl ? (
|
||||||
|
<img
|
||||||
|
src={appearance.logoUrl}
|
||||||
|
alt={portalTitle}
|
||||||
|
className="h-8 w-8 shrink-0 rounded-md object-contain"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span className="font-semibold tracking-tight truncate text-primary">
|
||||||
|
{portalTitle}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</SidebarHeader>
|
||||||
|
|
||||||
|
<SidebarContent className="gap-0">
|
||||||
|
<div className={isDetailedDensity ? "px-3 py-3" : isCompactDensity ? "px-1.5 py-1.5" : "px-2 py-2"}>
|
||||||
|
{sidebarSections.map((section, sectionIndex) => (
|
||||||
|
<div key={section.id}>
|
||||||
|
{sectionIndex > 0 ? <SidebarSeparator className={isDetailedDensity ? "my-3 opacity-70" : isCompactDensity ? "my-1.5 opacity-70" : "my-2 opacity-70"} /> : null}
|
||||||
|
<SidebarGroup className={isDetailedDensity ? "py-1.5" : isCompactDensity ? "py-0" : "py-0.5"}>
|
||||||
|
{!isCollapsed ? (
|
||||||
|
<SidebarGroupLabel
|
||||||
|
className={`px-2 ${isDetailedDensity ? "pb-2 text-[11px]" : isCompactDensity ? "pb-0.5 text-[9px]" : "pb-1 text-[10px]"} font-semibold uppercase tracking-[0.12em] text-muted-foreground/75 ${section.accentClass ?? ""}`}
|
||||||
|
>
|
||||||
|
{section.label}
|
||||||
|
</SidebarGroupLabel>
|
||||||
|
) : null}
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu className={isDetailedDensity ? "px-0 py-1.5" : isCompactDensity ? "px-0 py-0" : "px-0 py-0.5"}>
|
||||||
|
{section.items.map((item) => {
|
||||||
|
const isActive = isMenuItemActive(item.path);
|
||||||
|
const hasBadge = item.label === "Notifications" && item.badgeCount && item.badgeCount > 0;
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem key={item.path}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
isActive={isActive}
|
||||||
|
tooltip={item.label}
|
||||||
|
className={`${isDetailedDensity ? "h-11 rounded-2xl px-3" : isCompactDensity ? "h-8 rounded-lg px-2" : "h-9 rounded-xl px-2"} font-medium transition-all data-[active=true]:shadow-sm`}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={item.path}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
requestNavigation(() => {
|
||||||
|
if (isMobile) {
|
||||||
|
setOpenMobile(false);
|
||||||
|
}
|
||||||
|
setLocation(item.path);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
{...buildRoutePrefetchHandlers(item.path)}
|
||||||
|
className={`flex w-full items-center ${isDetailedDensity ? "gap-3" : isCompactDensity ? "gap-2" : "gap-2.5"}`}
|
||||||
|
>
|
||||||
|
<item.icon
|
||||||
|
className={`${isDetailedDensity ? "h-[1.05rem] w-[1.05rem]" : "h-4 w-4"} shrink-0 transition-colors ${isActive ? "text-primary" : item.accentClass ?? "text-muted-foreground"}`}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||||
|
{!isCollapsed && hasBadge ? (
|
||||||
|
<Badge className="min-w-5 rounded-full px-1.5 py-0 text-[10px] leading-4 shadow-sm">
|
||||||
|
{item.badgeCount && item.badgeCount > 99 ? "99+" : item.badgeCount}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</a>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SidebarContent>
|
||||||
|
|
||||||
|
<SidebarFooter className={isDetailedDensity ? "p-4" : isCompactDensity ? "p-2.5" : "p-3"}>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button className="flex items-center gap-3 rounded-lg px-1 py-1 hover:bg-accent/50 transition-colors w-full text-left group-data-[collapsible=icon]:justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||||
|
<Avatar className="h-9 w-9 border shrink-0">
|
||||||
|
<AvatarFallback className="text-xs font-medium">
|
||||||
|
{user?.name?.charAt(0).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 min-w-0 group-data-[collapsible=icon]:hidden">
|
||||||
|
<p className="text-sm font-medium truncate leading-none">
|
||||||
|
{user?.name || "-"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate mt-1.5">
|
||||||
|
{user?.email || "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => requestNavigation(() => void logout())}
|
||||||
|
className="cursor-pointer text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
<span>Sign out</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
<div
|
||||||
|
className={`absolute top-0 right-0 h-full w-1 cursor-col-resize transition-colors hover:bg-primary/20 ${isCollapsed || isTablet ? "hidden" : ""}`}
|
||||||
|
onMouseDown={() => {
|
||||||
|
if (isCollapsed || isTablet) return;
|
||||||
|
setIsResizing(true);
|
||||||
|
}}
|
||||||
|
style={{ zIndex: 50 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SidebarInset className="min-w-0 overflow-x-hidden">
|
||||||
|
{isMobile && (
|
||||||
|
<div className={`sticky top-0 z-40 flex ${isDetailedDensity ? "h-16 px-4" : isCompactDensity ? "h-[3.25rem] px-3" : "h-14 px-3"} items-center justify-between border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:backdrop-blur`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SidebarTrigger className="h-9 w-9 rounded-lg bg-background" />
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{appearance.logoUrl ? (
|
||||||
|
<img
|
||||||
|
src={appearance.logoUrl}
|
||||||
|
alt={portalTitle}
|
||||||
|
className="h-7 w-7 shrink-0 rounded-md object-contain"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<span className="truncate tracking-tight text-foreground">
|
||||||
|
{activeMenuItem?.label ?? "Menu"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<main className={`portal-immersive-main min-w-0 flex-1 overflow-x-hidden ${isDetailedDensity ? "p-4 sm:p-5 lg:p-7" : isCompactDensity ? "p-2.5 sm:p-3.5 lg:p-5" : "p-3 sm:p-4 lg:p-6"}`}>{children}</main>
|
||||||
|
</SidebarInset>
|
||||||
|
<Dialog open={guardDialogOpen} onOpenChange={setGuardDialogOpen}>
|
||||||
|
<DialogContent showCloseButton={false}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{guard?.title || "Modifications non enregistrées"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{guard?.message ||
|
||||||
|
"Vous avez des modifications non enregistrées ou une fiche incomplète. Souhaitez-vous enregistrer vos changements avant de quitter cette page ?"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="flex-col gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<Button type="button" variant="outline" onClick={handleCancelNavigation} disabled={isSavingBeforeLeave}>
|
||||||
|
Annuler et revenir au formulaire
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="secondary" onClick={handleContinueWithoutSaving} disabled={isSavingBeforeLeave}>
|
||||||
|
Continuer sans enregistrer
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => void handleSaveAndContinue()} disabled={isSavingBeforeLeave}>
|
||||||
|
{isSavingBeforeLeave ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||||
|
Enregistrer et continuer
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
client/src/components/DashboardLayoutSkeleton.tsx
Normal file
46
client/src/components/DashboardLayoutSkeleton.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { Skeleton } from './ui/skeleton';
|
||||||
|
|
||||||
|
export function DashboardLayoutSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-background">
|
||||||
|
{/* Sidebar skeleton */}
|
||||||
|
<div className="w-[280px] border-r border-border bg-background p-4 space-y-6">
|
||||||
|
{/* Logo area */}
|
||||||
|
<div className="flex items-center gap-3 px-2">
|
||||||
|
<Skeleton className="h-8 w-8 rounded-md" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Menu items */}
|
||||||
|
<div className="space-y-2 px-2">
|
||||||
|
<Skeleton className="h-10 w-full rounded-lg" />
|
||||||
|
<Skeleton className="h-10 w-full rounded-lg" />
|
||||||
|
<Skeleton className="h-10 w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User profile area at bottom */}
|
||||||
|
<div className="absolute bottom-4 left-4 right-4">
|
||||||
|
<div className="flex items-center gap-3 px-1">
|
||||||
|
<Skeleton className="h-9 w-9 rounded-full" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
<Skeleton className="h-2 w-32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content skeleton */}
|
||||||
|
<div className="flex-1 p-4 space-y-4">
|
||||||
|
{/* Content blocks */}
|
||||||
|
<Skeleton className="h-12 w-48 rounded-lg" />
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Skeleton className="h-32 rounded-xl" />
|
||||||
|
<Skeleton className="h-32 rounded-xl" />
|
||||||
|
<Skeleton className="h-32 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-64 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
client/src/components/DataPrivacyValidationDialog.tsx
Normal file
129
client/src/components/DataPrivacyValidationDialog.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { Link } from "wouter";
|
||||||
|
import { ArrowRight, ShieldCheck } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { dataPrivacyConsentContextLabels, type DataPrivacyConsentContext } from "@shared/privacyCompliance";
|
||||||
|
|
||||||
|
type DataPrivacyValidationDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
context: DataPrivacyConsentContext;
|
||||||
|
confirmLabel?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DataPrivacyValidationDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
context,
|
||||||
|
confirmLabel = "Valider",
|
||||||
|
onConfirm,
|
||||||
|
}: DataPrivacyValidationDialogProps) {
|
||||||
|
const [acknowledged, setAcknowledged] = useState(false);
|
||||||
|
|
||||||
|
const contextLabel = useMemo(() => dataPrivacyConsentContextLabels[context], [context]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
onOpenChange(nextOpen);
|
||||||
|
if (!nextOpen) {
|
||||||
|
setAcknowledged(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-h-[92vh] max-w-2xl overflow-hidden p-0 sm:max-h-[88vh]">
|
||||||
|
<div className="border-b bg-emerald-50/80 px-4 py-4 sm:px-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 rounded-full bg-emerald-100 p-2 text-emerald-700">
|
||||||
|
<ShieldCheck className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<DialogHeader className="text-left">
|
||||||
|
<DialogTitle>Validation des données personnelles</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Avant de poursuivre avec l’action <span className="font-medium text-foreground">{contextLabel}</span>, merci de confirmer la gestion des données personnelles.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[calc(92vh-190px)] space-y-4 overflow-y-auto px-4 py-5 sm:max-h-[calc(88vh-190px)] sm:px-6">
|
||||||
|
<div className="rounded-xl border border-emerald-200 bg-emerald-50/70 px-4 py-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-700">Action à valider</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold leading-6 text-slate-950">{confirmLabel}</p>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-slate-600">
|
||||||
|
Cette confirmation porte sur l’action métier suivante : <span className="font-medium text-slate-900">{contextLabel}</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-muted/20 p-4 text-sm text-muted-foreground">
|
||||||
|
Le portail collecte uniquement les informations nécessaires au traitement des dossiers associatifs, à l’instruction administrative, au suivi documentaire et à la sécurité opérationnelle du service.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-3 text-sm text-foreground">
|
||||||
|
<li>Les données saisies sont utilisées pour traiter les demandes, gérer les pièces jointes et assurer le suivi du portail.</li>
|
||||||
|
<li>Les accès sont limités selon les rôles internes et les actions sensibles sont tracées dans le journal du portail.</li>
|
||||||
|
<li>Les personnes concernées disposent d’un droit d’accès, de rectification, d’opposition et de suppression selon le RGPD.</li>
|
||||||
|
<li>Le portail applique une logique de minimisation des données et de séparation des accès en cohérence avec les bonnes pratiques ISO 27001.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-primary/15 bg-primary/5 p-4 text-sm">
|
||||||
|
<p className="font-medium">En savoir plus</p>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
Consulte la page de confidentialité pour les finalités, la base légale, les durées de conservation et les modalités d’exercice des droits.
|
||||||
|
</p>
|
||||||
|
<Link href="/confidentialite" className="mt-2 inline-flex text-sm font-medium text-primary underline underline-offset-4">
|
||||||
|
Ouvrir la politique de confidentialité
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 rounded-lg border p-4">
|
||||||
|
<Checkbox
|
||||||
|
checked={acknowledged}
|
||||||
|
onCheckedChange={(value) => setAcknowledged(Boolean(value))}
|
||||||
|
aria-label="Je confirme avoir pris connaissance des informations relatives aux données personnelles"
|
||||||
|
/>
|
||||||
|
<span className="text-sm leading-6">
|
||||||
|
Je confirme avoir pris connaissance des informations relatives aux données personnelles et je poursuis cette action en connaissance de cause.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="border-t bg-background/98 px-4 py-4 shadow-[0_-14px_28px_-24px_rgba(15,23,42,0.35)] backdrop-blur sm:px-6">
|
||||||
|
<div className="w-full rounded-xl border border-slate-200 bg-slate-50/85 px-3 py-2 text-sm text-slate-600 sm:mr-auto sm:max-w-md">
|
||||||
|
{acknowledged
|
||||||
|
? `La confirmation est prête. Cliquez sur « ${confirmLabel} » pour poursuivre.`
|
||||||
|
: "Cochez la confirmation ci-dessus, puis validez l’action pour continuer."}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} className="w-full sm:w-auto">
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
onConfirm();
|
||||||
|
setAcknowledged(false);
|
||||||
|
}}
|
||||||
|
disabled={!acknowledged}
|
||||||
|
className="w-full gap-2 sm:w-auto"
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
249
client/src/components/DirectionsMenu.tsx
Normal file
249
client/src/components/DirectionsMenu.tsx
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
|
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer";
|
||||||
|
import { useViewportFlags } from "@/hooks/useMobile";
|
||||||
|
import { buildDirectionsOptions, type DirectionsProvider } from "@/lib/mapDirections";
|
||||||
|
import type { VariantProps } from "class-variance-authority";
|
||||||
|
import { ChevronDown, Compass, ExternalLink, Globe, MapPinned, Navigation, Route, Smartphone } from "lucide-react";
|
||||||
|
|
||||||
|
type DirectionsMenuProps = {
|
||||||
|
latitude?: number | null;
|
||||||
|
longitude?: number | null;
|
||||||
|
destinationLabel?: string | null;
|
||||||
|
travelMode?: "driving" | "walking" | "bicycling" | "transit";
|
||||||
|
label?: string;
|
||||||
|
align?: "start" | "center" | "end";
|
||||||
|
buttonVariant?: VariantProps<typeof buttonVariants>["variant"];
|
||||||
|
buttonSize?: VariantProps<typeof buttonVariants>["size"];
|
||||||
|
className?: string;
|
||||||
|
providerLabels?: Partial<Record<DirectionsProvider, string>>;
|
||||||
|
usageContext?: "generic" | "public" | "admin-logistics" | "directory";
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDirectionsCopy(usageContext: NonNullable<DirectionsMenuProps["usageContext"]>) {
|
||||||
|
switch (usageContext) {
|
||||||
|
case "public":
|
||||||
|
return {
|
||||||
|
triggerLabel: "Rejoindre l’association",
|
||||||
|
drawerTitle: "Rejoindre l’association",
|
||||||
|
drawerDescription: "Choisis l’application la plus pratique selon ton appareil pour rejoindre cette association.",
|
||||||
|
desktopLabel: "Choisir l’application",
|
||||||
|
};
|
||||||
|
case "admin-logistics":
|
||||||
|
return {
|
||||||
|
triggerLabel: "Aller sur le terrain",
|
||||||
|
drawerTitle: "Aller sur le terrain",
|
||||||
|
drawerDescription: "Choisis l’application de navigation la plus adaptée pour rejoindre ce point logistique.",
|
||||||
|
desktopLabel: "Choisir l’application",
|
||||||
|
};
|
||||||
|
case "directory":
|
||||||
|
return {
|
||||||
|
triggerLabel: "Ouvrir le point",
|
||||||
|
drawerTitle: "Ouvrir le point",
|
||||||
|
drawerDescription: "Choisis une app de navigation pour ouvrir ce point sans quitter le repère métier.",
|
||||||
|
desktopLabel: "Choisir l’application",
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
triggerLabel: "Ouvrir avec",
|
||||||
|
drawerTitle: "Ouvrir l’itinéraire",
|
||||||
|
drawerDescription: "Choisis l’application la plus pratique selon ton appareil pour rejoindre ce point.",
|
||||||
|
desktopLabel: "Choisir l’application",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlatformDirectionsOrder(isMobile: boolean) {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return ["google", "apple", "waze"] as DirectionsProvider[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAgent = window.navigator.userAgent.toLowerCase();
|
||||||
|
const isAppleMobile = /iphone|ipad|ipod/.test(userAgent);
|
||||||
|
const isAndroidMobile = /android/.test(userAgent);
|
||||||
|
|
||||||
|
if (isMobile && isAppleMobile) {
|
||||||
|
return ["apple", "google", "waze"] as DirectionsProvider[];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile && isAndroidMobile) {
|
||||||
|
return ["google", "waze", "apple"] as DirectionsProvider[];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ["google", "apple", "waze"] as DirectionsProvider[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDirectionsIcon(provider: DirectionsProvider) {
|
||||||
|
switch (provider) {
|
||||||
|
case "apple":
|
||||||
|
return Compass;
|
||||||
|
case "waze":
|
||||||
|
return Route;
|
||||||
|
default:
|
||||||
|
return Navigation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDirectionsSubtitle(provider: DirectionsProvider, isMobile: boolean) {
|
||||||
|
if (provider === "apple") {
|
||||||
|
return isMobile ? "Ouvrir dans l’app native Apple Plans" : "Ouvrir dans Apple Plans";
|
||||||
|
}
|
||||||
|
if (provider === "google") {
|
||||||
|
return isMobile ? "Ouvrir dans l’app native Google Maps" : "Ouvrir dans Google Maps";
|
||||||
|
}
|
||||||
|
return isMobile ? "Ouvrir dans l’app native Waze" : "Ouvrir dans Waze";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDirectionsPlatformMeta(provider: DirectionsProvider, isMobile: boolean) {
|
||||||
|
if (provider === "apple") {
|
||||||
|
return {
|
||||||
|
label: isMobile ? "iPhone / iPad" : "Apple",
|
||||||
|
Icon: Smartphone,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === "google") {
|
||||||
|
return {
|
||||||
|
label: isMobile ? "Android & web" : "Web & mobile",
|
||||||
|
Icon: Globe,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: isMobile ? "Trafic en direct" : "Navigation",
|
||||||
|
Icon: Route,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DirectionsMenu({
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
destinationLabel,
|
||||||
|
travelMode = "driving",
|
||||||
|
label = "Ouvrir avec",
|
||||||
|
align = "start",
|
||||||
|
buttonVariant = "outline",
|
||||||
|
buttonSize = "sm",
|
||||||
|
className,
|
||||||
|
providerLabels,
|
||||||
|
usageContext = "generic",
|
||||||
|
}: DirectionsMenuProps) {
|
||||||
|
const { isMobile } = useViewportFlags();
|
||||||
|
const copy = getDirectionsCopy(usageContext);
|
||||||
|
const options = buildDirectionsOptions({
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
destinationLabel,
|
||||||
|
travelMode,
|
||||||
|
}).map((option) => ({
|
||||||
|
...option,
|
||||||
|
label: providerLabels?.[option.provider] || option.label,
|
||||||
|
}));
|
||||||
|
const orderedProviders = getPlatformDirectionsOrder(isMobile);
|
||||||
|
const orderedOptions = [...options].sort(
|
||||||
|
(left, right) => orderedProviders.indexOf(left.provider) - orderedProviders.indexOf(right.provider)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orderedOptions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<Button type="button" variant={buttonVariant} size={buttonSize} className={className}>
|
||||||
|
<MapPinned className="mr-2 h-4 w-4" />
|
||||||
|
{label || copy.triggerLabel}
|
||||||
|
<ChevronDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Drawer>
|
||||||
|
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||||
|
<DrawerContent className="max-h-[82vh] rounded-t-3xl border-slate-200">
|
||||||
|
<DrawerHeader className="border-b border-slate-100 px-4 pb-4 text-left">
|
||||||
|
<DrawerTitle className="text-base">{copy.drawerTitle}</DrawerTitle>
|
||||||
|
<DrawerDescription>
|
||||||
|
{copy.drawerDescription}
|
||||||
|
</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="space-y-2 px-4 py-4">
|
||||||
|
{orderedOptions.map((option, index) => {
|
||||||
|
const OptionIcon = getDirectionsIcon(option.provider);
|
||||||
|
const PlatformIcon = getDirectionsPlatformMeta(option.provider, true).Icon;
|
||||||
|
const platformLabel = getDirectionsPlatformMeta(option.provider, true).label;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={option.provider}
|
||||||
|
href={option.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex min-h-16 items-center justify-between rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-900 shadow-sm transition-colors hover:border-primary/30 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||||
|
<OptionIcon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p>{option.label}</p>
|
||||||
|
{index === 0 && (
|
||||||
|
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-semibold text-primary">
|
||||||
|
Recommandé
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-normal text-slate-500">
|
||||||
|
{getDirectionsSubtitle(option.provider, true)}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 pt-0.5">
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[11px] font-medium text-slate-600">
|
||||||
|
<PlatformIcon className="h-3 w-3" />
|
||||||
|
{platformLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ExternalLink className="h-4 w-4 text-slate-400" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
{trigger}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align={align} className="min-w-[220px]">
|
||||||
|
<DropdownMenuLabel>{copy.desktopLabel}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{orderedOptions.map((option) => {
|
||||||
|
const OptionIcon = getDirectionsIcon(option.provider);
|
||||||
|
const platformMeta = getDirectionsPlatformMeta(option.provider, false);
|
||||||
|
const PlatformIcon = platformMeta.Icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem asChild key={option.provider}>
|
||||||
|
<a href={option.href} target="_blank" rel="noopener noreferrer" className="flex items-center justify-between gap-3">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<OptionIcon className="h-4 w-4 text-slate-500" />
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-500">
|
||||||
|
<PlatformIcon className="h-3 w-3" />
|
||||||
|
{platformMeta.label}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
782
client/src/components/DirectorSignatureAdmin.tsx
Normal file
782
client/src/components/DirectorSignatureAdmin.tsx
Normal file
|
|
@ -0,0 +1,782 @@
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import ReservationCalendar from "@/components/ReservationCalendar";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import { formatReservationSallePeriod, getReservationSalleScheduleSummary } from "@/lib/reservationSalle";
|
||||||
|
import { SALLE_WORKFLOW_PAYMENT_STATUS_LABELS, type SalleWorkflowPaymentStatus } from "@shared/sallePricing";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Calendar,
|
||||||
|
CheckCircle,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
RotateCcw,
|
||||||
|
ShieldCheck,
|
||||||
|
UserPlus,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link, useLocation } from "wouter";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
type DirectorSignatureAdminProps = {
|
||||||
|
user: {
|
||||||
|
role?: string | null;
|
||||||
|
canSignSalle?: boolean | null;
|
||||||
|
name?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestTypeLabels: Record<string, string> = {
|
||||||
|
demande_salle: "Réservation de salle",
|
||||||
|
demande_materiel_evenementiel: "Matériel événementiel",
|
||||||
|
subvention_fonctionnement: "Subvention fonctionnement",
|
||||||
|
subvention_projet: "Subvention projet",
|
||||||
|
agrement_jeunesse_education: "Agrément JEP",
|
||||||
|
agrement_sport: "Agrément sport",
|
||||||
|
autorisation_occupation: "Autorisation d'occupation",
|
||||||
|
autre: "Autre",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
brouillon: "Brouillon",
|
||||||
|
soumise: "Soumise",
|
||||||
|
en_cours_traitement: "En cours",
|
||||||
|
information_complementaire: "Information complémentaire",
|
||||||
|
validee: "Validée",
|
||||||
|
refusee: "Refusée",
|
||||||
|
annulee: "Annulée",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDateTime(value: string | Date | null | undefined) {
|
||||||
|
if (!value) return "-";
|
||||||
|
try {
|
||||||
|
return new Date(value).toLocaleString("fr-FR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | Date | null | undefined) {
|
||||||
|
if (!value) return "-";
|
||||||
|
try {
|
||||||
|
return new Date(value).toLocaleDateString("fr-FR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(cents: number | null | undefined) {
|
||||||
|
if (!Number.isFinite(Number(cents))) return "-";
|
||||||
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
}).format(Number(cents) / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentStatusClasses: Record<SalleWorkflowPaymentStatus, string> = {
|
||||||
|
en_attente_paiement: "border-amber-200 bg-amber-100 text-amber-900",
|
||||||
|
paiement_partiel: "border-orange-200 bg-orange-100 text-orange-900",
|
||||||
|
paiement_recu: "border-sky-200 bg-sky-100 text-sky-900",
|
||||||
|
paiement_valide: "border-emerald-200 bg-emerald-100 text-emerald-900",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DirectorSignatureAdmin({ user }: DirectorSignatureAdminProps) {
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
|
const { data: delegation } = trpc.salleSignature.getDelegation.useQuery(undefined, {
|
||||||
|
enabled: user.role === "directrice",
|
||||||
|
});
|
||||||
|
const { data: signatureQueue, isLoading } = trpc.request.getSalleSignatureQueue.useQuery(undefined, {
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
const { data: pastRequests, isLoading: pastRequestsLoading } = trpc.request.getPastForSignature.useQuery();
|
||||||
|
const { data: billingEntries, isLoading: billingEntriesLoading } = trpc.request.getSalleBillingBoard.useQuery(undefined, {
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [candidateUserId, setCandidateUserId] = useState<string>("none");
|
||||||
|
const [delegateEmail, setDelegateEmail] = useState("");
|
||||||
|
const [returnComments, setReturnComments] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCandidateUserId("none");
|
||||||
|
}, [delegation?.delegatedUsers]);
|
||||||
|
|
||||||
|
const setDelegationMutation = trpc.salleSignature.setDelegation.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Délégation de signature mise à jour");
|
||||||
|
utils.salleSignature.getDelegation.invalidate();
|
||||||
|
utils.auth.me.invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || "Impossible d'enregistrer la délégation");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const addDelegateByEmailMutation = trpc.salleSignature.addDelegateByEmail.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Adresse ajoutée aux signataires remplaçants");
|
||||||
|
setDelegateEmail("");
|
||||||
|
utils.salleSignature.getDelegation.invalidate();
|
||||||
|
utils.auth.me.invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || "Impossible d'ajouter cette adresse");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeDelegateMutation = trpc.salleSignature.removeDelegate.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Signataire remplaçant retiré");
|
||||||
|
utils.salleSignature.getDelegation.invalidate();
|
||||||
|
utils.auth.me.invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || "Impossible de retirer cette personne");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalizeSalleReservationMutation = trpc.request.finalizeSalleReservation.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Demande signée. Le dossier revient à l’hôtesse pour l’envoi final.");
|
||||||
|
utils.request.getSalleSignatureQueue.invalidate();
|
||||||
|
utils.request.search.invalidate();
|
||||||
|
utils.request.getPending.invalidate();
|
||||||
|
utils.request.getPastForSignature.invalidate();
|
||||||
|
utils.auth.me.invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || "Impossible de signer la demande");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const returnSalleToHostessMutation = trpc.request.returnSalleToHostess.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Le dossier est revenu à l’hôtesse");
|
||||||
|
utils.request.getSalleSignatureQueue.invalidate();
|
||||||
|
utils.request.search.invalidate();
|
||||||
|
utils.request.getPending.invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || "Impossible de renvoyer le dossier à l’hôtesse");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizedQueue = useMemo(() => {
|
||||||
|
return (signatureQueue || []).map((entry) => {
|
||||||
|
const period = formatReservationSallePeriod({
|
||||||
|
dateReservation: entry.dateReservation,
|
||||||
|
dateFinReservation: entry.dateFinReservation,
|
||||||
|
});
|
||||||
|
const schedule = getReservationSalleScheduleSummary({
|
||||||
|
useDetailedSchedule: Boolean(entry.useDetailedSchedule),
|
||||||
|
horairesParJour: Array.isArray(entry.horairesParJour) ? entry.horairesParJour : [],
|
||||||
|
heureDebut: entry.heureDebut || "",
|
||||||
|
heureFin: entry.heureFin || "",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
period,
|
||||||
|
schedule,
|
||||||
|
paymentLate: Boolean(
|
||||||
|
entry.financialMode === "location_payante"
|
||||||
|
&& entry.paymentDueDate
|
||||||
|
&& !["paiement_recu", "paiement_valide"].includes(entry.paymentStatus || "")
|
||||||
|
&& entry.paymentDueDate < new Date().toISOString().slice(0, 10)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [signatureQueue]);
|
||||||
|
|
||||||
|
const delegatedUsers = delegation?.delegatedUsers || [];
|
||||||
|
const availableCandidates = (delegation?.candidates || []).filter(
|
||||||
|
(candidate) => !delegatedUsers.some((entry) => entry.id === candidate.id)
|
||||||
|
);
|
||||||
|
const billingCounts = useMemo(() => {
|
||||||
|
const entries = billingEntries || [];
|
||||||
|
return {
|
||||||
|
all: entries.length,
|
||||||
|
en_attente: entries.filter((entry) => entry.paymentStatus === "en_attente_paiement").length,
|
||||||
|
en_retard: entries.filter((entry) => entry.isLate).length,
|
||||||
|
recues: entries.filter((entry) => entry.paymentStatus === "paiement_recu").length,
|
||||||
|
validees: entries.filter((entry) => entry.paymentStatus === "paiement_valide").length,
|
||||||
|
};
|
||||||
|
}, [billingEntries]);
|
||||||
|
const directriceIndicators = useMemo(() => {
|
||||||
|
const signedLate = (billingEntries || []).filter((entry) => entry.isLate).length;
|
||||||
|
const locationsPayantes = (billingEntries || []).length;
|
||||||
|
return {
|
||||||
|
aSigner: normalizedQueue.length,
|
||||||
|
paiementsEnAttente: billingCounts.en_attente + billingCounts.en_retard,
|
||||||
|
locationsPayantes,
|
||||||
|
tauxOccupation: locationsPayantes > 0
|
||||||
|
? Math.round((billingCounts.validees / Math.max(locationsPayantes, 1)) * 100)
|
||||||
|
: 0,
|
||||||
|
paiementsCritiques: signedLate,
|
||||||
|
};
|
||||||
|
}, [billingCounts.en_attente, billingCounts.en_retard, billingCounts.validees, billingEntries, normalizedQueue.length]);
|
||||||
|
|
||||||
|
const handleAddExistingDelegate = () => {
|
||||||
|
if (candidateUserId === "none") return;
|
||||||
|
const nextIds = Array.from(
|
||||||
|
new Set(delegatedUsers.map((entry) => entry.id).concat(Number(candidateUserId)))
|
||||||
|
);
|
||||||
|
setDelegationMutation.mutate({ delegateUserIds: nextIds });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveDelegate = (userId: number) => {
|
||||||
|
removeDelegateMutation.mutate({ userId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddDelegateEmail = () => {
|
||||||
|
const normalizedEmail = delegateEmail.trim().toLowerCase();
|
||||||
|
if (!normalizedEmail) {
|
||||||
|
toast.error("Renseigne une adresse email");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addDelegateByEmailMutation.mutate({ email: normalizedEmail });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background px-4 py-8 md:px-8">
|
||||||
|
<div className="mx-auto flex max-w-6xl flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldCheck className="h-5 w-5 text-primary" />
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">Signature des demandes de salle</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Cet espace est réservé à la Directrice et, si besoin, aux personnes désignées pour signer en son absence.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<a href="#indicateurs">
|
||||||
|
<Button variant="outline">
|
||||||
|
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||||
|
Voir les statistiques
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
<a href="#facturation">
|
||||||
|
<Button variant="outline">
|
||||||
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
|
Consulter la facturation
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
<a href="#signature-queue">
|
||||||
|
<Button>
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
|
Signer et valider
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
<a href="#signature-queue">
|
||||||
|
<Button variant="outline">
|
||||||
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
|
Retour à l’accueil
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.location.assign("/admin?tab=ccds-directory");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Users className="mr-2 h-4 w-4" />
|
||||||
|
Annuaire CCDS
|
||||||
|
</Button>
|
||||||
|
<Link href="/dashboard">
|
||||||
|
<Button variant="outline">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Retour au tableau de bord
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card id="indicateurs" className="border-primary/10 bg-gradient-to-br from-primary/5 via-background to-background">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Indicateurs clés Direction</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Les signaux à lire rapidement avant de signer, arbitrer ou vérifier la bonne préparation des dossiers.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="rounded-lg border bg-background px-4 py-4">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">À signer maintenant</p>
|
||||||
|
<p className="mt-2 text-3xl font-semibold">{directriceIndicators.aSigner}</p>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">Dossiers déjà validés par l’association et transmis à la Direction.</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-background px-4 py-4">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Paiements à surveiller</p>
|
||||||
|
<p className="mt-2 text-3xl font-semibold">{directriceIndicators.paiementsEnAttente}</p>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">Locations payantes encore en attente ou en retard avant usage.</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-background px-4 py-4">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Locations payantes</p>
|
||||||
|
<p className="mt-2 text-3xl font-semibold">{directriceIndicators.locationsPayantes}</p>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">Dossiers facturés visibles dans la file de contrôle financier.</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-background px-4 py-4">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Paiements validés</p>
|
||||||
|
<p className="mt-2 text-3xl font-semibold">{directriceIndicators.tauxOccupation}%</p>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">Part des locations payantes déjà validées côté règlement.</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{directriceIndicators.paiementsCritiques > 0 && (
|
||||||
|
<Card className="border-red-200 bg-red-50/70">
|
||||||
|
<CardContent className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-red-800">Paiement non confirmé avant usage des lieux</p>
|
||||||
|
<p className="text-sm text-red-700">
|
||||||
|
{directriceIndicators.paiementsCritiques} dossier(s) facturé(s) présentent un retard ou une validation manquante à vérifier avant utilisation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="#facturation">
|
||||||
|
<Button variant="outline" className="border-red-200 bg-white text-red-800 hover:bg-red-100">
|
||||||
|
Voir la facturation
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user.role === "directrice" && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Délégation de signature</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Ajoute les personnes qui peuvent signer à ta place. L’Accueil peut donc recevoir temporairement ce droit, sans récupérer tout l’accès administrateur.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="delegate-email">Ajouter une adresse email</Label>
|
||||||
|
<Input
|
||||||
|
id="delegate-email"
|
||||||
|
type="email"
|
||||||
|
placeholder="prenom.nom@ccds-guyane.fr"
|
||||||
|
value={delegateEmail}
|
||||||
|
onChange={(event) => setDelegateEmail(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddDelegateEmail}
|
||||||
|
disabled={addDelegateByEmailMutation.isPending}
|
||||||
|
>
|
||||||
|
{addDelegateByEmailMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Ajouter l'adresse
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="delegated-signer">Ajouter une personne déjà interne</Label>
|
||||||
|
<Select value={candidateUserId} onValueChange={setCandidateUserId}>
|
||||||
|
<SelectTrigger id="delegated-signer">
|
||||||
|
<SelectValue placeholder="Choisir une personne, y compris l’Accueil" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">Choisir une personne</SelectItem>
|
||||||
|
{availableCandidates.map((candidate) => (
|
||||||
|
<SelectItem key={candidate.id} value={String(candidate.id)}>
|
||||||
|
{candidate.name} · {candidate.email || "sans email"} · {candidate.role}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleAddExistingDelegate}
|
||||||
|
disabled={candidateUserId === "none" || setDelegationMutation.isPending}
|
||||||
|
>
|
||||||
|
{setDelegationMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Ajouter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label>Signataires remplaçants actifs</Label>
|
||||||
|
{delegatedUsers.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Aucune délégation active pour le moment.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{delegatedUsers.map((entry) => (
|
||||||
|
<div key={entry.id} className="flex flex-col gap-3 rounded-lg border px-4 py-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="font-medium">{entry.name}</p>
|
||||||
|
<Badge variant="outline" className="bg-primary/5 text-primary border-primary/20">
|
||||||
|
Peut signer à ma place
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{entry.email || "Sans email"} · {entry.role}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemoveDelegate(entry.id)}
|
||||||
|
disabled={removeDelegateMutation.isPending}
|
||||||
|
>
|
||||||
|
{removeDelegateMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<X className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Retirer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user.role !== "directrice" && Boolean(user.canSignSalle) && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Signature déléguée active</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Tu peux signer les demandes de salle au nom de la Directrice tant que cette délégation reste active.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card id="signature-queue">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>À signer maintenant</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Chaque dossier ici a déjà été validé par l’association et transmis par l’hôtesse.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : normalizedQueue.length === 0 ? (
|
||||||
|
<div className="rounded-lg border border-dashed px-6 py-10 text-center text-sm text-muted-foreground">
|
||||||
|
Aucun dossier salle n’attend de signature pour le moment.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{normalizedQueue.map((entry) => (
|
||||||
|
<div key={entry.id} className="rounded-lg border p-4">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="font-semibold">{entry.titre}</h3>
|
||||||
|
<Badge className="border border-amber-200 bg-amber-100 text-amber-900">
|
||||||
|
Signature attendue
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{entry.associationName}</p>
|
||||||
|
<div className="grid gap-2 text-sm md:grid-cols-3">
|
||||||
|
<div className="rounded-md border bg-white px-3 py-2">
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">Période</p>
|
||||||
|
<p className="font-medium">{entry.period}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-white px-3 py-2">
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">Horaires</p>
|
||||||
|
<p className="font-medium">{entry.schedule || "-"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border bg-white px-3 py-2">
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">Espaces</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{entry.sallesSelectionnees.length > 0 ? entry.sallesSelectionnees.join(", ") : "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Calendar className="h-3.5 w-3.5" />
|
||||||
|
Transmis le {formatDateTime(entry.directorTransmissionAt)}
|
||||||
|
</span>
|
||||||
|
<span>Montant: {formatCurrency(entry.pricing?.totalAmountCents)}</span>
|
||||||
|
{entry.financialMode === "location_payante" && entry.paymentStatus ? (
|
||||||
|
<Badge className={paymentStatusClasses[entry.paymentStatus as SalleWorkflowPaymentStatus]}>
|
||||||
|
{SALLE_WORKFLOW_PAYMENT_STATUS_LABELS[entry.paymentStatus as SalleWorkflowPaymentStatus]}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{entry.financialMode === "location_payante" && entry.paymentDueDate ? (
|
||||||
|
<span>
|
||||||
|
Paiement attendu avant le {formatDate(entry.paymentDueDate)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{entry.paymentLate ? (
|
||||||
|
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800">
|
||||||
|
Paiement non validé avant la date prévue d’utilisation des lieux.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 md:min-w-[240px]">
|
||||||
|
<Button
|
||||||
|
onClick={() => finalizeSalleReservationMutation.mutate({ id: entry.id })}
|
||||||
|
disabled={finalizeSalleReservationMutation.isPending}
|
||||||
|
>
|
||||||
|
{finalizeSalleReservationMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Signer et valider
|
||||||
|
</Button>
|
||||||
|
<div className="space-y-2 rounded-lg border bg-slate-50 p-3">
|
||||||
|
<Label htmlFor={`return-comment-${entry.id}`} className="text-xs text-muted-foreground">
|
||||||
|
Commentaire de retour à l’accueil
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id={`return-comment-${entry.id}`}
|
||||||
|
placeholder="Explique ici ce que l’hôtesse doit corriger avant une nouvelle transmission."
|
||||||
|
value={returnComments[entry.id] || ""}
|
||||||
|
onChange={(event) => setReturnComments((prev) => ({ ...prev, [entry.id]: event.target.value }))}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => returnSalleToHostessMutation.mutate({ id: entry.id, comment: returnComments[entry.id] || undefined })}
|
||||||
|
disabled={returnSalleToHostessMutation.isPending}
|
||||||
|
>
|
||||||
|
{returnSalleToHostessMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Refuser / Retour à l’accueil
|
||||||
|
</Button>
|
||||||
|
<div className="flex flex-col gap-2 pt-2">
|
||||||
|
{entry.quotePdfUrl ? (
|
||||||
|
<Link href={`/dashboard/requests/${entry.id}/salle-document/devis?returnTo=${encodeURIComponent("/admin")}`}>
|
||||||
|
<Button variant="ghost" className="w-full justify-start">
|
||||||
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
|
Voir le devis
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{entry.administrativeDecisionPdfUrl ? (
|
||||||
|
<Link href={`/dashboard/requests/${entry.id}/salle-document/decision?returnTo=${encodeURIComponent("/admin")}`}>
|
||||||
|
<Button variant="ghost" className="w-full justify-start">
|
||||||
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
|
Voir la décision
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
<Link href={`/dashboard/requests/${entry.id}`}>
|
||||||
|
<Button variant="ghost" className="w-full justify-start">
|
||||||
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
|
Ouvrir le dossier
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card id="facturation">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Facturation des locations</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Vue de contrôle pour vérifier si les associations ont réglé leur location avant l’utilisation des lieux.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant="outline">Toutes : {billingCounts.all}</Badge>
|
||||||
|
<Badge className="border-amber-200 bg-amber-100 text-amber-900">En attente : {billingCounts.en_attente}</Badge>
|
||||||
|
<Badge variant="destructive">En retard : {billingCounts.en_retard}</Badge>
|
||||||
|
<Badge className="border-sky-200 bg-sky-100 text-sky-900">Reçues : {billingCounts.recues}</Badge>
|
||||||
|
<Badge className="border-emerald-200 bg-emerald-100 text-emerald-900">Validées : {billingCounts.validees}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{billingEntriesLoading ? (
|
||||||
|
<div className="flex justify-center py-10">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : !billingEntries?.length ? (
|
||||||
|
<div className="rounded-lg border border-dashed px-6 py-10 text-center text-sm text-muted-foreground">
|
||||||
|
Aucune location payante à suivre pour le moment.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{billingEntries.map((entry) => (
|
||||||
|
<div key={entry.id} className="flex flex-col gap-3 rounded-lg border px-4 py-4 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="font-medium">{entry.titre}</h3>
|
||||||
|
<Badge className={paymentStatusClasses[entry.paymentStatus as SalleWorkflowPaymentStatus]}>
|
||||||
|
{SALLE_WORKFLOW_PAYMENT_STATUS_LABELS[entry.paymentStatus as SalleWorkflowPaymentStatus]}
|
||||||
|
</Badge>
|
||||||
|
{entry.isLate ? <Badge variant="destructive">Retard</Badge> : null}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{entry.associationName}</p>
|
||||||
|
<div className="grid gap-2 text-sm text-muted-foreground md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">Montant dû :</span> {formatCurrency(entry.amountDueCents)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">Montant reçu :</span> {formatCurrency(entry.amountReceivedCents)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">Paiement attendu avant :</span> {formatDate(entry.paymentDueDate)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">Paiement reçu le :</span> {formatDate(entry.paymentReceivedAt)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">Paiement validé le :</span> {formatDate(entry.paymentValidatedAt)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">Usage :</span>{" "}
|
||||||
|
{entry.dateReservation
|
||||||
|
? formatReservationSallePeriod({
|
||||||
|
dateReservation: entry.dateReservation,
|
||||||
|
dateFinReservation: entry.dateFinReservation,
|
||||||
|
})
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{entry.sallesSelectionnees?.length ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Espaces : {entry.sallesSelectionnees.join(", ")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link href={`/dashboard/requests/${entry.id}`}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
|
Ouvrir le dossier
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Calendrier des réservations et demandes matérielles</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
La Directrice peut consulter ici les réservations de salle, les demandes de matériel événementiel et leur état d’avancement.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ReservationCalendar readOnly />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card id="history">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Historique / dossiers passés</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Historique récent des demandes déjà traitées, pour garder une vue d’ensemble avant signature ou en cas d’absence.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{pastRequestsLoading ? (
|
||||||
|
<div className="flex justify-center py-10">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : !pastRequests?.length ? (
|
||||||
|
<div className="rounded-lg border border-dashed px-6 py-10 text-center text-sm text-muted-foreground">
|
||||||
|
Aucune demande passée à afficher pour le moment.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{pastRequests.map((request) => (
|
||||||
|
<div key={request.id} className="flex flex-col gap-3 rounded-lg border px-4 py-4 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="font-medium">{request.titre}</h3>
|
||||||
|
<Badge variant="outline">{requestTypeLabels[request.type] || request.type}</Badge>
|
||||||
|
<Badge className="bg-slate-100 text-slate-800 border border-slate-200">
|
||||||
|
{statusLabels[request.status] || request.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{request.associationName}</p>
|
||||||
|
<div className="grid gap-2 text-sm text-muted-foreground md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">Créée le :</span> {formatDate(request.createdAt)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">Mise à jour :</span> {formatDate(request.updatedAt)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">Période :</span>{" "}
|
||||||
|
{request.dateReservation
|
||||||
|
? formatReservationSallePeriod({
|
||||||
|
dateReservation: request.dateReservation,
|
||||||
|
dateFinReservation: request.dateFinReservation,
|
||||||
|
})
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{request.sallesSelectionnees?.length ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Espaces : {request.sallesSelectionnees.join(", ")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link href={`/dashboard/requests/${request.id}`}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Ouvrir
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
client/src/components/EmptyStateCard.tsx
Normal file
51
client/src/components/EmptyStateCard.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
type EmptyStateCardProps = {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
secondaryActionLabel?: string;
|
||||||
|
onSecondaryAction?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EmptyStateCard({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
|
secondaryActionLabel,
|
||||||
|
onSecondaryAction,
|
||||||
|
}: EmptyStateCardProps) {
|
||||||
|
return (
|
||||||
|
<Card className="border-primary/10 bg-[linear-gradient(135deg,rgba(245,251,247,0.98),rgba(255,255,255,0.98))]">
|
||||||
|
<CardContent className="flex flex-col items-start gap-5 p-6 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||||
|
<Icon className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
||||||
|
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(actionLabel || secondaryActionLabel) ? (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{secondaryActionLabel && onSecondaryAction ? (
|
||||||
|
<Button variant="outline" onClick={onSecondaryAction}>
|
||||||
|
{secondaryActionLabel}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{actionLabel && onAction ? (
|
||||||
|
<Button onClick={onAction}>{actionLabel}</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
client/src/components/ErrorBoundary.tsx
Normal file
68
client/src/components/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { AlertTriangle, RotateCcw } from "lucide-react";
|
||||||
|
import { Component, ReactNode } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen p-8 bg-background">
|
||||||
|
<div className="flex flex-col items-center w-full max-w-2xl p-8">
|
||||||
|
<AlertTriangle
|
||||||
|
size={48}
|
||||||
|
className="text-destructive mb-6 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h2 className="text-xl mb-4">An unexpected error occurred.</h2>
|
||||||
|
|
||||||
|
{this.state.error?.message ? (
|
||||||
|
<div className="mb-4 w-full rounded border border-destructive/20 bg-destructive/5 p-3 text-sm text-destructive">
|
||||||
|
{this.state.error.message}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="p-4 w-full rounded bg-muted overflow-auto mb-6">
|
||||||
|
<pre className="text-sm text-muted-foreground whitespace-break-spaces">
|
||||||
|
{this.state.error?.stack}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2 rounded-lg",
|
||||||
|
"bg-primary text-primary-foreground",
|
||||||
|
"hover:opacity-90 cursor-pointer"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RotateCcw size={16} />
|
||||||
|
Reload Page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
155
client/src/components/Map.tsx
Normal file
155
client/src/components/Map.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
/**
|
||||||
|
* GOOGLE MAPS FRONTEND INTEGRATION - ESSENTIAL GUIDE
|
||||||
|
*
|
||||||
|
* USAGE FROM PARENT COMPONENT:
|
||||||
|
* ======
|
||||||
|
*
|
||||||
|
* const mapRef = useRef<google.maps.Map | null>(null);
|
||||||
|
*
|
||||||
|
* <MapView
|
||||||
|
* initialCenter={{ lat: 40.7128, lng: -74.0060 }}
|
||||||
|
* initialZoom={15}
|
||||||
|
* onMapReady={(map) => {
|
||||||
|
* mapRef.current = map; // Store to control map from parent anytime, google map itself is in charge of the re-rendering, not react state.
|
||||||
|
* </MapView>
|
||||||
|
*
|
||||||
|
* ======
|
||||||
|
* Available Libraries and Core Features:
|
||||||
|
* -------------------------------
|
||||||
|
* 📍 MARKER (from `marker` library)
|
||||||
|
* - Attaches to map using { map, position }
|
||||||
|
* new google.maps.marker.AdvancedMarkerElement({
|
||||||
|
* map,
|
||||||
|
* position: { lat: 37.7749, lng: -122.4194 },
|
||||||
|
* title: "San Francisco",
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* -------------------------------
|
||||||
|
* 🏢 PLACES (from `places` library)
|
||||||
|
* - Does not attach directly to map; use data with your map manually.
|
||||||
|
* const place = new google.maps.places.Place({ id: PLACE_ID });
|
||||||
|
* await place.fetchFields({ fields: ["displayName", "location"] });
|
||||||
|
* map.setCenter(place.location);
|
||||||
|
* new google.maps.marker.AdvancedMarkerElement({ map, position: place.location });
|
||||||
|
*
|
||||||
|
* -------------------------------
|
||||||
|
* 🧭 GEOCODER (from `geocoding` library)
|
||||||
|
* - Standalone service; manually apply results to map.
|
||||||
|
* const geocoder = new google.maps.Geocoder();
|
||||||
|
* geocoder.geocode({ address: "New York" }, (results, status) => {
|
||||||
|
* if (status === "OK" && results[0]) {
|
||||||
|
* map.setCenter(results[0].geometry.location);
|
||||||
|
* new google.maps.marker.AdvancedMarkerElement({
|
||||||
|
* map,
|
||||||
|
* position: results[0].geometry.location,
|
||||||
|
* });
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* -------------------------------
|
||||||
|
* 📐 GEOMETRY (from `geometry` library)
|
||||||
|
* - Pure utility functions; not attached to map.
|
||||||
|
* const dist = google.maps.geometry.spherical.computeDistanceBetween(p1, p2);
|
||||||
|
*
|
||||||
|
* -------------------------------
|
||||||
|
* 🛣️ ROUTES (from `routes` library)
|
||||||
|
* - Combines DirectionsService (standalone) + DirectionsRenderer (map-attached)
|
||||||
|
* const directionsService = new google.maps.DirectionsService();
|
||||||
|
* const directionsRenderer = new google.maps.DirectionsRenderer({ map });
|
||||||
|
* directionsService.route(
|
||||||
|
* { origin, destination, travelMode: "DRIVING" },
|
||||||
|
* (res, status) => status === "OK" && directionsRenderer.setDirections(res)
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* -------------------------------
|
||||||
|
* 🌦️ MAP LAYERS (attach directly to map)
|
||||||
|
* - new google.maps.TrafficLayer().setMap(map);
|
||||||
|
* - new google.maps.TransitLayer().setMap(map);
|
||||||
|
* - new google.maps.BicyclingLayer().setMap(map);
|
||||||
|
*
|
||||||
|
* -------------------------------
|
||||||
|
* ✅ SUMMARY
|
||||||
|
* - “map-attached” → AdvancedMarkerElement, DirectionsRenderer, Layers.
|
||||||
|
* - “standalone” → Geocoder, DirectionsService, DistanceMatrixService, ElevationService.
|
||||||
|
* - “data-only” → Place, Geometry utilities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/// <reference types="@types/google.maps" />
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { usePersistFn } from "@/hooks/usePersistFn";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
google?: typeof google;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_KEY = import.meta.env.VITE_FRONTEND_FORGE_API_KEY;
|
||||||
|
const FORGE_BASE_URL =
|
||||||
|
import.meta.env.VITE_FRONTEND_FORGE_API_URL ||
|
||||||
|
"https://forge.butterfly-effect.dev";
|
||||||
|
const MAPS_PROXY_URL = `${FORGE_BASE_URL}/v1/maps/proxy`;
|
||||||
|
|
||||||
|
function loadMapScript() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = `${MAPS_PROXY_URL}/maps/api/js?key=${API_KEY}&v=weekly&libraries=marker,places,geocoding,geometry`;
|
||||||
|
script.async = true;
|
||||||
|
script.crossOrigin = "anonymous";
|
||||||
|
script.onload = () => {
|
||||||
|
resolve(null);
|
||||||
|
script.remove(); // Clean up immediately
|
||||||
|
};
|
||||||
|
script.onerror = () => {
|
||||||
|
console.error("Failed to load Google Maps script");
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapViewProps {
|
||||||
|
className?: string;
|
||||||
|
initialCenter?: google.maps.LatLngLiteral;
|
||||||
|
initialZoom?: number;
|
||||||
|
onMapReady?: (map: google.maps.Map) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapView({
|
||||||
|
className,
|
||||||
|
initialCenter = { lat: 37.7749, lng: -122.4194 },
|
||||||
|
initialZoom = 12,
|
||||||
|
onMapReady,
|
||||||
|
}: MapViewProps) {
|
||||||
|
const mapContainer = useRef<HTMLDivElement>(null);
|
||||||
|
const map = useRef<google.maps.Map | null>(null);
|
||||||
|
|
||||||
|
const init = usePersistFn(async () => {
|
||||||
|
await loadMapScript();
|
||||||
|
if (!mapContainer.current) {
|
||||||
|
console.error("Map container not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
map.current = new window.google.maps.Map(mapContainer.current, {
|
||||||
|
zoom: initialZoom,
|
||||||
|
center: initialCenter,
|
||||||
|
mapTypeControl: true,
|
||||||
|
fullscreenControl: true,
|
||||||
|
zoomControl: true,
|
||||||
|
streetViewControl: true,
|
||||||
|
mapId: "DEMO_MAP_ID",
|
||||||
|
});
|
||||||
|
if (onMapReady) {
|
||||||
|
onMapReady(map.current);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
init();
|
||||||
|
}, [init]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={mapContainer} className={cn("w-full h-[500px]", className)} />
|
||||||
|
);
|
||||||
|
}
|
||||||
109
client/src/components/ProfileRequiredState.tsx
Normal file
109
client/src/components/ProfileRequiredState.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { ArrowRight, BadgeCheck, Building2, FileText, LucideIcon, Sparkles } from "lucide-react";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
|
||||||
|
type ProfileRequiredStateProps = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
highlights: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProfileRequiredState({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon: Icon,
|
||||||
|
highlights,
|
||||||
|
}: ProfileRequiredStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex min-h-[calc(100vh-18rem)] max-w-4xl items-start justify-center py-6">
|
||||||
|
<Card className="w-full overflow-hidden border-primary/15 bg-[linear-gradient(135deg,rgba(248,252,255,0.98),rgba(255,255,255,1))] shadow-[0_28px_60px_-38px_rgba(37,99,235,0.3)]">
|
||||||
|
<CardHeader className="border-b border-primary/10 bg-[linear-gradient(135deg,rgba(239,246,255,0.95),rgba(255,255,255,0.98))]">
|
||||||
|
<div className="flex flex-col gap-5 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary shadow-inner">
|
||||||
|
<Icon className="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full bg-primary/8 px-3 py-1 text-xs font-semibold text-primary">
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
Profil requis
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">{title}</CardTitle>
|
||||||
|
<CardDescription className="max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-primary/10 bg-white/85 px-4 py-3 text-sm shadow-sm">
|
||||||
|
<p className="font-semibold text-foreground">Avant de continuer</p>
|
||||||
|
<p className="mt-1 text-muted-foreground">
|
||||||
|
Complétez une seule fois votre fiche, elle sera ensuite réutilisée dans vos demandes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-6 p-6 lg:grid-cols-[1.3fr_0.9fr] lg:p-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-2xl border border-primary/10 bg-white/90 p-5 shadow-sm">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5 flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-50 text-emerald-600">
|
||||||
|
<Building2 className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Pourquoi remplir la fiche maintenant</h2>
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">
|
||||||
|
Le portail s’appuie sur votre identité, vos coordonnées et vos responsables pour préremplir les demandes,
|
||||||
|
fiabiliser l’annuaire et éviter les ressaisies inutiles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{highlights.map((highlight) => (
|
||||||
|
<div
|
||||||
|
key={highlight}
|
||||||
|
className="rounded-2xl border border-border/70 bg-white/90 p-4 text-sm leading-6 text-muted-foreground shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center gap-2 font-medium text-foreground">
|
||||||
|
<BadgeCheck className="h-4 w-4 text-primary" />
|
||||||
|
Repère utile
|
||||||
|
</div>
|
||||||
|
{highlight}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-dashed border-primary/20 bg-primary/5 p-5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-white text-primary shadow-sm">
|
||||||
|
<FileText className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Suite recommandée</h2>
|
||||||
|
<p className="text-sm leading-6 text-muted-foreground">
|
||||||
|
Ouvrez votre fiche association, renseignez les informations essentielles puis revenez ici pour reprendre votre
|
||||||
|
parcours sans perdre le fil.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex flex-col gap-3">
|
||||||
|
<Link href="/dashboard/profile">
|
||||||
|
<Button className="w-full justify-center gap-2">
|
||||||
|
Créer mon profil
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/dashboard">
|
||||||
|
<Button variant="outline" className="w-full justify-center">
|
||||||
|
Retour au tableau de bord
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
client/src/components/PublicDocumentBackLink.tsx
Normal file
28
client/src/components/PublicDocumentBackLink.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { Link, useLocation } from "wouter";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type PublicDocumentBackLinkProps = {
|
||||||
|
fallbackHref?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PublicDocumentBackLink({
|
||||||
|
fallbackHref = "/",
|
||||||
|
}: PublicDocumentBackLinkProps) {
|
||||||
|
const [location] = useLocation();
|
||||||
|
const queryIndex = location.indexOf("?");
|
||||||
|
const searchParams = new URLSearchParams(queryIndex >= 0 ? location.slice(queryIndex + 1) : "");
|
||||||
|
const rawReturnTo = searchParams.get("returnTo") || "";
|
||||||
|
const returnTo = rawReturnTo.startsWith("/") ? rawReturnTo : fallbackHref;
|
||||||
|
const label = returnTo.startsWith("/admin") ? "Retour à l'administration" : "Retour";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={returnTo}>
|
||||||
|
<Button variant="outline" className="gap-2">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
1842
client/src/components/ReservationCalendar.tsx
Normal file
1842
client/src/components/ReservationCalendar.tsx
Normal file
File diff suppressed because it is too large
Load diff
129
client/src/components/SignaturePad.tsx
Normal file
129
client/src/components/SignaturePad.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Eraser } from "lucide-react";
|
||||||
|
import { forwardRef, useEffect, useImperativeHandle, useRef, useState, type PointerEvent } from "react";
|
||||||
|
|
||||||
|
export type SignaturePadHandle = {
|
||||||
|
clear: () => void;
|
||||||
|
getDataUrl: () => string;
|
||||||
|
isEmpty: () => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SignaturePadProps = {
|
||||||
|
label: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SignaturePad = forwardRef<SignaturePadHandle, SignaturePadProps>(function SignaturePad(
|
||||||
|
{ label, onChange },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [isDrawing, setIsDrawing] = useState(false);
|
||||||
|
const [hasDrawing, setHasDrawing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const ratio = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = Math.floor(rect.width * ratio);
|
||||||
|
canvas.height = Math.floor(rect.height * ratio);
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) return;
|
||||||
|
context.scale(ratio, ratio);
|
||||||
|
context.lineWidth = 2;
|
||||||
|
context.lineCap = "round";
|
||||||
|
context.lineJoin = "round";
|
||||||
|
context.strokeStyle = "#0f172a";
|
||||||
|
context.fillStyle = "#ffffff";
|
||||||
|
context.fillRect(0, 0, rect.width, rect.height);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getContext = () => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return null;
|
||||||
|
return canvas.getContext("2d");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCoordinates = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: event.clientX - rect.left,
|
||||||
|
y: event.clientY - rect.top,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const context = getContext();
|
||||||
|
if (!canvas || !context) return;
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
context.fillStyle = "#ffffff";
|
||||||
|
context.fillRect(0, 0, rect.width, rect.height);
|
||||||
|
setHasDrawing(false);
|
||||||
|
onChange?.("");
|
||||||
|
};
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
clear,
|
||||||
|
getDataUrl: () => canvasRef.current?.toDataURL("image/png") || "",
|
||||||
|
isEmpty: () => !hasDrawing,
|
||||||
|
}), [hasDrawing]);
|
||||||
|
|
||||||
|
const finishDrawing = () => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
setIsDrawing(false);
|
||||||
|
setHasDrawing(true);
|
||||||
|
onChange?.(canvas.toDataURL("image/png"));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-medium">{label}</p>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={clear}>
|
||||||
|
<Eraser className="mr-2 h-4 w-4" />
|
||||||
|
Effacer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-white overflow-hidden">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="block h-36 w-full touch-none bg-white"
|
||||||
|
onPointerDown={(event: PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const context = getContext();
|
||||||
|
if (!context) return;
|
||||||
|
const point = getCoordinates(event);
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(point.x, point.y);
|
||||||
|
setIsDrawing(true);
|
||||||
|
}}
|
||||||
|
onPointerMove={(event: PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!isDrawing) return;
|
||||||
|
const context = getContext();
|
||||||
|
if (!context) return;
|
||||||
|
const point = getCoordinates(event);
|
||||||
|
context.lineTo(point.x, point.y);
|
||||||
|
context.stroke();
|
||||||
|
if (!hasDrawing) {
|
||||||
|
setHasDrawing(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerUp={() => {
|
||||||
|
if (!isDrawing) return;
|
||||||
|
finishDrawing();
|
||||||
|
}}
|
||||||
|
onPointerLeave={() => {
|
||||||
|
if (!isDrawing) return;
|
||||||
|
finishDrawing();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Signez directement au doigt ou au stylet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
358
client/src/components/UserOperatingTour.tsx
Normal file
358
client/src/components/UserOperatingTour.tsx
Normal file
|
|
@ -0,0 +1,358 @@
|
||||||
|
import { useAuth } from "@/_core/hooks/useAuth";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { HelpCircle, Sparkles, X } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useLocation } from "wouter";
|
||||||
|
|
||||||
|
type OperatingTourAudience = "association" | "accueil" | "admin";
|
||||||
|
|
||||||
|
type OperatingTourStep = {
|
||||||
|
id: string;
|
||||||
|
route: string;
|
||||||
|
selector: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STEP_GROUPS: Record<OperatingTourAudience, OperatingTourStep[]> = {
|
||||||
|
association: [
|
||||||
|
{
|
||||||
|
id: "association-dashboard",
|
||||||
|
route: "/dashboard",
|
||||||
|
selector: '[data-tour-id="dashboard-welcome"]',
|
||||||
|
title: "Tableau de bord association",
|
||||||
|
description: "C’est votre point de départ. Vous y retrouvez les raccourcis, vos documents et l’état général de vos démarches.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "association-profile-nav",
|
||||||
|
route: "/dashboard",
|
||||||
|
selector: '[data-tour-id="nav-profile"]',
|
||||||
|
title: "Mettre à jour la fiche",
|
||||||
|
description: "Passez ici pour compléter ou corriger la fiche de votre association avant d’envoyer de nouvelles demandes.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "association-profile-page",
|
||||||
|
route: "/dashboard/profile",
|
||||||
|
selector: '[data-tour-id="profile-header"]',
|
||||||
|
title: "Page Mon association",
|
||||||
|
description: "Cette page centralise l’identité de l’association, ses coordonnées et les informations utiles à l’administration.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "association-documents",
|
||||||
|
route: "/dashboard/documents",
|
||||||
|
selector: '[data-tour-id="documents-header"]',
|
||||||
|
title: "Déposer les documents",
|
||||||
|
description: "Ajoutez ici les pièces demandées comme le RIB, les statuts ou l’attestation d’assurance pour éviter les blocages.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "association-requests",
|
||||||
|
route: "/dashboard/requests",
|
||||||
|
selector: '[data-tour-id="requests-new"]',
|
||||||
|
title: "Créer une nouvelle demande",
|
||||||
|
description: "Depuis Mes demandes, vous lancez un nouveau dossier et vous suivez ensuite son état sans perdre le fil.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "association-reservation",
|
||||||
|
route: "/dashboard/reservation-salle",
|
||||||
|
selector: '[data-tour-id="reservation-header"]',
|
||||||
|
title: "Réserver une salle",
|
||||||
|
description: "Le formulaire guide la réservation pas à pas, avec les créneaux, les dates et les salles nécessaires.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "association-directory",
|
||||||
|
route: "/dashboard/associations",
|
||||||
|
selector: '[data-tour-id="directory-header"]',
|
||||||
|
title: "Consulter l’annuaire",
|
||||||
|
description: "L’annuaire permet de retrouver rapidement une association, vérifier sa visibilité et partager les bonnes informations.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
accueil: [
|
||||||
|
{
|
||||||
|
id: "accueil-admin-home",
|
||||||
|
route: "/admin",
|
||||||
|
selector: '[data-tour-id="admin-header"]',
|
||||||
|
title: "Accueil administratif",
|
||||||
|
description: "Cette vue vous remet sur les priorités du jour et les chemins rapides vers les dossiers à faire avancer.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "accueil-requests",
|
||||||
|
route: "/admin?tab=requests",
|
||||||
|
selector: '[data-tour-id="admin-tab-requests"]',
|
||||||
|
title: "Gestion des demandes",
|
||||||
|
description: "C’est ici que l’hôtesse traite les demandes entrantes, suit les devis et relance les dossiers en attente.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "accueil-calendar",
|
||||||
|
route: "/admin?tab=calendrier",
|
||||||
|
selector: '[data-tour-id="admin-tab-calendrier"]',
|
||||||
|
title: "Calendrier",
|
||||||
|
description: "Le calendrier aide à vérifier les disponibilités et à éviter les conflits avant validation.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "accueil-notifications",
|
||||||
|
route: "/admin?tab=notifications",
|
||||||
|
selector: '[data-tour-id="admin-tab-notifications"]',
|
||||||
|
title: "Notifications",
|
||||||
|
description: "Les alertes et retours importants arrivent ici pour que rien ne reste silencieusement bloqué.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
admin: [
|
||||||
|
{
|
||||||
|
id: "admin-home",
|
||||||
|
route: "/admin",
|
||||||
|
selector: '[data-tour-id="admin-header"]',
|
||||||
|
title: "Vue d’administration",
|
||||||
|
description: "Vous retrouvez ici les modules globaux, les états de supervision et l’accès rapide aux réglages sensibles.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "admin-requests",
|
||||||
|
route: "/admin?tab=requests",
|
||||||
|
selector: '[data-tour-id="admin-tab-requests"]',
|
||||||
|
title: "Pilotage des demandes",
|
||||||
|
description: "Cet onglet concentre le suivi des dossiers, les arbitrages et les points qui demandent une action rapide.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "admin-analytics",
|
||||||
|
route: "/admin?tab=analytics",
|
||||||
|
selector: '[data-tour-id="admin-tab-analytics"]',
|
||||||
|
title: "Statistiques et supervision",
|
||||||
|
description: "Vous y trouvez le Double Tour opérationnel, les indicateurs clés et le suivi technique du portail.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "admin-ccds-directory",
|
||||||
|
route: "/admin?tab=ccds-directory",
|
||||||
|
selector: '[data-tour-id="admin-tab-ccds-directory"]',
|
||||||
|
title: "Annuaire interne CCDS",
|
||||||
|
description: "Cet espace regroupe le répertoire interne et les informations utiles aux équipes pour travailler plus vite.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function getAudience(role: string | undefined): OperatingTourAudience | null {
|
||||||
|
if (role === "user") return "association";
|
||||||
|
if (role === "accueil") return "accueil";
|
||||||
|
if (role === "admin" || role === "super_admin" || role === "directrice" || role === "logistique_controle") {
|
||||||
|
return "admin";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserOperatingTour() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
|
const { data: settings } = trpc.settings.getUserOperatingTour.useQuery();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [stepIndex, setStepIndex] = useState(0);
|
||||||
|
const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
|
||||||
|
const [selectorReady, setSelectorReady] = useState(false);
|
||||||
|
const lastScrolledStepRef = useRef<string | null>(null);
|
||||||
|
const audience = getAudience(user?.role);
|
||||||
|
|
||||||
|
const steps = useMemo(() => (audience ? STEP_GROUPS[audience] : []), [audience]);
|
||||||
|
const activeStep = steps[stepIndex] || null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settings?.enabled) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}, [settings?.enabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!audience) {
|
||||||
|
setIsOpen(false);
|
||||||
|
setStepIndex(0);
|
||||||
|
}
|
||||||
|
}, [audience]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !activeStep) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let timer: number | null = null;
|
||||||
|
|
||||||
|
const updateRect = () => {
|
||||||
|
const node = document.querySelector(activeStep.selector);
|
||||||
|
if (node instanceof HTMLElement) {
|
||||||
|
setSelectorReady(true);
|
||||||
|
setTargetRect(node.getBoundingClientRect());
|
||||||
|
} else {
|
||||||
|
setSelectorReady(false);
|
||||||
|
setTargetRect(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncStep = (attempt = 0) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const currentRoute = `${window.location.pathname}${window.location.search}`;
|
||||||
|
if (currentRoute !== activeStep.route) {
|
||||||
|
setSelectorReady(false);
|
||||||
|
setTargetRect(null);
|
||||||
|
setLocation(activeStep.route);
|
||||||
|
timer = window.setTimeout(() => syncStep(attempt + 1), attempt > 3 ? 280 : 120);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = document.querySelector(activeStep.selector);
|
||||||
|
if (node instanceof HTMLElement) {
|
||||||
|
if (lastScrolledStepRef.current !== activeStep.id) {
|
||||||
|
node.scrollIntoView({ behavior: "smooth", block: "center", inline: "center" });
|
||||||
|
lastScrolledStepRef.current = activeStep.id;
|
||||||
|
}
|
||||||
|
setSelectorReady(true);
|
||||||
|
setTargetRect(node.getBoundingClientRect());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < 30) {
|
||||||
|
timer = window.setTimeout(() => syncStep(attempt + 1), 180);
|
||||||
|
} else {
|
||||||
|
setSelectorReady(false);
|
||||||
|
setTargetRect(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
syncStep();
|
||||||
|
window.addEventListener("resize", updateRect);
|
||||||
|
window.addEventListener("scroll", updateRect, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timer) {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
}
|
||||||
|
window.removeEventListener("resize", updateRect);
|
||||||
|
window.removeEventListener("scroll", updateRect, true);
|
||||||
|
};
|
||||||
|
}, [activeStep, isOpen, setLocation]);
|
||||||
|
|
||||||
|
if (!settings?.enabled || !audience || steps.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTour = () => {
|
||||||
|
lastScrolledStepRef.current = null;
|
||||||
|
setStepIndex(0);
|
||||||
|
setIsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopTour = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setTargetRect(null);
|
||||||
|
setSelectorReady(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goNext = () => {
|
||||||
|
if (stepIndex >= steps.length - 1) {
|
||||||
|
stopTour();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStepIndex((current) => current + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goPrevious = () => {
|
||||||
|
setStepIndex((current) => Math.max(0, current - 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const bubbleWidth = typeof window === "undefined" ? 360 : Math.min(360, window.innerWidth - 32);
|
||||||
|
const bubbleHeightEstimate = 220;
|
||||||
|
const bubbleLeft = targetRect
|
||||||
|
? clamp(targetRect.left, 16, Math.max(16, window.innerWidth - bubbleWidth - 16))
|
||||||
|
: Math.max(16, (typeof window !== "undefined" ? window.innerWidth : 420) / 2 - bubbleWidth / 2);
|
||||||
|
const bubbleTop = targetRect
|
||||||
|
? targetRect.top > (window.innerHeight * 0.55)
|
||||||
|
? clamp(targetRect.top - bubbleHeightEstimate - 16, 16, Math.max(16, window.innerHeight - bubbleHeightEstimate - 16))
|
||||||
|
: clamp(targetRect.bottom + 16, 16, Math.max(16, window.innerHeight - bubbleHeightEstimate - 16))
|
||||||
|
: Math.max(24, (typeof window !== "undefined" ? window.innerHeight : 720) / 2 - bubbleHeightEstimate / 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="fixed bottom-5 right-5 z-[90] flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="border-primary/30 bg-white/95 px-3 py-2 shadow-sm">
|
||||||
|
<Sparkles className="h-3.5 w-3.5 text-primary" />
|
||||||
|
Tour guide
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={isOpen ? stopTour : startTour}
|
||||||
|
className="shadow-lg"
|
||||||
|
>
|
||||||
|
<HelpCircle className="mr-2 h-4 w-4" />
|
||||||
|
{isOpen ? "Arreter le tour" : "Lancer le tour"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen ? (
|
||||||
|
<div className="pointer-events-none fixed inset-0 z-[85]">
|
||||||
|
{targetRect ? (
|
||||||
|
<>
|
||||||
|
<div className="absolute left-0 top-0 w-full bg-slate-950/55" style={{ height: Math.max(0, targetRect.top - 8) }} />
|
||||||
|
<div className="absolute left-0 bg-slate-950/55" style={{ top: Math.max(0, targetRect.top - 8), width: Math.max(0, targetRect.left - 8), height: targetRect.height + 16 }} />
|
||||||
|
<div className="absolute right-0 bg-slate-950/55" style={{ top: Math.max(0, targetRect.top - 8), left: targetRect.right + 8, height: targetRect.height + 16 }} />
|
||||||
|
<div className="absolute left-0 w-full bg-slate-950/55" style={{ top: targetRect.bottom + 8, bottom: 0 }} />
|
||||||
|
<div
|
||||||
|
className="absolute rounded-xl border-2 border-primary bg-white/10 shadow-[0_0_0_9999px_rgba(15,23,42,0.04)] transition-all"
|
||||||
|
style={{
|
||||||
|
left: targetRect.left - 8,
|
||||||
|
top: targetRect.top - 8,
|
||||||
|
width: targetRect.width + 16,
|
||||||
|
height: targetRect.height + 16,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 bg-slate-950/55" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto fixed rounded-xl border bg-white p-4 shadow-2xl",
|
||||||
|
selectorReady ? "border-primary/30" : "border-amber-200"
|
||||||
|
)}
|
||||||
|
style={{ width: bubbleWidth, left: bubbleLeft, top: bubbleTop }}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Badge variant="outline" className="border-primary/20 bg-primary/5 text-primary">
|
||||||
|
Etape {stepIndex + 1} sur {steps.length}
|
||||||
|
</Badge>
|
||||||
|
<h3 className="text-base font-semibold">{activeStep?.title}</h3>
|
||||||
|
</div>
|
||||||
|
<Button type="button" size="icon" variant="ghost" className="h-8 w-8 shrink-0" onClick={stopTour}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||||
|
{selectorReady
|
||||||
|
? activeStep?.description
|
||||||
|
: "Je prepare cette etape et je me place sur le bon ecran pour te la montrer proprement."}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={stopTour}>
|
||||||
|
Fermer
|
||||||
|
</Button>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={goPrevious} disabled={stepIndex === 0}>
|
||||||
|
Retour
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" onClick={goNext}>
|
||||||
|
{stepIndex >= steps.length - 1 ? "Terminer" : "Suivant"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserOperatingTour;
|
||||||
187
client/src/components/admin/AdminAnalyticsCharts.tsx
Normal file
187
client/src/components/admin/AdminAnalyticsCharts.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
Legend,
|
||||||
|
Pie,
|
||||||
|
PieChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
type TimelineEntry = {
|
||||||
|
label: string;
|
||||||
|
salles: number;
|
||||||
|
materiel: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CategoryEntry = {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RoomOccupancyEntry = {
|
||||||
|
name: string;
|
||||||
|
taux: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MaterialUsageEntry = {
|
||||||
|
name: string;
|
||||||
|
quantite: number;
|
||||||
|
demandes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdminAnalyticsChartsProps = {
|
||||||
|
periodLabel?: string | null;
|
||||||
|
timelineData: TimelineEntry[];
|
||||||
|
categoryData: CategoryEntry[];
|
||||||
|
roomOccupancyData: RoomOccupancyEntry[];
|
||||||
|
materialUsageData: MaterialUsageEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLORS = ["#000091", "#6a6af4", "#009081", "#e1000f", "#c9191e", "#b34000"];
|
||||||
|
|
||||||
|
export default function AdminAnalyticsCharts({
|
||||||
|
periodLabel,
|
||||||
|
timelineData,
|
||||||
|
categoryData,
|
||||||
|
roomOccupancyData,
|
||||||
|
materialUsageData,
|
||||||
|
}: AdminAnalyticsChartsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Répartition des réservations</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Vue {periodLabel?.toLowerCase() || "périodique"} des salles et du matériel.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[320px]">
|
||||||
|
{timelineData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={timelineData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="label" fontSize={12} />
|
||||||
|
<YAxis fontSize={12} />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Bar dataKey="salles" name="Salles" fill="#000091" radius={[6, 6, 0, 0]} />
|
||||||
|
<Bar dataKey="materiel" name="Matériel" fill="#009081" radius={[6, 6, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Pas encore de réservations sur cette période.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Catégories d’utilisateurs</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Répartition par typologie d’associations et structures utilisatrices.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[320px]">
|
||||||
|
{categoryData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={categoryData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
labelLine={false}
|
||||||
|
outerRadius={105}
|
||||||
|
dataKey="value"
|
||||||
|
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||||
|
>
|
||||||
|
{categoryData.map((entry, index) => (
|
||||||
|
<Cell key={`${entry.name}-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Catégorisation indisponible pour le moment.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Taux d’occupation des salles</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Lecture simple des espaces les plus mobilisés sur la période.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[320px]">
|
||||||
|
{roomOccupancyData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={roomOccupancyData} layout="vertical" margin={{ top: 8, right: 8, left: 24, bottom: 8 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
||||||
|
<XAxis type="number" fontSize={12} unit="%" />
|
||||||
|
<YAxis dataKey="name" type="category" width={120} fontSize={12} />
|
||||||
|
<Tooltip formatter={(value: number, name: string) => [name === "taux" ? `${value}%` : value, name === "taux" ? "Occupation" : "Jours réservés"]} />
|
||||||
|
<Bar dataKey="taux" name="Occupation" fill="#6a6af4" radius={[0, 6, 6, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Aucune occupation à afficher.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Matériels les plus demandés</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Quantité totale demandée et nombre de dossiers concernés.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[320px]">
|
||||||
|
{materialUsageData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={materialUsageData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="name" fontSize={12} />
|
||||||
|
<YAxis fontSize={12} />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Bar dataKey="quantite" name="Quantité" fill="#000091" radius={[6, 6, 0, 0]} />
|
||||||
|
<Bar dataKey="demandes" name="Dossiers" fill="#f4b740" radius={[6, 6, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Aucune demande matérielle à afficher.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
client/src/components/admin/AdminDashboardCharts.tsx
Normal file
159
client/src/components/admin/AdminDashboardCharts.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
Legend,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
Pie,
|
||||||
|
PieChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { BarChart3 } from "lucide-react";
|
||||||
|
|
||||||
|
type CommuneUsageEntry = {
|
||||||
|
commune: string;
|
||||||
|
quantite: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MonthlyChartEntry = {
|
||||||
|
month: string;
|
||||||
|
total: number;
|
||||||
|
validated: number;
|
||||||
|
rejected: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TypeChartEntry = {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdminDashboardChartsProps = {
|
||||||
|
usageByCommuneData: CommuneUsageEntry[];
|
||||||
|
monthlyChartData: MonthlyChartEntry[];
|
||||||
|
typeChartData: TypeChartEntry[];
|
||||||
|
isFullAdminView: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLORS = ["#000091", "#6a6af4", "#009081", "#e1000f", "#c9191e", "#b34000"];
|
||||||
|
|
||||||
|
export default function AdminDashboardCharts({
|
||||||
|
usageByCommuneData,
|
||||||
|
monthlyChartData,
|
||||||
|
typeChartData,
|
||||||
|
isFullAdminView,
|
||||||
|
}: AdminDashboardChartsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-5 w-5 text-primary" />
|
||||||
|
Sollicitations du stock par commune
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Lecture simple du matériel mobilisé ce mois-ci, pour repérer les communes les plus consommatrices.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[280px]">
|
||||||
|
{usageByCommuneData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={usageByCommuneData} layout="vertical" margin={{ top: 8, right: 8, left: 16, bottom: 8 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
||||||
|
<XAxis type="number" fontSize={12} />
|
||||||
|
<YAxis dataKey="commune" type="category" width={90} fontSize={12} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number, name: string) => [
|
||||||
|
value,
|
||||||
|
name === "quantite" ? "Quantité mobilisée" : "Dossiers",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="quantite" fill="#000091" radius={[0, 6, 6, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Pas encore assez de prêts matériels validés pour ce mois-ci.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{isFullAdminView ? (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Évolution des demandes</CardTitle>
|
||||||
|
<CardDescription>Demandes par mois sur les 12 derniers mois</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[300px]">
|
||||||
|
{monthlyChartData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={monthlyChartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="month" fontSize={12} />
|
||||||
|
<YAxis fontSize={12} />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Line type="monotone" dataKey="total" name="Total" stroke="#000091" strokeWidth={2} />
|
||||||
|
<Line type="monotone" dataKey="validated" name="Validées" stroke="#009081" strokeWidth={2} />
|
||||||
|
<Line type="monotone" dataKey="rejected" name="Refusées" stroke="#e1000f" strokeWidth={2} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||||
|
Pas de données disponibles
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Répartition par type</CardTitle>
|
||||||
|
<CardDescription>Distribution des demandes par catégorie</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-[300px]">
|
||||||
|
{typeChartData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={typeChartData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
labelLine={false}
|
||||||
|
outerRadius={100}
|
||||||
|
fill="#8884d8"
|
||||||
|
dataKey="value"
|
||||||
|
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||||
|
>
|
||||||
|
{typeChartData.map((entry, index) => (
|
||||||
|
<Cell key={`${entry.name}-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||||
|
Pas de données disponibles
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
client/src/components/portal/PortalSurface.tsx
Normal file
174
client/src/components/portal/PortalSurface.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
|
||||||
|
export function PortalDashboardHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
backHref = "/dashboard",
|
||||||
|
className,
|
||||||
|
contentClassName,
|
||||||
|
tourId,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
backHref?: string;
|
||||||
|
className?: string;
|
||||||
|
contentClassName?: string;
|
||||||
|
tourId?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<header className={cn("sticky top-0 z-10 border-b border-border bg-white", className)}>
|
||||||
|
<div className="container py-3 sm:py-4">
|
||||||
|
<div className="flex items-start gap-3 sm:items-center sm:gap-4">
|
||||||
|
<Link href={backHref}>
|
||||||
|
<Button variant="ghost" size="icon" className="mt-0.5 shrink-0 sm:mt-0">
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div data-tour-id={tourId} className={cn("min-w-0 flex-1", contentClassName)}>
|
||||||
|
<PortalPageHeader title={title} description={description} actions={actions} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortalPageHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 md:gap-3.5 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="min-w-0 space-y-1.5">
|
||||||
|
<h1 className="text-[1.65rem] font-bold tracking-tight leading-tight text-slate-950 sm:text-[1.8rem]">{title}</h1>
|
||||||
|
{description ? <p className="max-w-3xl text-sm leading-6 text-slate-600 sm:text-[0.95rem]">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex w-full flex-col gap-2.5 pt-0.5 sm:w-auto sm:flex-row sm:flex-wrap sm:justify-end">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortalSectionIntro({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
aside,
|
||||||
|
className,
|
||||||
|
eyebrowClassName,
|
||||||
|
titleClassName,
|
||||||
|
descriptionClassName,
|
||||||
|
}: {
|
||||||
|
eyebrow?: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
aside?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
eyebrowClassName?: string;
|
||||||
|
titleClassName?: string;
|
||||||
|
descriptionClassName?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between", className)}>
|
||||||
|
<div className="max-w-2xl space-y-1.5">
|
||||||
|
{eyebrow ? (
|
||||||
|
<p className={cn("text-xs font-semibold uppercase tracking-[0.16em] text-primary", eyebrowClassName)}>{eyebrow}</p>
|
||||||
|
) : null}
|
||||||
|
<h2 className={cn("text-lg font-semibold tracking-tight text-slate-950 sm:text-[1.45rem]", titleClassName)}>{title}</h2>
|
||||||
|
{description ? <p className={cn("text-sm leading-6 text-slate-600", descriptionClassName)}>{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
{aside ? (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white/92 px-4 py-2.5 text-sm text-slate-600 shadow-sm">
|
||||||
|
{aside}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortalSurface({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
badge,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
bodyClassName,
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
badge?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
bodyClassName?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden rounded-2xl border border-slate-200/90 bg-white/96 shadow-[var(--portal-panel-shadow)] transition-[transform,box-shadow,border-color,background-color] duration-[var(--portal-motion-base)] hover:shadow-[var(--portal-panel-hover-shadow)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title || description || badge ? (
|
||||||
|
<div className="border-b border-slate-100 px-4 py-4 sm:px-5">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{title ? <h3 className="text-base font-semibold text-slate-950">{title}</h3> : null}
|
||||||
|
{description ? <p className="text-sm leading-6 text-slate-600">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
{badge ? <div className="shrink-0">{badge}</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={cn("px-4 py-4 sm:px-5", bodyClassName)}>{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortalMetricTile({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
detail,
|
||||||
|
tone = "default",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: ReactNode;
|
||||||
|
detail?: ReactNode;
|
||||||
|
tone?: "default" | "success" | "info" | "warning";
|
||||||
|
}) {
|
||||||
|
const toneClassName =
|
||||||
|
tone === "success"
|
||||||
|
? "border-emerald-200 bg-emerald-50"
|
||||||
|
: tone === "info"
|
||||||
|
? "border-blue-200 bg-blue-50"
|
||||||
|
: tone === "warning"
|
||||||
|
? "border-amber-200 bg-amber-50"
|
||||||
|
: "border-slate-200 bg-white";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("rounded-xl border px-4 py-3.5 shadow-[var(--portal-card-shadow)] transition-[transform,box-shadow,border-color,background-color] duration-[var(--portal-motion-base)] hover:-translate-y-0.5 hover:shadow-[var(--portal-card-hover-shadow)]", toneClassName)}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">{label}</p>
|
||||||
|
<div className="mt-1.5 text-[1.65rem] font-semibold tracking-tight text-slate-950">{value}</div>
|
||||||
|
{detail ? <div className="mt-1.5 text-xs leading-5 text-slate-500">{detail}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortalActionGroup({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return <div className={cn("flex flex-col gap-2 sm:flex-row sm:flex-wrap", className)}>{children}</div>;
|
||||||
|
}
|
||||||
64
client/src/components/ui/accordion.tsx
Normal file
64
client/src/components/ui/accordion.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||||
|
import { ChevronDownIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Accordion({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||||
|
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot="accordion-item"
|
||||||
|
className={cn("border-b last:border-b-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
data-slot="accordion-content"
|
||||||
|
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||||
155
client/src/components/ui/alert-dialog.tsx
Normal file
155
client/src/components/ui/alert-dialog.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
};
|
||||||
66
client/src/components/ui/alert.tsx
Normal file
66
client/src/components/ui/alert.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
9
client/src/components/ui/aspect-ratio.tsx
Normal file
9
client/src/components/ui/aspect-ratio.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
|
||||||
|
|
||||||
|
function AspectRatio({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||||
|
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AspectRatio };
|
||||||
51
client/src/components/ui/avatar.tsx
Normal file
51
client/src/components/ui/avatar.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn("aspect-square size-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback };
|
||||||
46
client/src/components/ui/badge.tsx
Normal file
46
client/src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "span";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
109
client/src/components/ui/breadcrumb.tsx
Normal file
109
client/src/components/ui/breadcrumb.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "a";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="breadcrumb-link"
|
||||||
|
className={cn("hover:text-foreground transition-colors", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("text-foreground font-normal", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
};
|
||||||
83
client/src/components/ui/button-group.tsx
Normal file
83
client/src/components/ui/button-group.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
|
const buttonGroupVariants = cva(
|
||||||
|
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
horizontal:
|
||||||
|
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||||
|
vertical:
|
||||||
|
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function ButtonGroup({
|
||||||
|
className,
|
||||||
|
orientation,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="button-group"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonGroupText({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "div";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(
|
||||||
|
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonGroupSeparator({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="button-group-separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ButtonGroup,
|
||||||
|
ButtonGroupSeparator,
|
||||||
|
ButtonGroupText,
|
||||||
|
buttonGroupVariants,
|
||||||
|
};
|
||||||
61
client/src/components/ui/button.tsx
Normal file
61
client/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium shadow-[var(--portal-card-shadow)] transition-[transform,box-shadow,background-color,color,border-color,opacity] duration-[var(--portal-motion-fast)] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.985]",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-[var(--portal-button)] text-[var(--portal-button-foreground)] hover:-translate-y-0.5 hover:bg-[var(--portal-button-hover)] hover:shadow-[0_14px_30px_-18px_rgba(21,128,61,0.65)]",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:-translate-y-0.5 hover:bg-destructive/90 hover:shadow-[0_14px_30px_-18px_rgba(220,38,38,0.7)] focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-transparent shadow-xs hover:-translate-y-0.5 hover:border-primary/25 hover:bg-accent/70 hover:shadow-[0_12px_24px_-18px_rgba(15,23,42,0.35)] dark:bg-transparent dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:-translate-y-0.5 hover:bg-secondary/80 hover:shadow-[0_12px_24px_-18px_rgba(15,23,42,0.28)]",
|
||||||
|
ghost:
|
||||||
|
"shadow-none hover:bg-accent/70 dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
211
client/src/components/ui/calendar.tsx
Normal file
211
client/src/components/ui/calendar.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = "label",
|
||||||
|
buttonVariant = "ghost",
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
|
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn(
|
||||||
|
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||||
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: date =>
|
||||||
|
date.toLocaleString("default", { month: "short" }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
|
months: cn(
|
||||||
|
"flex gap-4 flex-col md:flex-row relative",
|
||||||
|
defaultClassNames.months
|
||||||
|
),
|
||||||
|
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||||
|
nav: cn(
|
||||||
|
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||||
|
defaultClassNames.nav
|
||||||
|
),
|
||||||
|
button_previous: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||||
|
defaultClassNames.button_previous
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||||
|
defaultClassNames.button_next
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||||
|
defaultClassNames.month_caption
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||||
|
defaultClassNames.dropdowns
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||||
|
defaultClassNames.dropdown_root
|
||||||
|
),
|
||||||
|
dropdown: cn(
|
||||||
|
"absolute bg-popover inset-0 opacity-0",
|
||||||
|
defaultClassNames.dropdown
|
||||||
|
),
|
||||||
|
caption_label: cn(
|
||||||
|
"select-none font-medium",
|
||||||
|
captionLayout === "label"
|
||||||
|
? "text-sm"
|
||||||
|
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||||
|
defaultClassNames.caption_label
|
||||||
|
),
|
||||||
|
table: "w-full border-collapse",
|
||||||
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||||
|
defaultClassNames.weekday
|
||||||
|
),
|
||||||
|
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||||
|
week_number_header: cn(
|
||||||
|
"select-none w-(--cell-size)",
|
||||||
|
defaultClassNames.week_number_header
|
||||||
|
),
|
||||||
|
week_number: cn(
|
||||||
|
"text-[0.8rem] select-none text-muted-foreground",
|
||||||
|
defaultClassNames.week_number
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||||
|
defaultClassNames.day
|
||||||
|
),
|
||||||
|
range_start: cn(
|
||||||
|
"rounded-l-md bg-accent",
|
||||||
|
defaultClassNames.range_start
|
||||||
|
),
|
||||||
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
|
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||||
|
today: cn(
|
||||||
|
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||||
|
defaultClassNames.today
|
||||||
|
),
|
||||||
|
outside: cn(
|
||||||
|
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
|
defaultClassNames.outside
|
||||||
|
),
|
||||||
|
disabled: cn(
|
||||||
|
"text-muted-foreground opacity-50",
|
||||||
|
defaultClassNames.disabled
|
||||||
|
),
|
||||||
|
hidden: cn("invisible", defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="calendar"
|
||||||
|
ref={rootRef}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === "left") {
|
||||||
|
return (
|
||||||
|
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === "right") {
|
||||||
|
return (
|
||||||
|
<ChevronRightIcon
|
||||||
|
className={cn("size-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||||
|
);
|
||||||
|
},
|
||||||
|
DayButton: CalendarDayButton,
|
||||||
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayButton({
|
||||||
|
className,
|
||||||
|
day,
|
||||||
|
modifiers,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton>) {
|
||||||
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus();
|
||||||
|
}, [modifiers.focused]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-day={day.date.toLocaleDateString()}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected &&
|
||||||
|
!modifiers.range_start &&
|
||||||
|
!modifiers.range_end &&
|
||||||
|
!modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||||
|
defaultClassNames.day,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton };
|
||||||
140
client/src/components/ui/card.tsx
Normal file
140
client/src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { getBackgroundScopeForPath, isDecorationScopeActive } from "@/lib/appearanceScopes";
|
||||||
|
import type { AppearanceCardDecorationAnchor } from "@shared/appearance";
|
||||||
|
import { useLocation } from "wouter";
|
||||||
|
|
||||||
|
const cardDecorationAnchorClasses: Record<AppearanceCardDecorationAnchor, string> = {
|
||||||
|
top_left: "left-0 top-0 -translate-x-[18%] -translate-y-[18%]",
|
||||||
|
top_right: "right-0 top-0 translate-x-[18%] -translate-y-[18%]",
|
||||||
|
bottom_left: "bottom-0 left-0 -translate-x-[18%] translate-y-[18%]",
|
||||||
|
bottom_right: "bottom-0 right-0 translate-x-[18%] translate-y-[18%]",
|
||||||
|
};
|
||||||
|
|
||||||
|
function Card({ className, children, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const [location] = useLocation();
|
||||||
|
const { appearance } = useTheme();
|
||||||
|
const scope = getBackgroundScopeForPath(location);
|
||||||
|
const cardDecorations = React.useMemo(
|
||||||
|
() =>
|
||||||
|
(appearance.decorativeElements || []).filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.imageUrl &&
|
||||||
|
entry.layer === "card" &&
|
||||||
|
isDecorationScopeActive(entry.scope, scope),
|
||||||
|
),
|
||||||
|
[appearance.decorativeElements, scope],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"portal-card-shell bg-card text-card-foreground flex flex-col gap-4 rounded-xl border border-border/80 py-4 shadow-[var(--portal-card-shadow)] transition-[transform,box-shadow,border-color,background-color] duration-[var(--portal-motion-base)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{cardDecorations.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"portal-card-decoration",
|
||||||
|
cardDecorationAnchorClasses[entry.cardAnchor],
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: `${entry.widthPercent}%`,
|
||||||
|
opacity: entry.opacity / 100,
|
||||||
|
filter: `blur(${entry.blurRadius}px)`,
|
||||||
|
margin: `${entry.cardInsetPercent}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={entry.imageUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-5 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-5 [.border-t]:pt-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
};
|
||||||
239
client/src/components/ui/carousel.tsx
Normal file
239
client/src/components/ui/carousel.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import useEmblaCarousel, {
|
||||||
|
type UseEmblaCarouselType,
|
||||||
|
} from "embla-carousel-react";
|
||||||
|
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type CarouselApi = UseEmblaCarouselType[1];
|
||||||
|
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||||
|
type CarouselOptions = UseCarouselParameters[0];
|
||||||
|
type CarouselPlugin = UseCarouselParameters[1];
|
||||||
|
|
||||||
|
type CarouselProps = {
|
||||||
|
opts?: CarouselOptions;
|
||||||
|
plugins?: CarouselPlugin;
|
||||||
|
orientation?: "horizontal" | "vertical";
|
||||||
|
setApi?: (api: CarouselApi) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CarouselContextProps = {
|
||||||
|
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||||
|
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||||
|
scrollPrev: () => void;
|
||||||
|
scrollNext: () => void;
|
||||||
|
canScrollPrev: boolean;
|
||||||
|
canScrollNext: boolean;
|
||||||
|
} & CarouselProps;
|
||||||
|
|
||||||
|
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||||
|
|
||||||
|
function useCarousel() {
|
||||||
|
const context = React.useContext(CarouselContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useCarousel must be used within a <Carousel />");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Carousel({
|
||||||
|
orientation = "horizontal",
|
||||||
|
opts,
|
||||||
|
setApi,
|
||||||
|
plugins,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||||
|
const [carouselRef, api] = useEmblaCarousel(
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
axis: orientation === "horizontal" ? "x" : "y",
|
||||||
|
},
|
||||||
|
plugins
|
||||||
|
);
|
||||||
|
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||||
|
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||||
|
|
||||||
|
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||||
|
if (!api) return;
|
||||||
|
setCanScrollPrev(api.canScrollPrev());
|
||||||
|
setCanScrollNext(api.canScrollNext());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scrollPrev = React.useCallback(() => {
|
||||||
|
api?.scrollPrev();
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const scrollNext = React.useCallback(() => {
|
||||||
|
api?.scrollNext();
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
const handleKeyDown = React.useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollPrev();
|
||||||
|
} else if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollNext();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollPrev, scrollNext]
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api || !setApi) return;
|
||||||
|
setApi(api);
|
||||||
|
}, [api, setApi]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api) return;
|
||||||
|
onSelect(api);
|
||||||
|
api.on("reInit", onSelect);
|
||||||
|
api.on("select", onSelect);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
api?.off("select", onSelect);
|
||||||
|
};
|
||||||
|
}, [api, onSelect]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CarouselContext.Provider
|
||||||
|
value={{
|
||||||
|
carouselRef,
|
||||||
|
api: api,
|
||||||
|
opts,
|
||||||
|
orientation:
|
||||||
|
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||||
|
scrollPrev,
|
||||||
|
scrollNext,
|
||||||
|
canScrollPrev,
|
||||||
|
canScrollNext,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onKeyDownCapture={handleKeyDown}
|
||||||
|
className={cn("relative", className)}
|
||||||
|
role="region"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
data-slot="carousel"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</CarouselContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const { carouselRef, orientation } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={carouselRef}
|
||||||
|
className="overflow-hidden"
|
||||||
|
data-slot="carousel-content"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex",
|
||||||
|
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const { orientation } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="slide"
|
||||||
|
data-slot="carousel-item"
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 shrink-0 grow-0 basis-full",
|
||||||
|
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselPrevious({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="carousel-previous"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute size-8 rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "top-1/2 -left-12 -translate-y-1/2"
|
||||||
|
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={!canScrollPrev}
|
||||||
|
onClick={scrollPrev}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowLeft />
|
||||||
|
<span className="sr-only">Previous slide</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarouselNext({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="carousel-next"
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute size-8 rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "top-1/2 -right-12 -translate-y-1/2"
|
||||||
|
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={!canScrollNext}
|
||||||
|
onClick={scrollNext}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowRight />
|
||||||
|
<span className="sr-only">Next slide</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
type CarouselApi,
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
CarouselPrevious,
|
||||||
|
CarouselNext,
|
||||||
|
};
|
||||||
355
client/src/components/ui/chart.tsx
Normal file
355
client/src/components/ui/chart.tsx
Normal file
|
|
@ -0,0 +1,355 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as RechartsPrimitive from "recharts";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: "", dark: ".dark" } as const;
|
||||||
|
|
||||||
|
export type ChartConfig = {
|
||||||
|
[k in string]: {
|
||||||
|
label?: React.ReactNode;
|
||||||
|
icon?: React.ComponentType;
|
||||||
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useChart must be used within a <ChartContainer />");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartContainer({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
config: ChartConfig;
|
||||||
|
children: React.ComponentProps<
|
||||||
|
typeof RechartsPrimitive.ResponsiveContainer
|
||||||
|
>["children"];
|
||||||
|
}) {
|
||||||
|
const uniqueId = React.useId();
|
||||||
|
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-slot="chart"
|
||||||
|
data-chart={chartId}
|
||||||
|
className={cn(
|
||||||
|
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(
|
||||||
|
([, config]) => config.theme || config.color
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color =
|
||||||
|
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||||
|
itemConfig.color;
|
||||||
|
return color ? ` --color-${key}: ${color};` : null;
|
||||||
|
})
|
||||||
|
.join("\n")}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||||
|
|
||||||
|
function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
hideLabel?: boolean;
|
||||||
|
hideIndicator?: boolean;
|
||||||
|
indicator?: "line" | "dot" | "dashed";
|
||||||
|
nameKey?: string;
|
||||||
|
labelKey?: string;
|
||||||
|
}) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload;
|
||||||
|
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === "string"
|
||||||
|
? config[label as keyof typeof config]?.label || label
|
||||||
|
: itemConfig?.label;
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>
|
||||||
|
{labelFormatter(value, payload)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||||
|
}, [
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
payload,
|
||||||
|
hideLabel,
|
||||||
|
labelClassName,
|
||||||
|
config,
|
||||||
|
labelKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload
|
||||||
|
.filter(item => item.type !== "none")
|
||||||
|
.map((item, index) => {
|
||||||
|
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const indicatorColor = color || item.payload.fill || item.color;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.dataKey}
|
||||||
|
className={cn(
|
||||||
|
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||||
|
indicator === "dot" && "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||||
|
{
|
||||||
|
"h-2.5 w-2.5": indicator === "dot",
|
||||||
|
"w-1": indicator === "line",
|
||||||
|
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||||
|
indicator === "dashed",
|
||||||
|
"my-0.5": nestLabel && indicator === "dashed",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color-bg": indicatorColor,
|
||||||
|
"--color-border": indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 justify-between leading-none",
|
||||||
|
nestLabel ? "items-end" : "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label || item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value && (
|
||||||
|
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||||
|
{item.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend;
|
||||||
|
|
||||||
|
function ChartLegendContent({
|
||||||
|
className,
|
||||||
|
hideIcon = false,
|
||||||
|
payload,
|
||||||
|
verticalAlign = "bottom",
|
||||||
|
nameKey,
|
||||||
|
}: React.ComponentProps<"div"> &
|
||||||
|
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||||
|
hideIcon?: boolean;
|
||||||
|
nameKey?: string;
|
||||||
|
}) {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload
|
||||||
|
.filter(item => item.type !== "none")
|
||||||
|
.map(item => {
|
||||||
|
const key = `${nameKey || item.dataKey || "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className={cn(
|
||||||
|
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to extract item config from a payload.
|
||||||
|
function getPayloadConfigFromPayload(
|
||||||
|
config: ChartConfig,
|
||||||
|
payload: unknown,
|
||||||
|
key: string
|
||||||
|
) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
"payload" in payload &&
|
||||||
|
typeof payload.payload === "object" &&
|
||||||
|
payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let configLabelKey: string = key;
|
||||||
|
|
||||||
|
if (
|
||||||
|
key in payload &&
|
||||||
|
typeof payload[key as keyof typeof payload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[
|
||||||
|
key as keyof typeof payloadPayload
|
||||||
|
] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config
|
||||||
|
? config[configLabelKey]
|
||||||
|
: config[key as keyof typeof config];
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
};
|
||||||
30
client/src/components/ui/checkbox.tsx
Normal file
30
client/src/components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||||
|
import { CheckIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="flex items-center justify-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox };
|
||||||
31
client/src/components/ui/collapsible.tsx
Normal file
31
client/src/components/ui/collapsible.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||||
|
|
||||||
|
function Collapsible({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
data-slot="collapsible-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleContent
|
||||||
|
data-slot="collapsible-content"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||||
184
client/src/components/ui/command.tsx
Normal file
184
client/src/components/ui/command.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Command as CommandPrimitive } from "cmdk";
|
||||||
|
import { SearchIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
className?: string;
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent
|
||||||
|
className={cn("overflow-hidden p-0", className)}
|
||||||
|
showCloseButton={showCloseButton}
|
||||||
|
>
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="command-input-wrapper"
|
||||||
|
className="flex h-9 items-center gap-2 border-b px-3"
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("bg-border -mx-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
};
|
||||||
250
client/src/components/ui/context-menu.tsx
Normal file
250
client/src/components/ui/context-menu.tsx
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function ContextMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
||||||
|
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||||
|
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioGroup
|
||||||
|
data-slot="context-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubTrigger
|
||||||
|
data-slot="context-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.SubContent
|
||||||
|
data-slot="context-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Portal>
|
||||||
|
<ContextMenuPrimitive.Content
|
||||||
|
data-slot="context-menu-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ContextMenuPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Item
|
||||||
|
data-slot="context-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="context-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.CheckboxItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.RadioItem
|
||||||
|
data-slot="context-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<ContextMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</ContextMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</ContextMenuPrimitive.RadioItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Label
|
||||||
|
data-slot="context-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<ContextMenuPrimitive.Separator
|
||||||
|
data-slot="context-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="context-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ContextMenu,
|
||||||
|
ContextMenuTrigger,
|
||||||
|
ContextMenuContent,
|
||||||
|
ContextMenuItem,
|
||||||
|
ContextMenuCheckboxItem,
|
||||||
|
ContextMenuRadioItem,
|
||||||
|
ContextMenuLabel,
|
||||||
|
ContextMenuSeparator,
|
||||||
|
ContextMenuShortcut,
|
||||||
|
ContextMenuGroup,
|
||||||
|
ContextMenuPortal,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
|
ContextMenuRadioGroup,
|
||||||
|
};
|
||||||
209
client/src/components/ui/dialog.tsx
Normal file
209
client/src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
// Context to track composition state across dialog children
|
||||||
|
const DialogCompositionContext = React.createContext<{
|
||||||
|
isComposing: () => boolean;
|
||||||
|
setComposing: (composing: boolean) => void;
|
||||||
|
justEndedComposing: () => boolean;
|
||||||
|
markCompositionEnd: () => void;
|
||||||
|
}>({
|
||||||
|
isComposing: () => false,
|
||||||
|
setComposing: () => {},
|
||||||
|
justEndedComposing: () => false,
|
||||||
|
markCompositionEnd: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useDialogComposition = () =>
|
||||||
|
React.useContext(DialogCompositionContext);
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
const composingRef = React.useRef(false);
|
||||||
|
const justEndedRef = React.useRef(false);
|
||||||
|
const endTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const contextValue = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
isComposing: () => composingRef.current,
|
||||||
|
setComposing: (composing: boolean) => {
|
||||||
|
composingRef.current = composing;
|
||||||
|
},
|
||||||
|
justEndedComposing: () => justEndedRef.current,
|
||||||
|
markCompositionEnd: () => {
|
||||||
|
justEndedRef.current = true;
|
||||||
|
if (endTimerRef.current) {
|
||||||
|
clearTimeout(endTimerRef.current);
|
||||||
|
}
|
||||||
|
endTimerRef.current = setTimeout(() => {
|
||||||
|
justEndedRef.current = false;
|
||||||
|
}, 150);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogCompositionContext.Provider value={contextValue}>
|
||||||
|
<DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
</DialogCompositionContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DialogOverlay.displayName = "DialogOverlay";
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
onEscapeKeyDown,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}) {
|
||||||
|
const { isComposing } = useDialogComposition();
|
||||||
|
|
||||||
|
const handleEscapeKeyDown = React.useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
// Check both the native isComposing property and our context state
|
||||||
|
// This handles Safari's timing issues with composition events
|
||||||
|
const isCurrentlyComposing = (e as any).isComposing || isComposing();
|
||||||
|
|
||||||
|
// If IME is composing, prevent dialog from closing
|
||||||
|
if (isCurrentlyComposing) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call user's onEscapeKeyDown if provided
|
||||||
|
onEscapeKeyDown?.(e);
|
||||||
|
},
|
||||||
|
[isComposing, onEscapeKeyDown]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onEscapeKeyDown={handleEscapeKeyDown}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger
|
||||||
|
};
|
||||||
|
|
||||||
133
client/src/components/ui/drawer.tsx
Normal file
133
client/src/components/ui/drawer.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Drawer({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||||
|
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||||
|
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||||
|
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||||
|
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
data-slot="drawer-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DrawerPortal data-slot="drawer-portal">
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
data-slot="drawer-content"
|
||||||
|
className={cn(
|
||||||
|
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||||
|
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||||
|
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||||
|
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||||
|
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-header"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
data-slot="drawer-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
data-slot="drawer-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
};
|
||||||
255
client/src/components/ui/dropdown-menu.tsx
Normal file
255
client/src/components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function DropdownMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Trigger
|
||||||
|
data-slot="dropdown-menu-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[12rem] max-w-[calc(100vw-1rem)] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-xl border border-slate-200/90 p-1.5 shadow-[0_18px_40px_-22px_rgba(15,23,42,0.28)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex min-h-11 cursor-default items-center gap-2 rounded-lg px-3 py-2.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
};
|
||||||
104
client/src/components/ui/empty.tsx
Normal file
104
client/src/components/ui/empty.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty"
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-header"
|
||||||
|
className={cn(
|
||||||
|
"flex max-w-sm flex-col items-center gap-2 text-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMediaVariants = cva(
|
||||||
|
"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function EmptyMedia({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-icon"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(emptyMediaVariants({ variant, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-title"
|
||||||
|
className={cn("text-lg font-medium tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="empty-content"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Empty,
|
||||||
|
EmptyHeader,
|
||||||
|
EmptyTitle,
|
||||||
|
EmptyDescription,
|
||||||
|
EmptyContent,
|
||||||
|
EmptyMedia,
|
||||||
|
};
|
||||||
242
client/src/components/ui/field.tsx
Normal file
242
client/src/components/ui/field.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
|
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
data-slot="field-set"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-6",
|
||||||
|
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLegend({
|
||||||
|
className,
|
||||||
|
variant = "legend",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||||
|
return (
|
||||||
|
<legend
|
||||||
|
data-slot="field-legend"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"mb-3 font-medium",
|
||||||
|
"data-[variant=legend]:text-base",
|
||||||
|
"data-[variant=label]:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-group"
|
||||||
|
className={cn(
|
||||||
|
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldVariants = cva(
|
||||||
|
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
||||||
|
horizontal: [
|
||||||
|
"flex-row items-center",
|
||||||
|
"[&>[data-slot=field-label]]:flex-auto",
|
||||||
|
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
],
|
||||||
|
responsive: [
|
||||||
|
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
|
||||||
|
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
||||||
|
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "vertical",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="field"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(fieldVariants({ orientation }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-content"
|
||||||
|
className={cn(
|
||||||
|
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Label>) {
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
||||||
|
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
|
||||||
|
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-label"
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="field-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
|
||||||
|
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
|
||||||
|
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="field-separator"
|
||||||
|
data-content={!!children}
|
||||||
|
className={cn(
|
||||||
|
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Separator className="absolute inset-0 top-1/2" />
|
||||||
|
{children && (
|
||||||
|
<span
|
||||||
|
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||||
|
data-slot="field-separator-content"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldError({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
errors,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
errors?: Array<{ message?: string } | undefined>;
|
||||||
|
}) {
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if (children) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!errors) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors?.length === 1 && errors[0]?.message) {
|
||||||
|
return errors[0].message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||||
|
{errors.map(
|
||||||
|
(error, index) =>
|
||||||
|
error?.message && <li key={index}>{error.message}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}, [children, errors]);
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
data-slot="field-error"
|
||||||
|
className={cn("text-destructive text-sm font-normal", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Field,
|
||||||
|
FieldLabel,
|
||||||
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldSet,
|
||||||
|
FieldContent,
|
||||||
|
FieldTitle,
|
||||||
|
};
|
||||||
168
client/src/components/ui/form.tsx
Normal file
168
client/src/components/ui/form.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
useFormState,
|
||||||
|
type ControllerProps,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
const Form = FormProvider;
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = {
|
||||||
|
name: TName;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue
|
||||||
|
);
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext);
|
||||||
|
const itemContext = React.useContext(FormItemContext);
|
||||||
|
const { getFieldState } = useFormContext();
|
||||||
|
const formState = useFormState({ name: fieldContext.name });
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState);
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error("useFormField should be used within <FormField>");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = itemContext;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormItemContextValue = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue
|
||||||
|
);
|
||||||
|
|
||||||
|
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const id = React.useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div
|
||||||
|
data-slot="form-item"
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
const { error, formItemId } = useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="form-label"
|
||||||
|
data-error={!!error}
|
||||||
|
className={cn("data-[error=true]:text-destructive", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||||
|
useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
data-slot="form-control"
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { formDescriptionId } = useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-description"
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { error, formMessageId } = useFormField();
|
||||||
|
const body = error ? String(error?.message ?? "") : props.children;
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-message"
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-destructive text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
};
|
||||||
42
client/src/components/ui/hover-card.tsx
Normal file
42
client/src/components/ui/hover-card.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function HoverCard({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||||
|
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HoverCardContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||||
|
<HoverCardPrimitive.Content
|
||||||
|
data-slot="hover-card-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</HoverCardPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||||
168
client/src/components/ui/input-group.tsx
Normal file
168
client/src/components/ui/input-group.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-group"
|
||||||
|
role="group"
|
||||||
|
className={cn(
|
||||||
|
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
|
||||||
|
"h-9 min-w-0 has-[>textarea]:h-auto",
|
||||||
|
|
||||||
|
// Variants based on alignment.
|
||||||
|
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
||||||
|
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
||||||
|
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||||
|
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||||
|
|
||||||
|
// Focus state.
|
||||||
|
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
|
||||||
|
|
||||||
|
// Error state.
|
||||||
|
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
||||||
|
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupAddonVariants = cva(
|
||||||
|
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
align: {
|
||||||
|
"inline-start":
|
||||||
|
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||||
|
"inline-end":
|
||||||
|
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
||||||
|
"block-start":
|
||||||
|
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
|
||||||
|
"block-end":
|
||||||
|
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
align: "inline-start",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function InputGroupAddon({
|
||||||
|
className,
|
||||||
|
align = "inline-start",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="input-group-addon"
|
||||||
|
data-align={align}
|
||||||
|
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||||
|
onClick={e => {
|
||||||
|
if ((e.target as HTMLElement).closest("button")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.currentTarget.parentElement?.querySelector("input")?.focus();
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupButtonVariants = cva(
|
||||||
|
"text-sm shadow-none flex gap-2 items-center",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||||
|
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||||
|
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "xs",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function InputGroupButton({
|
||||||
|
className,
|
||||||
|
type = "button",
|
||||||
|
variant = "ghost",
|
||||||
|
size = "xs",
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||||
|
VariantProps<typeof inputGroupButtonVariants>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type={type}
|
||||||
|
data-size={size}
|
||||||
|
variant={variant}
|
||||||
|
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupTextarea({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupText,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupTextarea,
|
||||||
|
};
|
||||||
75
client/src/components/ui/input-otp.tsx
Normal file
75
client/src/components/ui/input-otp.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { OTPInput, OTPInputContext } from "input-otp";
|
||||||
|
import { MinusIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function InputOTP({
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof OTPInput> & {
|
||||||
|
containerClassName?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<OTPInput
|
||||||
|
data-slot="input-otp"
|
||||||
|
containerClassName={cn(
|
||||||
|
"flex items-center gap-2 has-disabled:opacity-50",
|
||||||
|
containerClassName
|
||||||
|
)}
|
||||||
|
className={cn("disabled:cursor-not-allowed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-group"
|
||||||
|
className={cn("flex items-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSlot({
|
||||||
|
index,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
index: number;
|
||||||
|
}) {
|
||||||
|
const inputOTPContext = React.useContext(OTPInputContext);
|
||||||
|
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-slot"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
{hasFakeCaret && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||||
|
<MinusIcon />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||||
70
client/src/components/ui/input.tsx
Normal file
70
client/src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { useDialogComposition } from "@/components/ui/dialog";
|
||||||
|
import { useComposition } from "@/hooks/useComposition";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
function Input({
|
||||||
|
className,
|
||||||
|
type,
|
||||||
|
onKeyDown,
|
||||||
|
onCompositionStart,
|
||||||
|
onCompositionEnd,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"input">) {
|
||||||
|
// Get dialog composition context if available (will be no-op if not inside Dialog)
|
||||||
|
const dialogComposition = useDialogComposition();
|
||||||
|
|
||||||
|
// Add composition event handlers to support input method editor (IME) for CJK languages.
|
||||||
|
const {
|
||||||
|
onCompositionStart: handleCompositionStart,
|
||||||
|
onCompositionEnd: handleCompositionEnd,
|
||||||
|
onKeyDown: handleKeyDown,
|
||||||
|
} = useComposition<HTMLInputElement>({
|
||||||
|
onKeyDown: (e) => {
|
||||||
|
// Check if this is an Enter key that should be blocked
|
||||||
|
const isComposing = (e.nativeEvent as any).isComposing || dialogComposition.justEndedComposing();
|
||||||
|
|
||||||
|
// If Enter key is pressed while composing or just after composition ended,
|
||||||
|
// don't call the user's onKeyDown (this blocks the business logic)
|
||||||
|
if (e.key === "Enter" && isComposing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, call the user's onKeyDown
|
||||||
|
onKeyDown?.(e);
|
||||||
|
},
|
||||||
|
onCompositionStart: e => {
|
||||||
|
dialogComposition.setComposing(true);
|
||||||
|
onCompositionStart?.(e);
|
||||||
|
},
|
||||||
|
onCompositionEnd: e => {
|
||||||
|
// Mark that composition just ended - this helps handle the Enter key that confirms input
|
||||||
|
dialogComposition.markCompositionEnd();
|
||||||
|
// Delay setting composing to false to handle Safari's event order
|
||||||
|
// In Safari, compositionEnd fires before the ESC keydown event
|
||||||
|
setTimeout(() => {
|
||||||
|
dialogComposition.setComposing(false);
|
||||||
|
}, 100);
|
||||||
|
onCompositionEnd?.(e);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-white/90 px-3 py-1 text-base shadow-xs transition-[color,box-shadow,border-color,background-color] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium hover:border-primary/25 hover:bg-white disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onCompositionStart={handleCompositionStart}
|
||||||
|
onCompositionEnd={handleCompositionEnd}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input };
|
||||||
193
client/src/components/ui/item.tsx
Normal file
193
client/src/components/ui/item.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
|
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="list"
|
||||||
|
data-slot="item-group"
|
||||||
|
className={cn("group/item-group flex flex-col", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="item-separator"
|
||||||
|
orientation="horizontal"
|
||||||
|
className={cn("my-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemVariants = cva(
|
||||||
|
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
outline: "border-border",
|
||||||
|
muted: "bg-muted/50",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "p-4 gap-4 ",
|
||||||
|
sm: "py-3 px-4 gap-2.5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function Item({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> &
|
||||||
|
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "div";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="item"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(itemVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemMediaVariants = cva(
|
||||||
|
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-transparent",
|
||||||
|
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
image:
|
||||||
|
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function ItemMedia({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-media"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(itemMediaVariants({ variant, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-content"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-title"
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="item-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
|
||||||
|
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-actions"
|
||||||
|
className={cn("flex items-center gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-header"
|
||||||
|
className={cn(
|
||||||
|
"flex basis-full items-center justify-between gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="item-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex basis-full items-center justify-between gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Item,
|
||||||
|
ItemMedia,
|
||||||
|
ItemContent,
|
||||||
|
ItemActions,
|
||||||
|
ItemGroup,
|
||||||
|
ItemSeparator,
|
||||||
|
ItemTitle,
|
||||||
|
ItemDescription,
|
||||||
|
ItemHeader,
|
||||||
|
ItemFooter,
|
||||||
|
};
|
||||||
28
client/src/components/ui/kbd.tsx
Normal file
28
client/src/components/ui/kbd.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
|
||||||
|
"[&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd-group"
|
||||||
|
className={cn("inline-flex items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Kbd, KbdGroup };
|
||||||
22
client/src/components/ui/label.tsx
Normal file
22
client/src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label };
|
||||||
274
client/src/components/ui/menubar.tsx
Normal file
274
client/src/components/ui/menubar.tsx
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as MenubarPrimitive from "@radix-ui/react-menubar";
|
||||||
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Menubar({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.Root
|
||||||
|
data-slot="menubar"
|
||||||
|
className={cn(
|
||||||
|
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarMenu({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
||||||
|
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
||||||
|
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
||||||
|
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.Trigger
|
||||||
|
data-slot="menubar-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarContent({
|
||||||
|
className,
|
||||||
|
align = "start",
|
||||||
|
alignOffset = -4,
|
||||||
|
sideOffset = 8,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<MenubarPortal>
|
||||||
|
<MenubarPrimitive.Content
|
||||||
|
data-slot="menubar-content"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenubarPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.Item
|
||||||
|
data-slot="menubar-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.CheckboxItem
|
||||||
|
data-slot="menubar-checkbox-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<MenubarPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</MenubarPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenubarPrimitive.CheckboxItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.RadioItem
|
||||||
|
data-slot="menubar-radio-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
|
<MenubarPrimitive.ItemIndicator>
|
||||||
|
<CircleIcon className="size-2 fill-current" />
|
||||||
|
</MenubarPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenubarPrimitive.RadioItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.Label
|
||||||
|
data-slot="menubar-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.Separator
|
||||||
|
data-slot="menubar-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="menubar-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
||||||
|
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.SubTrigger
|
||||||
|
data-slot="menubar-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||||
|
</MenubarPrimitive.SubTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive.SubContent
|
||||||
|
data-slot="menubar-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Menubar,
|
||||||
|
MenubarPortal,
|
||||||
|
MenubarMenu,
|
||||||
|
MenubarTrigger,
|
||||||
|
MenubarContent,
|
||||||
|
MenubarGroup,
|
||||||
|
MenubarSeparator,
|
||||||
|
MenubarLabel,
|
||||||
|
MenubarItem,
|
||||||
|
MenubarShortcut,
|
||||||
|
MenubarCheckboxItem,
|
||||||
|
MenubarRadioGroup,
|
||||||
|
MenubarRadioItem,
|
||||||
|
MenubarSub,
|
||||||
|
MenubarSubTrigger,
|
||||||
|
MenubarSubContent,
|
||||||
|
};
|
||||||
168
client/src/components/ui/navigation-menu.tsx
Normal file
168
client/src/components/ui/navigation-menu.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
import { ChevronDownIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function NavigationMenu({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
viewport = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||||
|
viewport?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Root
|
||||||
|
data-slot="navigation-menu"
|
||||||
|
data-viewport={viewport}
|
||||||
|
className={cn(
|
||||||
|
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{viewport && <NavigationMenuViewport />}
|
||||||
|
</NavigationMenuPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.List
|
||||||
|
data-slot="navigation-menu-list"
|
||||||
|
className={cn(
|
||||||
|
"group flex flex-1 list-none items-center justify-center gap-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Item
|
||||||
|
data-slot="navigation-menu-item"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigationMenuTriggerStyle = cva(
|
||||||
|
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
);
|
||||||
|
|
||||||
|
function NavigationMenuTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Trigger
|
||||||
|
data-slot="navigation-menu-trigger"
|
||||||
|
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}{" "}
|
||||||
|
<ChevronDownIcon
|
||||||
|
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</NavigationMenuPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Content
|
||||||
|
data-slot="navigation-menu-content"
|
||||||
|
className={cn(
|
||||||
|
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||||
|
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuViewport({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-full left-0 isolate z-50 flex justify-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<NavigationMenuPrimitive.Viewport
|
||||||
|
data-slot="navigation-menu-viewport"
|
||||||
|
className={cn(
|
||||||
|
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuLink({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Link
|
||||||
|
data-slot="navigation-menu-link"
|
||||||
|
className={cn(
|
||||||
|
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavigationMenuIndicator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||||
|
return (
|
||||||
|
<NavigationMenuPrimitive.Indicator
|
||||||
|
data-slot="navigation-menu-indicator"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||||
|
</NavigationMenuPrimitive.Indicator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
NavigationMenu,
|
||||||
|
NavigationMenuList,
|
||||||
|
NavigationMenuItem,
|
||||||
|
NavigationMenuContent,
|
||||||
|
NavigationMenuTrigger,
|
||||||
|
NavigationMenuLink,
|
||||||
|
NavigationMenuIndicator,
|
||||||
|
NavigationMenuViewport,
|
||||||
|
navigationMenuTriggerStyle,
|
||||||
|
};
|
||||||
127
client/src/components/ui/pagination.tsx
Normal file
127
client/src/components/ui/pagination.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
|
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
aria-label="pagination"
|
||||||
|
data-slot="pagination"
|
||||||
|
className={cn("mx-auto flex w-full justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="pagination-content"
|
||||||
|
className={cn("flex flex-row items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||||
|
return <li data-slot="pagination-item" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginationLinkProps = {
|
||||||
|
isActive?: boolean;
|
||||||
|
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||||
|
React.ComponentProps<"a">;
|
||||||
|
|
||||||
|
function PaginationLink({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: PaginationLinkProps) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
data-slot="pagination-link"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: isActive ? "outline" : "ghost",
|
||||||
|
size,
|
||||||
|
}),
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationPrevious({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
<span className="hidden sm:block">Previous</span>
|
||||||
|
</PaginationLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationNext({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to next page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:block">Next</span>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</PaginationLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-slot="pagination-ellipsis"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
<span className="sr-only">More pages</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationPrevious,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationEllipsis,
|
||||||
|
};
|
||||||
46
client/src/components/ui/popover.tsx
Normal file
46
client/src/components/ui/popover.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||||
29
client/src/components/ui/progress.tsx
Normal file
29
client/src/components/ui/progress.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className="bg-primary h-full w-full flex-1 transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress };
|
||||||
43
client/src/components/ui/radio-group.tsx
Normal file
43
client/src/components/ui/radio-group.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||||
|
import { CircleIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn("grid gap-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator
|
||||||
|
data-slot="radio-group-indicator"
|
||||||
|
className="relative flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem };
|
||||||
54
client/src/components/ui/resizable.tsx
Normal file
54
client/src/components/ui/resizable.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import { GripVerticalIcon } from "lucide-react";
|
||||||
|
import * as ResizablePrimitive from "react-resizable-panels";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function ResizablePanelGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
|
||||||
|
return (
|
||||||
|
<ResizablePrimitive.PanelGroup
|
||||||
|
data-slot="resizable-panel-group"
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResizablePanel({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||||
|
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResizableHandle({
|
||||||
|
withHandle,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||||
|
withHandle?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ResizablePrimitive.PanelResizeHandle
|
||||||
|
data-slot="resizable-handle"
|
||||||
|
className={cn(
|
||||||
|
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{withHandle && (
|
||||||
|
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||||
|
<GripVerticalIcon className="size-2.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePrimitive.PanelResizeHandle>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||||
56
client/src/components/ui/scroll-area.tsx
Normal file
56
client/src/components/ui/scroll-area.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar };
|
||||||
185
client/src/components/ui/select.tsx
Normal file
185
client/src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "popper",
|
||||||
|
align = "center",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
};
|
||||||
26
client/src/components/ui/separator.tsx
Normal file
26
client/src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
139
client/src/components/ui/sheet.tsx
Normal file
139
client/src/components/ui/sheet.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SheetPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
side === "right" &&
|
||||||
|
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||||
|
side === "left" &&
|
||||||
|
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||||
|
side === "top" &&
|
||||||
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||||
|
side === "bottom" &&
|
||||||
|
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
};
|
||||||
733
client/src/components/ui/sidebar.tsx
Normal file
733
client/src/components/ui/sidebar.tsx
Normal file
|
|
@ -0,0 +1,733 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { useIsMobile } from "@/hooks/useMobile";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, VariantProps } from "class-variance-authority";
|
||||||
|
import { PanelLeftIcon } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||||
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||||
|
const SIDEBAR_WIDTH = "16rem";
|
||||||
|
const SIDEBAR_WIDTH_MOBILE = "min(20rem, calc(100vw - 1rem))";
|
||||||
|
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||||
|
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||||
|
|
||||||
|
type SidebarContextProps = {
|
||||||
|
state: "expanded" | "collapsed";
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
openMobile: boolean;
|
||||||
|
setOpenMobile: (open: boolean) => void;
|
||||||
|
isMobile: boolean;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||||
|
|
||||||
|
function useSidebar() {
|
||||||
|
const context = React.useContext(SidebarContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarProvider({
|
||||||
|
defaultOpen = true,
|
||||||
|
open: openProp,
|
||||||
|
onOpenChange: setOpenProp,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [openMobile, setOpenMobile] = React.useState(false);
|
||||||
|
|
||||||
|
// This is the internal state of the sidebar.
|
||||||
|
// We use openProp and setOpenProp for control from outside the component.
|
||||||
|
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||||
|
const open = openProp ?? _open;
|
||||||
|
const setOpen = React.useCallback(
|
||||||
|
(value: boolean | ((value: boolean) => boolean)) => {
|
||||||
|
const openState = typeof value === "function" ? value(open) : value;
|
||||||
|
if (setOpenProp) {
|
||||||
|
setOpenProp(openState);
|
||||||
|
} else {
|
||||||
|
_setOpen(openState);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This sets the cookie to keep the sidebar state.
|
||||||
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||||
|
},
|
||||||
|
[setOpenProp, open]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper to toggle the sidebar.
|
||||||
|
const toggleSidebar = React.useCallback(() => {
|
||||||
|
return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open);
|
||||||
|
}, [isMobile, setOpen, setOpenMobile]);
|
||||||
|
|
||||||
|
// Adds a keyboard shortcut to toggle the sidebar.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||||
|
(event.metaKey || event.ctrlKey)
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleSidebar();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [toggleSidebar]);
|
||||||
|
|
||||||
|
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||||
|
// This makes it easier to style the sidebar with Tailwind classes.
|
||||||
|
const state = open ? "expanded" : "collapsed";
|
||||||
|
|
||||||
|
const contextValue = React.useMemo<SidebarContextProps>(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
isMobile,
|
||||||
|
openMobile,
|
||||||
|
setOpenMobile,
|
||||||
|
toggleSidebar,
|
||||||
|
}),
|
||||||
|
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarContext.Provider value={contextValue}>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-wrapper"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": SIDEBAR_WIDTH,
|
||||||
|
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||||
|
...style,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar({
|
||||||
|
side = "left",
|
||||||
|
variant = "sidebar",
|
||||||
|
collapsible = "offcanvas",
|
||||||
|
disableTransition = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
side?: "left" | "right";
|
||||||
|
variant?: "sidebar" | "floating" | "inset";
|
||||||
|
collapsible?: "offcanvas" | "icon" | "none";
|
||||||
|
disableTransition?: boolean;
|
||||||
|
}) {
|
||||||
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||||
|
|
||||||
|
if (collapsible === "none") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar"
|
||||||
|
className={cn(
|
||||||
|
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||||
|
<SheetContent
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar"
|
||||||
|
data-mobile="true"
|
||||||
|
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) max-w-[calc(100vw-1rem)] overflow-y-auto p-0 shadow-xl [&>button]:hidden"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
side={side}
|
||||||
|
>
|
||||||
|
<SheetHeader className="sr-only">
|
||||||
|
<SheetTitle>Sidebar</SheetTitle>
|
||||||
|
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex h-full w-full flex-col">{children}</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group peer text-sidebar-foreground hidden md:block"
|
||||||
|
data-state={state}
|
||||||
|
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||||
|
data-variant={variant}
|
||||||
|
data-side={side}
|
||||||
|
data-slot="sidebar"
|
||||||
|
>
|
||||||
|
{/* This is what handles the sidebar gap on desktop */}
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-gap"
|
||||||
|
className={cn(
|
||||||
|
"relative w-(--sidebar-width) bg-transparent",
|
||||||
|
disableTransition
|
||||||
|
? "transition-none"
|
||||||
|
: "transition-[width] duration-200 ease-linear",
|
||||||
|
"group-data-[collapsible=offcanvas]:w-0",
|
||||||
|
"group-data-[side=right]:rotate-180",
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||||
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-container"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) md:flex",
|
||||||
|
disableTransition
|
||||||
|
? "transition-none"
|
||||||
|
: "transition-[left,right,width] duration-200 ease-linear",
|
||||||
|
side === "left"
|
||||||
|
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||||
|
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||||
|
// Adjust the padding for floating and inset variants.
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||||
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar-inner"
|
||||||
|
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarTrigger({
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { toggleSidebar } = useSidebar();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-sidebar="trigger"
|
||||||
|
data-slot="sidebar-trigger"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn("size-7", className)}
|
||||||
|
onClick={event => {
|
||||||
|
onClick?.(event);
|
||||||
|
toggleSidebar();
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<PanelLeftIcon />
|
||||||
|
<span className="sr-only">Toggle Sidebar</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||||
|
const { toggleSidebar } = useSidebar();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
data-sidebar="rail"
|
||||||
|
data-slot="sidebar-rail"
|
||||||
|
aria-label="Toggle Sidebar"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
title="Toggle Sidebar"
|
||||||
|
className={cn(
|
||||||
|
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||||
|
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||||
|
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||||
|
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||||
|
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||||
|
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
data-slot="sidebar-inset"
|
||||||
|
className={cn(
|
||||||
|
"bg-background relative flex w-full flex-1 flex-col",
|
||||||
|
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Input>) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="sidebar-input"
|
||||||
|
data-sidebar="input"
|
||||||
|
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-header"
|
||||||
|
data-sidebar="header"
|
||||||
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-footer"
|
||||||
|
data-sidebar="footer"
|
||||||
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="sidebar-separator"
|
||||||
|
data-sidebar="separator"
|
||||||
|
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-content"
|
||||||
|
data-sidebar="content"
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-group"
|
||||||
|
data-sidebar="group"
|
||||||
|
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupLabel({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "div";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-group-label"
|
||||||
|
data-sidebar="group-label"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupAction({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-group-action"
|
||||||
|
data-sidebar="group-action"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
|
"after:absolute after:-inset-2 md:after:hidden",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-group-content"
|
||||||
|
data-sidebar="group-content"
|
||||||
|
className={cn("w-full text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu"
|
||||||
|
data-sidebar="menu"
|
||||||
|
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-item"
|
||||||
|
data-sidebar="menu-item"
|
||||||
|
className={cn("group/menu-item relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarMenuButtonVariants = cva(
|
||||||
|
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||||
|
outline:
|
||||||
|
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-8 text-sm",
|
||||||
|
sm: "h-7 text-xs",
|
||||||
|
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function SidebarMenuButton({
|
||||||
|
asChild = false,
|
||||||
|
isActive = false,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
tooltip,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
isActive?: boolean;
|
||||||
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||||
|
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
const { isMobile, state } = useSidebar();
|
||||||
|
|
||||||
|
const button = (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-menu-button"
|
||||||
|
data-sidebar="menu-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!tooltip) {
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof tooltip === "string") {
|
||||||
|
tooltip = {
|
||||||
|
children: tooltip,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
hidden={state !== "collapsed" || isMobile}
|
||||||
|
{...tooltip}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuAction({
|
||||||
|
className,
|
||||||
|
asChild = false,
|
||||||
|
showOnHover = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
showOnHover?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-menu-action"
|
||||||
|
data-sidebar="menu-action"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
// Increases the hit area of the button on mobile.
|
||||||
|
"after:absolute after:-inset-2 md:after:hidden",
|
||||||
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
showOnHover &&
|
||||||
|
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuBadge({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-menu-badge"
|
||||||
|
data-sidebar="menu-badge"
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||||
|
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||||
|
"peer-data-[size=sm]/menu-button:top-1",
|
||||||
|
"peer-data-[size=default]/menu-button:top-1.5",
|
||||||
|
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSkeleton({
|
||||||
|
className,
|
||||||
|
showIcon = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showIcon?: boolean;
|
||||||
|
}) {
|
||||||
|
// Random width between 50 to 90%.
|
||||||
|
const width = React.useMemo(() => {
|
||||||
|
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-menu-skeleton"
|
||||||
|
data-sidebar="menu-skeleton"
|
||||||
|
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{showIcon && (
|
||||||
|
<Skeleton
|
||||||
|
className="size-4 rounded-md"
|
||||||
|
data-sidebar="menu-skeleton-icon"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Skeleton
|
||||||
|
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||||
|
data-sidebar="menu-skeleton-text"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--skeleton-width": width,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu-sub"
|
||||||
|
data-sidebar="menu-sub"
|
||||||
|
className={cn(
|
||||||
|
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSubItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-sub-item"
|
||||||
|
data-sidebar="menu-sub-item"
|
||||||
|
className={cn("group/menu-sub-item relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSubButton({
|
||||||
|
asChild = false,
|
||||||
|
size = "md",
|
||||||
|
isActive = false,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
isActive?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "a";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="sidebar-menu-sub-button"
|
||||||
|
data-sidebar="menu-sub-button"
|
||||||
|
data-size={size}
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||||
|
size === "sm" && "text-xs",
|
||||||
|
size === "md" && "text-sm",
|
||||||
|
"group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupAction,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInput,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
|
SidebarMenuBadge,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSkeleton,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarRail,
|
||||||
|
SidebarSeparator,
|
||||||
|
SidebarTrigger,
|
||||||
|
useSidebar
|
||||||
|
};
|
||||||
13
client/src/components/ui/skeleton.tsx
Normal file
13
client/src/components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
61
client/src/components/ui/slider.tsx
Normal file
61
client/src/components/ui/slider.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Slider({
|
||||||
|
className,
|
||||||
|
defaultValue,
|
||||||
|
value,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||||
|
const _values = React.useMemo(
|
||||||
|
() =>
|
||||||
|
Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: Array.isArray(defaultValue)
|
||||||
|
? defaultValue
|
||||||
|
: [min, max],
|
||||||
|
[value, defaultValue, min, max]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
data-slot="slider"
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track
|
||||||
|
data-slot="slider-track"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Range
|
||||||
|
data-slot="slider-range"
|
||||||
|
className={cn(
|
||||||
|
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
{Array.from({ length: _values.length }, (_, index) => (
|
||||||
|
<SliderPrimitive.Thumb
|
||||||
|
data-slot="slider-thumb"
|
||||||
|
key={index}
|
||||||
|
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Slider };
|
||||||
23
client/src/components/ui/sonner.tsx
Normal file
23
client/src/components/ui/sonner.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={resolvedTheme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "var(--popover)",
|
||||||
|
"--normal-text": "var(--popover-foreground)",
|
||||||
|
"--normal-border": "var(--border)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Toaster };
|
||||||
16
client/src/components/ui/spinner.tsx
Normal file
16
client/src/components/ui/spinner.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { Loader2Icon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||||
|
return (
|
||||||
|
<Loader2Icon
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading"
|
||||||
|
className={cn("size-4 animate-spin", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Spinner };
|
||||||
29
client/src/components/ui/switch.tsx
Normal file
29
client/src/components/ui/switch.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
className={cn(
|
||||||
|
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className={cn(
|
||||||
|
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
114
client/src/components/ui/table.tsx
Normal file
114
client/src/components/ui/table.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
};
|
||||||
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