prod setup
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -26,3 +26,7 @@ pnpm-debug.log*
|
|||||||
|
|
||||||
# temp
|
# temp
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# runtime data (uploads + JSON content edited via /admin)
|
||||||
|
# kept on the server, not deployed via FTP
|
||||||
|
data/
|
||||||
|
|||||||
20
CLAUDE.md
20
CLAUDE.md
@@ -22,7 +22,23 @@ Site vitrine personnel en **Astro 6 hybrid** (SSG public + SSR admin), identité
|
|||||||
## Points critiques à connaître
|
## Points critiques à connaître
|
||||||
|
|
||||||
### Content
|
### Content
|
||||||
Tout le contenu du site est dans `public/content/*.json` (7 fichiers). Chaque fichier est validé par un schéma Zod dans `src/content/schemas/`. Édition possible via `/admin` ou directement en éditant le JSON.
|
Tout le contenu du site est dans **`data/content/*.json`** (7 fichiers, **gitignored**). Chaque fichier est validé par un schéma Zod dans `src/content/schemas/`. Édition possible via `/admin` ou directement en éditant le JSON. Au premier run, les JSON sont copiés depuis `public/content/*.json` (seeds versionnés en repo) si `data/content/` est vide — voir `src/lib/content.ts`.
|
||||||
|
|
||||||
|
### Persistance prod (Plesk)
|
||||||
|
Le déploiement Gitea CI/CD FTP **rebuilt `public/` à chaque push**. C'est pour ça que tout le contenu dynamique est dans **`data/`** (gitignored, conservé entre déploiements) :
|
||||||
|
- `data/content/*.json` — JSON édités via `/admin`
|
||||||
|
- `data/uploads/img/{folder}/*` — images uploadées
|
||||||
|
- `data/uploads/assets/{folder}/*` — PDF (CV)
|
||||||
|
|
||||||
|
Les fichiers de `data/uploads/` sont servis via la route **`/api/files/[...path]`** (Astro SSR) — donc les chemins en JSON sont du type `/api/files/img/pp/foo.png` ou `/api/files/assets/cv.pdf`.
|
||||||
|
|
||||||
|
**Init prod (premier déploiement post-refonte)** :
|
||||||
|
1. Sur le serveur Plesk, créer `data/{content,uploads/img,uploads/assets}` avec les bons droits (utilisateur Node).
|
||||||
|
2. Copier les seeds : `cp public/content/*.json data/content/` et `cp -r public/img/* data/uploads/img/` et `cp public/assets/cv.pdf data/uploads/assets/`.
|
||||||
|
3. Vérifier que la CI Gitea **n'écrase pas** `data/` (l'exclure du déploiement FTP, ou s'assurer qu'il ne fait pas partie du payload).
|
||||||
|
4. Tester un upload via `/admin` → vérifier que le fichier arrive bien dans `data/uploads/...` et que `/api/files/...` le sert.
|
||||||
|
|
||||||
|
**Sécurité** : `/api/files/[...path].ts` protège contre le path traversal (résolu via `resolve()` + check préfixe). Les uploads passent par `/api/upload` qui est JWT-gated dans `src/middleware.ts`.
|
||||||
|
|
||||||
### Admin
|
### Admin
|
||||||
Protégé par mot de passe bcrypt + JWT cookie httpOnly 7j. Middleware dans `src/middleware.ts`. Routes `/admin/*` et `/api/*` ont `export const prerender = false`.
|
Protégé par mot de passe bcrypt + JWT cookie httpOnly 7j. Middleware dans `src/middleware.ts`. Routes `/admin/*` et `/api/*` ont `export const prerender = false`.
|
||||||
@@ -72,7 +88,7 @@ Tous les composants animés **doivent** respecter `prefers-reduced-motion`. Les
|
|||||||
## Workflow d'édition
|
## Workflow d'édition
|
||||||
|
|
||||||
1. Lire `docs/README.md` puis `docs/01-analyse-et-plan.md` + `docs/02-moodboard-freeparty.md` pour la direction artistique
|
1. Lire `docs/README.md` puis `docs/01-analyse-et-plan.md` + `docs/02-moodboard-freeparty.md` pour la direction artistique
|
||||||
2. Pour un changement de contenu → éditer `public/content/*.json` (ou via `/admin`)
|
2. Pour un changement de contenu → éditer `data/content/*.json` (ou via `/admin`). Les seeds dans `public/content/*.json` ne sont utilisés qu'au premier run (bootstrap)
|
||||||
3. Pour un changement de design → modifier `src/styles/global.css` + composants
|
3. Pour un changement de design → modifier `src/styles/global.css` + composants
|
||||||
4. Pour un changement structurel → modifier schéma Zod d'abord, puis composant, puis JSON
|
4. Pour un changement structurel → modifier schéma Zod d'abord, puis composant, puis JSON
|
||||||
|
|
||||||
|
|||||||
40
package-lock.json
generated
40
package-lock.json
generated
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "portfolio",
|
"name": "portfolio",
|
||||||
"version": "0.0.1",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "portfolio",
|
"name": "portfolio",
|
||||||
"version": "0.0.1",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^10.0.5",
|
"@astrojs/node": "^10.0.5",
|
||||||
"@astrojs/react": "^5.0.3",
|
"@astrojs/react": "^5.0.3",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@iconify-icon/react": "^3.0.3",
|
"@iconify-icon/react": "^3.0.3",
|
||||||
"@react-three/drei": "^10.7.7",
|
"@react-three/drei": "^10.7.7",
|
||||||
"@react-three/fiber": "^9.6.0",
|
"@react-three/fiber": "^9.6.0",
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
"react-hook-form": "^7.75.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"three": "^0.184.0",
|
"three": "^0.184.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
@@ -1084,6 +1086,18 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@hookform/resolvers": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/utils": "^0.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-hook-form": "^7.55.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@iconify-icon/react": {
|
"node_modules/@iconify-icon/react": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@iconify-icon/react/-/react-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@iconify-icon/react/-/react-3.0.3.tgz",
|
||||||
@@ -2181,6 +2195,12 @@
|
|||||||
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
|
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.2.2",
|
"version": "4.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
|
||||||
@@ -6045,6 +6065,22 @@
|
|||||||
"react": "^19.2.5"
|
"react": "^19.2.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-hook-form": {
|
||||||
|
"version": "7.75.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.75.0.tgz",
|
||||||
|
"integrity": "sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/react-hook-form"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^10.0.5",
|
"@astrojs/node": "^10.0.5",
|
||||||
"@astrojs/react": "^5.0.3",
|
"@astrojs/react": "^5.0.3",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@iconify-icon/react": "^3.0.3",
|
"@iconify-icon/react": "^3.0.3",
|
||||||
"@react-three/drei": "^10.7.7",
|
"@react-three/drei": "^10.7.7",
|
||||||
"@react-three/fiber": "^9.6.0",
|
"@react-three/fiber": "^9.6.0",
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
"react-hook-form": "^7.75.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"three": "^0.184.0",
|
"three": "^0.184.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{
|
{
|
||||||
"id": "almeria",
|
"id": "almeria",
|
||||||
"company": "Almeria",
|
"company": "Almeria",
|
||||||
"logo": "/img/company/almeria.png",
|
"logo": "/api/files/img/company/almeria.png",
|
||||||
"role": "Développeur",
|
"role": "Développeur",
|
||||||
"period": "Depuis 2023",
|
"period": "Depuis 2023",
|
||||||
"duration": "3 ans",
|
"duration": "3 ans",
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
{
|
{
|
||||||
"id": "cerema",
|
"id": "cerema",
|
||||||
"company": "CEREMA",
|
"company": "CEREMA",
|
||||||
"logo": "/img/company/cerema.png",
|
"logo": "/api/files/img/company/cerema.png",
|
||||||
"role": "Technicien évaluation mesure trafic & mobilité",
|
"role": "Technicien évaluation mesure trafic & mobilité",
|
||||||
"period": "2021 - 2023",
|
"period": "2021 - 2023",
|
||||||
"duration": "2 ans",
|
"duration": "2 ans",
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
{
|
{
|
||||||
"id": "sncf",
|
"id": "sncf",
|
||||||
"company": "Réseau SNCF",
|
"company": "Réseau SNCF",
|
||||||
"logo": "/img/company/sncf.png",
|
"logo": "/api/files/img/company/sncf.png",
|
||||||
"role": "Agent télécom",
|
"role": "Agent télécom",
|
||||||
"period": "2019 - 2021",
|
"period": "2019 - 2021",
|
||||||
"duration": "2 ans",
|
"duration": "2 ans",
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
{
|
{
|
||||||
"id": "novoferm",
|
"id": "novoferm",
|
||||||
"company": "Novoferm",
|
"company": "Novoferm",
|
||||||
"logo": "/img/company/novoferm.png",
|
"logo": "/api/files/img/company/novoferm.png",
|
||||||
"role": "Stage développeur",
|
"role": "Stage développeur",
|
||||||
"period": "2019",
|
"period": "2019",
|
||||||
"duration": "7 semaines",
|
"duration": "7 semaines",
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
{
|
{
|
||||||
"id": "pivotpoint",
|
"id": "pivotpoint",
|
||||||
"company": "Pivot Point",
|
"company": "Pivot Point",
|
||||||
"logo": "/img/company/pivotpoint.png",
|
"logo": "/api/files/img/company/pivotpoint.png",
|
||||||
"role": "Stage développeur",
|
"role": "Stage développeur",
|
||||||
"period": "2018",
|
"period": "2018",
|
||||||
"duration": "1 semaine",
|
"duration": "1 semaine",
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
{
|
{
|
||||||
"id": "actemium",
|
"id": "actemium",
|
||||||
"company": "Actemium",
|
"company": "Actemium",
|
||||||
"logo": "/img/company/actemium.png",
|
"logo": "/api/files/img/company/actemium.png",
|
||||||
"role": "Stage développeur",
|
"role": "Stage développeur",
|
||||||
"period": "2018",
|
"period": "2018",
|
||||||
"duration": "1 semaine",
|
"duration": "1 semaine",
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "Johan Leroy",
|
"name": "Johan Leroy",
|
||||||
"title": "Développeur Fullstack",
|
"title": "Développeur",
|
||||||
"tagline": "Dev le jour, DJ la nuit",
|
"tagline": "Dev le jour, DJ la nuit",
|
||||||
"bio": "Développeur fullstack passionné par les technos modernes. J'aime créer des expériences web performantes, propres et scalables — et accessoirement mixer de la tekno quand l'écran s'éteint.",
|
"bio": "Développeur fullstack passionné par les technos modernes. J'aime créer des expériences web performantes, propres et scalables — et accessoirement mixer de la tekno quand l'écran s'éteint.",
|
||||||
"available": true,
|
"available": true,
|
||||||
"availableLabel": "Ouvert aux opportunités",
|
"availableLabel": "Ouvert aux opportunités",
|
||||||
"hero": {
|
"hero": {
|
||||||
"photo": "/img/pp/pp3.png",
|
"photo": "/api/files/img/pp/pp3.png",
|
||||||
"photos": ["/img/pp/pp1.png", "/img/pp/pp2.png", "/img/pp/pp3.png"],
|
"photos": [
|
||||||
"roles": ["Dev", "DJ", "Dev"],
|
"/api/files/img/pp/pp1.png",
|
||||||
|
"/api/files/img/pp/pp2.png",
|
||||||
|
"/api/files/img/pp/pp3.png"
|
||||||
|
],
|
||||||
|
"roles": [
|
||||||
|
"Dev",
|
||||||
|
"DJ",
|
||||||
|
"Dev"
|
||||||
|
],
|
||||||
"cta": [
|
"cta": [
|
||||||
{
|
{
|
||||||
"label": "Télécharger mon CV",
|
"label": "Télécharger mon CV",
|
||||||
"href": "/assets/cv.pdf",
|
"href": "/api/files/assets/cv.pdf",
|
||||||
"variant": "primary",
|
"variant": "primary",
|
||||||
"external": true
|
"external": true
|
||||||
},
|
},
|
||||||
@@ -28,7 +36,8 @@
|
|||||||
"email": "contact@johanleroy.fr",
|
"email": "contact@johanleroy.fr",
|
||||||
"github": "https://github.com/JohanLeroy",
|
"github": "https://github.com/JohanLeroy",
|
||||||
"gitea": "https://git.lidge.fr/johanleroy",
|
"gitea": "https://git.lidge.fr/johanleroy",
|
||||||
|
"linkedin": "https://www.linkedin.com/in/johan-leroy-472409180/",
|
||||||
"website": "https://johanleroy.fr"
|
"website": "https://johanleroy.fr"
|
||||||
},
|
},
|
||||||
"cv": "/assets/cv.pdf"
|
"cv": "/api/files/assets/cv.pdf"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"title": "Techos",
|
"title": "Techos",
|
||||||
"subtitle": "Plateforme hébergement & domaines",
|
"subtitle": "Plateforme hébergement & domaines",
|
||||||
"description": "Plateforme client + admin de gestion d'hébergement web et de domaines pour une association, construite par-dessus WHMCS. Design system Liquid Glass dark + light mode.",
|
"description": "Plateforme client + admin de gestion d'hébergement web et de domaines pour une association, construite par-dessus WHMCS. Design system Liquid Glass dark + light mode.",
|
||||||
"image": "/img/projet/techos.png",
|
"image": "/api/files/img/projet/techos.png",
|
||||||
"category": "web",
|
"category": "web",
|
||||||
"status": "live",
|
"status": "live",
|
||||||
"url": "https://techos-asso.fr",
|
"url": "https://techos-asso.fr",
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
"title": "Liberty Pour La Vie",
|
"title": "Liberty Pour La Vie",
|
||||||
"subtitle": "Site associatif (archivé)",
|
"subtitle": "Site associatif (archivé)",
|
||||||
"description": "Développement d'un site web complet pour l'association Liberty Pour La Vie. Projet fermé, plus en ligne.",
|
"description": "Développement d'un site web complet pour l'association Liberty Pour La Vie. Projet fermé, plus en ligne.",
|
||||||
"image": "/img/projet/lplv.png",
|
"image": "/api/files/img/projet/lplv.png",
|
||||||
"category": "web",
|
"category": "web",
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"stack": ["Java Spring Boot", "Thymeleaf", "Bootstrap", "MariaDB"],
|
"stack": ["Java Spring Boot", "Thymeleaf", "Bootstrap", "MariaDB"],
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
"title": "Mon Voisin Geek",
|
"title": "Mon Voisin Geek",
|
||||||
"subtitle": "Site vitrine (archivé)",
|
"subtitle": "Site vitrine (archivé)",
|
||||||
"description": "Développement d'un site web vitrine simple pour Mon Voisin Geek. Projet fermé, plus en ligne.",
|
"description": "Développement d'un site web vitrine simple pour Mon Voisin Geek. Projet fermé, plus en ligne.",
|
||||||
"image": "/img/projet/monvoisingeek.png",
|
"image": "/api/files/img/projet/monvoisingeek.png",
|
||||||
"category": "web",
|
"category": "web",
|
||||||
"status": "archived",
|
"status": "archived",
|
||||||
"stack": ["Angular", "Tailwind CSS", "Express.js", "MariaDB"],
|
"stack": ["Angular", "Tailwind CSS", "Express.js", "MariaDB"],
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"freelance",
|
"freelance",
|
||||||
"nantes"
|
"nantes"
|
||||||
],
|
],
|
||||||
"ogImage": "/img/logo/android-chrome-512x512.png",
|
"ogImage": "/api/files/img/logo/android-chrome-512x512.png",
|
||||||
"features": {
|
"features": {
|
||||||
"hero3D": false,
|
"hero3D": false,
|
||||||
"smoothScroll": true,
|
"smoothScroll": true,
|
||||||
|
|||||||
@@ -116,6 +116,20 @@ const year = new Date().getFullYear();
|
|||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
profile.socials.linkedin && (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={profile.socials.linkedin}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-acid)] transition"
|
||||||
|
>
|
||||||
|
> linkedin ↗
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
{
|
{
|
||||||
profile.socials.website && (
|
profile.socials.website && (
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const current = Astro.url.pathname;
|
|||||||
|
|
||||||
<header
|
<header
|
||||||
class="fixed top-0 inset-x-0 z-50 backdrop-blur-xl bg-[rgba(10,10,15,0.75)] border-b border-[color:var(--color-border)]"
|
class="fixed top-0 inset-x-0 z-50 backdrop-blur-xl bg-[rgba(10,10,15,0.75)] border-b border-[color:var(--color-border)]"
|
||||||
transition:persist
|
transition:animate="none"
|
||||||
>
|
>
|
||||||
<!-- Scroll progress bar -->
|
<!-- Scroll progress bar -->
|
||||||
<div
|
<div
|
||||||
@@ -198,7 +198,7 @@ const current = Astro.url.pathname;
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<script>
|
<script data-astro-rerun>
|
||||||
// Mobile menu
|
// Mobile menu
|
||||||
const btn = document.getElementById('mobile-menu-btn');
|
const btn = document.getElementById('mobile-menu-btn');
|
||||||
const menu = document.getElementById('mobile-menu');
|
const menu = document.getElementById('mobile-menu');
|
||||||
@@ -221,16 +221,16 @@ const current = Astro.url.pathname;
|
|||||||
updateProgress();
|
updateProgress();
|
||||||
|
|
||||||
// Sound toggle (state in localStorage, off by default)
|
// Sound toggle (state in localStorage, off by default)
|
||||||
const soundBtn = document.getElementById('sound-toggle') as HTMLButtonElement | null;
|
const soundBtn = document.getElementById('sound-toggle');
|
||||||
const onIcon = soundBtn?.querySelector('.sound-on') as SVGElement | null;
|
const onIcon = soundBtn?.querySelector('.sound-on');
|
||||||
const offIcon = soundBtn?.querySelector('.sound-off') as SVGElement | null;
|
const offIcon = soundBtn?.querySelector('.sound-off');
|
||||||
|
|
||||||
function applySoundState(on: boolean) {
|
function applySoundState(on) {
|
||||||
if (!soundBtn || !onIcon || !offIcon) return;
|
if (!soundBtn || !onIcon || !offIcon) return;
|
||||||
soundBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
soundBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||||
onIcon.classList.toggle('hidden', !on);
|
onIcon.classList.toggle('hidden', !on);
|
||||||
offIcon.classList.toggle('hidden', on);
|
offIcon.classList.toggle('hidden', on);
|
||||||
(window as unknown as { __soundOn?: boolean }).__soundOn = on;
|
window.__soundOn = on;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stored = localStorage.getItem('portfolio:sound');
|
const stored = localStorage.getItem('portfolio:sound');
|
||||||
|
|||||||
46
src/components/admin/EditPanel.tsx
Normal file
46
src/components/admin/EditPanel.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { ContentKey } from '../../content/schemas';
|
||||||
|
import { EditTabs } from './EditTabs';
|
||||||
|
import { RawJsonEditor } from './RawJsonEditor';
|
||||||
|
import { ProfileForm } from './forms/ProfileForm';
|
||||||
|
import { SkillsForm } from './forms/SkillsForm';
|
||||||
|
import { ExperiencesForm } from './forms/ExperiencesForm';
|
||||||
|
import { FormationsForm } from './forms/FormationsForm';
|
||||||
|
import { ProjectsForm } from './forms/ProjectsForm';
|
||||||
|
import { InterestsForm } from './forms/InterestsForm';
|
||||||
|
import { SiteForm } from './forms/SiteForm';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
section: ContentKey;
|
||||||
|
data: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EditPanel({ section, data }: Props) {
|
||||||
|
return (
|
||||||
|
<EditTabs
|
||||||
|
section={section}
|
||||||
|
formNode={renderForm(section, data)}
|
||||||
|
rawNode={<RawJsonEditor section={section} initial={data} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderForm(section: ContentKey, data: unknown) {
|
||||||
|
switch (section) {
|
||||||
|
case 'profile':
|
||||||
|
return <ProfileForm defaultValues={data as never} />;
|
||||||
|
case 'skills':
|
||||||
|
return <SkillsForm defaultValues={data as never} />;
|
||||||
|
case 'experiences':
|
||||||
|
return <ExperiencesForm defaultValues={data as never} />;
|
||||||
|
case 'formations':
|
||||||
|
return <FormationsForm defaultValues={data as never} />;
|
||||||
|
case 'projects':
|
||||||
|
return <ProjectsForm defaultValues={data as never} />;
|
||||||
|
case 'interests':
|
||||||
|
return <InterestsForm defaultValues={data as never} />;
|
||||||
|
case 'site':
|
||||||
|
return <SiteForm defaultValues={data as never} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/components/admin/EditTabs.tsx
Normal file
83
src/components/admin/EditTabs.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
type Tab = 'form' | 'raw';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
section: string;
|
||||||
|
formNode: ReactNode;
|
||||||
|
rawNode: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EditTabs({ section, formNode, rawNode }: Props) {
|
||||||
|
const storageKey = `admin:tab:${section}`;
|
||||||
|
const [tab, setTab] = useState<Tab>('form');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = sessionStorage.getItem(storageKey) as Tab | null;
|
||||||
|
if (saved === 'form' || saved === 'raw') setTab(saved);
|
||||||
|
}, [storageKey]);
|
||||||
|
|
||||||
|
const switchTo = (t: Tab) => {
|
||||||
|
setTab(t);
|
||||||
|
sessionStorage.setItem(storageKey, t);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<nav
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Mode d'édition"
|
||||||
|
className="flex gap-1 border-b border-[color:var(--color-border)] mb-6"
|
||||||
|
>
|
||||||
|
<TabButton
|
||||||
|
active={tab === 'form'}
|
||||||
|
onClick={() => switchTo('form')}
|
||||||
|
label="Formulaire"
|
||||||
|
tag="// guidé"
|
||||||
|
/>
|
||||||
|
<TabButton
|
||||||
|
active={tab === 'raw'}
|
||||||
|
onClick={() => switchTo('raw')}
|
||||||
|
label="JSON brut"
|
||||||
|
tag="// avancé"
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div role="tabpanel" hidden={tab !== 'form'}>
|
||||||
|
{tab === 'form' && formNode}
|
||||||
|
</div>
|
||||||
|
<div role="tabpanel" hidden={tab !== 'raw'}>
|
||||||
|
{tab === 'raw' && rawNode}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabButton({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
tag,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
label: string;
|
||||||
|
tag: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`px-4 py-2 font-display uppercase tracking-widest text-sm transition-colors border-b-2 ${
|
||||||
|
active
|
||||||
|
? 'text-[color:var(--color-acid)] border-[color:var(--color-acid)] glow-text-acid'
|
||||||
|
: 'text-[color:var(--color-text-muted)] border-transparent hover:text-[color:var(--color-cyan)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="block leading-none">{label}</span>
|
||||||
|
<span className="block font-mono text-[9px] opacity-70 mt-0.5">{tag}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
src/components/admin/FormShell.tsx
Normal file
146
src/components/admin/FormShell.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
FormProvider,
|
||||||
|
useForm,
|
||||||
|
type DefaultValues,
|
||||||
|
type FieldValues,
|
||||||
|
type Resolver,
|
||||||
|
} from 'react-hook-form';
|
||||||
|
|
||||||
|
type Props<T extends FieldValues> = {
|
||||||
|
section: string;
|
||||||
|
defaultValues: DefaultValues<T>;
|
||||||
|
resolver: Resolver<T>;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Status =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'saving' }
|
||||||
|
| { kind: 'saved' }
|
||||||
|
| { kind: 'error'; message: string };
|
||||||
|
|
||||||
|
export function FormShell<T extends FieldValues>({
|
||||||
|
section,
|
||||||
|
defaultValues,
|
||||||
|
resolver,
|
||||||
|
children,
|
||||||
|
}: Props<T>) {
|
||||||
|
const methods = useForm<T>({ defaultValues, resolver, mode: 'onBlur' });
|
||||||
|
const [status, setStatus] = useState<Status>({ kind: 'idle' });
|
||||||
|
|
||||||
|
const onSubmit = methods.handleSubmit(async (values) => {
|
||||||
|
setStatus({ kind: 'saving' });
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/content/${section}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setStatus({ kind: 'error', message: data.message || data.error || 'erreur' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus({ kind: 'saved' });
|
||||||
|
methods.reset(values as DefaultValues<T>);
|
||||||
|
setTimeout(() => setStatus({ kind: 'idle' }), 2500);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus({ kind: 'error', message: (e as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onSubmit]);
|
||||||
|
|
||||||
|
const dirty = methods.formState.isDirty;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...methods}>
|
||||||
|
<form onSubmit={onSubmit} className="space-y-6">
|
||||||
|
<div className="sticky top-16 z-30 -mx-4 px-4 py-3 bg-[rgba(10,10,15,0.85)] backdrop-blur-xl border-b border-[color:var(--color-border)] flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<Pill status={status} dirty={dirty} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{dirty && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => methods.reset()}
|
||||||
|
className="font-mono text-xs uppercase tracking-widest text-[color:var(--color-text-muted)] hover:text-[color:var(--color-magenta)] px-3 py-1.5 transition-colors"
|
||||||
|
>
|
||||||
|
annuler
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={status.kind === 'saving'}
|
||||||
|
className="btn-primary text-xs py-1.5 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{status.kind === 'saving' ? 'enregistrement…' : 'enregistrer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8">{children}</div>
|
||||||
|
|
||||||
|
<div className="pt-6 border-t border-[color:var(--color-border)] flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={status.kind === 'saving'}
|
||||||
|
className="btn-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{status.kind === 'saving' ? 'enregistrement…' : 'enregistrer'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pill({ status, dirty }: { status: Status; dirty: boolean }) {
|
||||||
|
if (status.kind === 'saving')
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-2 font-mono text-xs uppercase tracking-widest text-[color:var(--color-acid)]">
|
||||||
|
<span className="led led-acid animate-led-red" />
|
||||||
|
enregistrement…
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
if (status.kind === 'saved')
|
||||||
|
return (
|
||||||
|
<span className="sticker text-[color:var(--color-cyan)] glow-text-cyan animate-pulse-neon">
|
||||||
|
✓ saved
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
if (status.kind === 'error')
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-2 font-mono text-xs uppercase tracking-widest text-[color:var(--color-magenta)]"
|
||||||
|
title={status.message}
|
||||||
|
>
|
||||||
|
<span className="led led-red animate-led-red" />
|
||||||
|
✗ {status.message.slice(0, 80)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
if (dirty)
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-2 font-mono text-xs uppercase tracking-widest text-[color:var(--color-text-muted)]">
|
||||||
|
<span className="led" style={{ background: 'var(--color-text-muted)', boxShadow: 'none' }} />
|
||||||
|
modifications non sauvées
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-2 font-mono text-xs uppercase tracking-widest text-[color:var(--color-text-dim)]">
|
||||||
|
<span className="led led-cyan" />
|
||||||
|
synced
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
src/components/admin/RawJsonEditor.tsx
Normal file
122
src/components/admin/RawJsonEditor.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
section: string;
|
||||||
|
initial: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Status =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'saving' }
|
||||||
|
| { kind: 'saved' }
|
||||||
|
| { kind: 'error'; message: string }
|
||||||
|
| { kind: 'invalid' };
|
||||||
|
|
||||||
|
export function RawJsonEditor({ section, initial }: Props) {
|
||||||
|
const [value, setValue] = useState(() => JSON.stringify(initial, null, 2));
|
||||||
|
const [status, setStatus] = useState<Status>({ kind: 'idle' });
|
||||||
|
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
const format = () => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
setValue(JSON.stringify(parsed, null, 2));
|
||||||
|
setStatus({ kind: 'idle' });
|
||||||
|
} catch {
|
||||||
|
setStatus({ kind: 'invalid' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
setStatus({ kind: 'invalid' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus({ kind: 'saving' });
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/content/${section}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setStatus({ kind: 'error', message: data.message || data.error || 'erreur' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus({ kind: 'saved' });
|
||||||
|
setTimeout(() => setStatus({ kind: 'idle' }), 2500);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus({ kind: 'error', message: (e as Error).message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const ta = taRef.current;
|
||||||
|
ta?.addEventListener('keydown', onKey as EventListener);
|
||||||
|
return () => ta?.removeEventListener('keydown', onKey as EventListener);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
|
<p className="font-mono text-xs text-[color:var(--color-text-dim)]">
|
||||||
|
Mode avancé — JSON brut, validé Zod côté serveur.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<StatusPill status={status} />
|
||||||
|
<button type="button" onClick={format} className="btn-ghost text-xs py-1.5">
|
||||||
|
Formater
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={save} className="btn-primary text-xs py-1.5">
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
ref={taRef}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
className="w-full min-h-[400px] h-[calc(100vh-300px)] max-h-[80vh] bg-[color:var(--color-surface)] border border-[color:var(--color-border)] focus:border-[color:var(--color-cyan)] focus:outline-none p-4 rounded text-sm font-mono text-[color:var(--color-text)] resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: Status }) {
|
||||||
|
if (status.kind === 'idle') return null;
|
||||||
|
if (status.kind === 'saving')
|
||||||
|
return (
|
||||||
|
<span className="font-mono text-xs text-[color:var(--color-text-muted)]">
|
||||||
|
enregistrement…
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
if (status.kind === 'saved')
|
||||||
|
return (
|
||||||
|
<span className="font-mono text-xs text-[color:var(--color-cyan)] glow-text-cyan">
|
||||||
|
✓ enregistré
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
if (status.kind === 'invalid')
|
||||||
|
return (
|
||||||
|
<span className="font-mono text-xs text-[color:var(--color-magenta)]">
|
||||||
|
JSON invalide
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<span className="font-mono text-xs text-[color:var(--color-magenta)]" title={status.message}>
|
||||||
|
✗ {status.message.slice(0, 60)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/components/admin/Section.tsx
Normal file
23
src/components/admin/Section.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Section({ title, subtitle, children }: Props) {
|
||||||
|
return (
|
||||||
|
<section className="card-neon space-y-4">
|
||||||
|
<header className="flex items-baseline gap-3 pb-2 border-b border-[color:var(--color-border)]">
|
||||||
|
<h2 className="font-display uppercase tracking-widest text-lg text-[color:var(--color-cyan)] glow-text-cyan">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{subtitle && (
|
||||||
|
<span className="font-mono text-xs text-[color:var(--color-text-dim)]">{subtitle}</span>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<div className="space-y-4">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
src/components/admin/fields/ArrayEditor.tsx
Normal file
99
src/components/admin/fields/ArrayEditor.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
|
type Props<T> = {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
newItem: () => T;
|
||||||
|
itemLabel?: (index: number) => string;
|
||||||
|
children: (params: { index: number; namePrefix: string }) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ArrayEditor<T>({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
newItem,
|
||||||
|
itemLabel,
|
||||||
|
children,
|
||||||
|
}: Props<T>) {
|
||||||
|
const { control } = useFormContext();
|
||||||
|
const { fields, append, remove, move } = useFieldArray({ control, name });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-display uppercase tracking-widest text-base text-[color:var(--color-acid)] glow-text-acid">
|
||||||
|
{label}
|
||||||
|
</h3>
|
||||||
|
{hint && (
|
||||||
|
<p className="font-mono text-[10px] text-[color:var(--color-text-dim)] mt-0.5">
|
||||||
|
{hint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => append(newItem() as never)}
|
||||||
|
className="px-3 py-1.5 font-mono text-xs uppercase tracking-widest text-[color:var(--color-bg)] bg-[color:var(--color-acid)] rounded hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
+ ajouter
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{fields.length === 0 && (
|
||||||
|
<div className="font-mono text-xs text-[color:var(--color-text-dim)] italic border border-dashed border-[color:var(--color-border)] rounded p-4 text-center">
|
||||||
|
(aucun élément — clique « ajouter » pour commencer)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ol className="space-y-3">
|
||||||
|
{fields.map((field, idx) => (
|
||||||
|
<li
|
||||||
|
key={field.id}
|
||||||
|
className="border border-[color:var(--color-border)] rounded bg-[color:var(--color-surface)] p-4 relative"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3 pb-2 border-b border-[color:var(--color-border)]">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-widest text-[color:var(--color-cyan)]">
|
||||||
|
{itemLabel ? itemLabel(idx) : `# ${String(idx + 1).padStart(2, '0')}`}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => idx > 0 && move(idx, idx - 1)}
|
||||||
|
disabled={idx === 0}
|
||||||
|
className="px-2 py-1 font-mono text-xs text-[color:var(--color-text-muted)] hover:text-[color:var(--color-cyan)] disabled:opacity-30"
|
||||||
|
aria-label="monter"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => idx < fields.length - 1 && move(idx, idx + 1)}
|
||||||
|
disabled={idx === fields.length - 1}
|
||||||
|
className="px-2 py-1 font-mono text-xs text-[color:var(--color-text-muted)] hover:text-[color:var(--color-cyan)] disabled:opacity-30"
|
||||||
|
aria-label="descendre"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(idx)}
|
||||||
|
className="px-2 py-1 font-mono text-xs text-[color:var(--color-magenta)] hover:text-[color:var(--color-bg)] hover:bg-[color:var(--color-magenta)] rounded transition-colors"
|
||||||
|
aria-label="supprimer"
|
||||||
|
>
|
||||||
|
supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{children({ index: idx, namePrefix: `${name}.${idx}` })}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
src/components/admin/fields/ChipsField.tsx
Normal file
95
src/components/admin/fields/ChipsField.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { useState, type KeyboardEvent } from 'react';
|
||||||
|
import { Controller, useFormContext } from 'react-hook-form';
|
||||||
|
import { FieldError } from './FieldError';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChipsField({ name, label, hint, placeholder = 'Ajouter puis Entrée…' }: Props) {
|
||||||
|
const { control } = useFormContext();
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name={name}
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => {
|
||||||
|
const items: string[] = Array.isArray(field.value) ? field.value : [];
|
||||||
|
|
||||||
|
const add = () => {
|
||||||
|
const value = draft.trim();
|
||||||
|
if (!value) return;
|
||||||
|
if (items.includes(value)) {
|
||||||
|
setDraft('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
field.onChange([...items, value]);
|
||||||
|
setDraft('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (idx: number) => {
|
||||||
|
field.onChange(items.filter((_, i) => i !== idx));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ',') {
|
||||||
|
e.preventDefault();
|
||||||
|
add();
|
||||||
|
} else if (e.key === 'Backspace' && draft === '' && items.length > 0) {
|
||||||
|
field.onChange(items.slice(0, -1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="block font-mono text-xs uppercase tracking-widest text-[color:var(--color-cyan)] mb-1.5">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{hint && (
|
||||||
|
<span className="block font-mono text-[10px] text-[color:var(--color-text-dim)] mb-1.5">
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<span
|
||||||
|
key={`${item}-${idx}`}
|
||||||
|
className="chip group cursor-pointer"
|
||||||
|
onClick={() => remove(idx)}
|
||||||
|
title="Cliquer pour retirer"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
<span className="ml-1 opacity-50 group-hover:opacity-100 group-hover:text-[color:var(--color-magenta)]">
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={onKey}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="flex-1 bg-[color:var(--color-surface-2)] border border-[color:var(--color-border)] focus:border-[color:var(--color-cyan)] focus:outline-none p-2 rounded text-sm font-mono text-[color:var(--color-text)] transition-colors"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={add}
|
||||||
|
className="px-3 py-1.5 font-mono text-xs uppercase tracking-widest border border-[color:var(--color-cyan)] text-[color:var(--color-cyan)] rounded hover:bg-[color:var(--color-cyan)] hover:text-[color:var(--color-bg)] transition-colors"
|
||||||
|
>
|
||||||
|
+ add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<FieldError name={name} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/admin/fields/EmojiField.tsx
Normal file
55
src/components/admin/fields/EmojiField.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
import { FieldError } from './FieldError';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUGGESTIONS = ['⚡', '🚀', '💼', '🎓', '🎧', '🛠️', '🎵', '💻', '📱', '🤖', '☁️', '🔥', '✨', '🎯', '🎨', '🌐'];
|
||||||
|
|
||||||
|
export function EmojiField({ name, label, hint }: Props) {
|
||||||
|
const { register, setValue, watch } = useFormContext();
|
||||||
|
const value = watch(name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block">
|
||||||
|
<span className="block font-mono text-xs uppercase tracking-widest text-[color:var(--color-cyan)] mb-1.5">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{hint && (
|
||||||
|
<span className="block font-mono text-[10px] text-[color:var(--color-text-dim)] mb-1.5">
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl w-12 h-12 flex items-center justify-center bg-[color:var(--color-surface-2)] border border-[color:var(--color-border)] rounded">
|
||||||
|
{value || '·'}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
maxLength={4}
|
||||||
|
{...register(name)}
|
||||||
|
className="flex-1 bg-[color:var(--color-surface-2)] border border-[color:var(--color-border)] focus:border-[color:var(--color-cyan)] focus:outline-none p-2.5 rounded text-sm font-mono text-[color:var(--color-text)] transition-colors"
|
||||||
|
placeholder="emoji ou caractère"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{SUGGESTIONS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setValue(name, s, { shouldDirty: true, shouldValidate: true })}
|
||||||
|
className="w-8 h-8 flex items-center justify-center text-base hover:bg-[color:var(--color-surface-2)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FieldError name={name} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/components/admin/fields/FieldError.tsx
Normal file
17
src/components/admin/fields/FieldError.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useFormContext, get } from 'react-hook-form';
|
||||||
|
|
||||||
|
type Props = { name: string };
|
||||||
|
|
||||||
|
export function FieldError({ name }: Props) {
|
||||||
|
const {
|
||||||
|
formState: { errors },
|
||||||
|
} = useFormContext();
|
||||||
|
const error = get(errors, name);
|
||||||
|
if (!error?.message) return null;
|
||||||
|
return (
|
||||||
|
<p className="mt-1 flex items-center gap-2 font-mono text-xs text-[color:var(--color-magenta)]">
|
||||||
|
<span className="led led-red animate-led-red" />
|
||||||
|
<span>{String(error.message)}</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
223
src/components/admin/fields/FileUploadField.tsx
Normal file
223
src/components/admin/fields/FileUploadField.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { useRef, useState, type DragEvent } from 'react';
|
||||||
|
import { Controller, useFormContext } from 'react-hook-form';
|
||||||
|
import { FieldError } from './FieldError';
|
||||||
|
|
||||||
|
type Kind = 'image' | 'document';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
folder?: string;
|
||||||
|
hint?: string;
|
||||||
|
accept?: string;
|
||||||
|
maxSizeMb?: number;
|
||||||
|
kind?: Kind;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACCEPT: Record<Kind, string> = {
|
||||||
|
image: 'image/png,image/jpeg,image/webp,image/svg+xml,image/gif',
|
||||||
|
document: 'application/pdf,.pdf',
|
||||||
|
};
|
||||||
|
|
||||||
|
const HELP_TEXT: Record<Kind, string> = {
|
||||||
|
image: 'png, jpg, webp, svg, gif',
|
||||||
|
document: 'pdf uniquement',
|
||||||
|
};
|
||||||
|
|
||||||
|
function fileNameFromUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const clean = url.split('?')[0];
|
||||||
|
const segments = clean.split('/');
|
||||||
|
return segments[segments.length - 1] || url;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileUploadField({
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
folder = '',
|
||||||
|
hint,
|
||||||
|
accept,
|
||||||
|
maxSizeMb,
|
||||||
|
kind = 'image',
|
||||||
|
}: Props) {
|
||||||
|
const { control } = useFormContext();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [hover, setHover] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const effectiveAccept = accept ?? ACCEPT[kind];
|
||||||
|
const effectiveMax = maxSizeMb ?? (kind === 'document' ? 8 : 5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name={name}
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => {
|
||||||
|
const url: string = field.value || '';
|
||||||
|
|
||||||
|
const upload = async (file: File) => {
|
||||||
|
setLocalError(null);
|
||||||
|
if (file.size > effectiveMax * 1024 * 1024) {
|
||||||
|
setLocalError(`Fichier trop lourd (max ${effectiveMax} Mo).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
fd.append('kind', kind);
|
||||||
|
if (folder) fd.append('folder', folder);
|
||||||
|
const res = await fetch('/api/upload', { method: 'POST', body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || !data.url) {
|
||||||
|
setLocalError(data?.error || 'upload échoué');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
field.onChange(data.url);
|
||||||
|
} catch (e) {
|
||||||
|
setLocalError((e as Error).message || 'upload échoué');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setHover(false);
|
||||||
|
const file = e.dataTransfer.files?.[0];
|
||||||
|
if (file) upload(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="block font-mono text-xs uppercase tracking-widest text-[color:var(--color-cyan)] mb-1.5">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{hint && (
|
||||||
|
<span className="block font-mono text-[10px] text-[color:var(--color-text-dim)] mb-1.5">
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start gap-3">
|
||||||
|
{url && kind === 'image' ? (
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
className="w-28 h-28 object-cover border border-[color:var(--color-cyan)] rounded"
|
||||||
|
/>
|
||||||
|
<span className="sticker absolute -top-2 -left-2 text-[color:var(--color-cyan)]">
|
||||||
|
ok
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{url && kind === 'document' ? (
|
||||||
|
<div className="relative shrink-0 w-full sm:w-44 p-3 border border-[color:var(--color-cyan)] rounded bg-[color:var(--color-surface-2)] flex flex-col gap-1">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-widest text-[color:var(--color-cyan)]">
|
||||||
|
pdf actuel
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[11px] text-[color:var(--color-text)] truncate">
|
||||||
|
{fileNameFromUrl(url)}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
className="font-mono text-[10px] uppercase tracking-widest text-[color:var(--color-acid)] hover:underline"
|
||||||
|
>
|
||||||
|
voir le fichier ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div
|
||||||
|
onDragEnter={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setHover(true);
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setHover(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setHover(false)}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
inputRef.current?.click();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`flex-1 cursor-pointer p-4 rounded text-center transition-all ${
|
||||||
|
hover
|
||||||
|
? 'neon-border-cyan glow-cyan'
|
||||||
|
: 'border border-dashed border-[color:var(--color-border)] hover:border-[color:var(--color-cyan)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 font-mono text-xs uppercase tracking-widest text-[color:var(--color-acid)]">
|
||||||
|
<span className="led led-acid animate-led-red" />
|
||||||
|
upload en cours…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-1 font-mono text-xs uppercase tracking-widest text-[color:var(--color-text-muted)]">
|
||||||
|
<span className="text-[color:var(--color-cyan)]">
|
||||||
|
{url ? '↻ remplacer' : '↑ glisser-déposer'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-[color:var(--color-text-dim)] normal-case tracking-normal">
|
||||||
|
ou cliquer · {HELP_TEXT[kind]} (max {effectiveMax} Mo)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{url && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => field.onChange('')}
|
||||||
|
className="self-start sm:self-auto shrink-0 px-2 py-1 font-mono text-xs uppercase tracking-widest text-[color:var(--color-magenta)] border border-[color:var(--color-magenta)] rounded hover:bg-[color:var(--color-magenta)] hover:text-[color:var(--color-bg)] transition-colors"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={effectiveAccept}
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) upload(file);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{url && (
|
||||||
|
<p className="mt-1 font-mono text-[10px] text-[color:var(--color-text-dim)] truncate">
|
||||||
|
{url}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{localError && (
|
||||||
|
<p className="mt-1 flex items-center gap-2 font-mono text-xs text-[color:var(--color-magenta)]">
|
||||||
|
<span className="led led-red animate-led-red" />
|
||||||
|
{localError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FieldError name={name} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/components/admin/fields/ImageUploadField.tsx
Normal file
14
src/components/admin/fields/ImageUploadField.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { FileUploadField } from './FileUploadField';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
folder?: string;
|
||||||
|
hint?: string;
|
||||||
|
accept?: string;
|
||||||
|
maxSizeMb?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ImageUploadField(props: Props) {
|
||||||
|
return <FileUploadField {...props} kind="image" />;
|
||||||
|
}
|
||||||
38
src/components/admin/fields/SelectField.tsx
Normal file
38
src/components/admin/fields/SelectField.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
import { FieldError } from './FieldError';
|
||||||
|
|
||||||
|
type Option = { value: string; label: string };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
options: Option[];
|
||||||
|
hint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SelectField({ name, label, options, hint }: Props) {
|
||||||
|
const { register } = useFormContext();
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="block font-mono text-xs uppercase tracking-widest text-[color:var(--color-cyan)] mb-1.5">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{hint && (
|
||||||
|
<span className="block font-mono text-[10px] text-[color:var(--color-text-dim)] mb-1.5">
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
{...register(name)}
|
||||||
|
className="w-full bg-[color:var(--color-surface-2)] border border-[color:var(--color-border)] focus:border-[color:var(--color-cyan)] focus:outline-none p-2.5 rounded text-sm text-[color:var(--color-text)] transition-colors"
|
||||||
|
>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<FieldError name={name} />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/components/admin/fields/TextAreaField.tsx
Normal file
34
src/components/admin/fields/TextAreaField.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
import { FieldError } from './FieldError';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
rows?: number;
|
||||||
|
mono?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TextAreaField({ name, label, hint, placeholder, rows = 4, mono = false }: Props) {
|
||||||
|
const { register } = useFormContext();
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="block font-mono text-xs uppercase tracking-widest text-[color:var(--color-cyan)] mb-1.5">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{hint && (
|
||||||
|
<span className="block font-mono text-[10px] text-[color:var(--color-text-dim)] mb-1.5">
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
rows={rows}
|
||||||
|
placeholder={placeholder}
|
||||||
|
{...register(name)}
|
||||||
|
className={`w-full bg-[color:var(--color-surface-2)] border border-[color:var(--color-border)] focus:border-[color:var(--color-cyan)] focus:outline-none p-2.5 rounded text-sm text-[color:var(--color-text)] transition-colors resize-y ${mono ? 'font-mono' : ''}`}
|
||||||
|
/>
|
||||||
|
<FieldError name={name} />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/admin/fields/TextField.tsx
Normal file
33
src/components/admin/fields/TextField.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
import { FieldError } from './FieldError';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
type?: 'text' | 'email' | 'url';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TextField({ name, label, hint, placeholder, type = 'text' }: Props) {
|
||||||
|
const { register } = useFormContext();
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="block font-mono text-xs uppercase tracking-widest text-[color:var(--color-cyan)] mb-1.5">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{hint && (
|
||||||
|
<span className="block font-mono text-[10px] text-[color:var(--color-text-dim)] mb-1.5">
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
placeholder={placeholder}
|
||||||
|
{...register(name)}
|
||||||
|
className="w-full bg-[color:var(--color-surface-2)] border border-[color:var(--color-border)] focus:border-[color:var(--color-cyan)] focus:outline-none p-2.5 rounded text-sm text-[color:var(--color-text)] transition-colors"
|
||||||
|
/>
|
||||||
|
<FieldError name={name} />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/components/admin/fields/ToggleField.tsx
Normal file
53
src/components/admin/fields/ToggleField.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Controller, useFormContext } from 'react-hook-form';
|
||||||
|
import { FieldError } from './FieldError';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ToggleField({ name, label, hint }: Props) {
|
||||||
|
const { control } = useFormContext();
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
name={name}
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => {
|
||||||
|
const on = !!field.value;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={on}
|
||||||
|
onClick={() => field.onChange(!on)}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2 rounded border bg-[color:var(--color-surface-2)] transition-colors ${
|
||||||
|
on
|
||||||
|
? 'border-[color:var(--color-acid)] text-[color:var(--color-acid)]'
|
||||||
|
: 'border-[color:var(--color-border)] text-[color:var(--color-text-muted)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`led ${on ? 'led-acid animate-led-green' : ''}`}
|
||||||
|
style={!on ? { background: 'var(--color-text-dim)', boxShadow: 'none' } : undefined}
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-xs uppercase tracking-widest">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto font-mono text-[10px] opacity-70">
|
||||||
|
{on ? 'ON' : 'OFF'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{hint && (
|
||||||
|
<span className="block font-mono text-[10px] text-[color:var(--color-text-dim)] mt-1">
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<FieldError name={name} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/components/admin/forms/ExperiencesForm.tsx
Normal file
78
src/components/admin/forms/ExperiencesForm.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { ExperiencesSchema, type Experiences } from '../../../content/schemas/experiences';
|
||||||
|
import { FormShell } from '../FormShell';
|
||||||
|
import { Section } from '../Section';
|
||||||
|
import { TextField } from '../fields/TextField';
|
||||||
|
import { TextAreaField } from '../fields/TextAreaField';
|
||||||
|
import { SelectField } from '../fields/SelectField';
|
||||||
|
import { ToggleField } from '../fields/ToggleField';
|
||||||
|
import { ChipsField } from '../fields/ChipsField';
|
||||||
|
import { ImageUploadField } from '../fields/ImageUploadField';
|
||||||
|
import { ArrayEditor } from '../fields/ArrayEditor';
|
||||||
|
|
||||||
|
type Props = { defaultValues: Experiences };
|
||||||
|
|
||||||
|
const TYPE_OPTIONS = [
|
||||||
|
{ value: 'cdi', label: 'CDI' },
|
||||||
|
{ value: 'cdd', label: 'CDD' },
|
||||||
|
{ value: 'stage', label: 'Stage' },
|
||||||
|
{ value: 'alternance', label: 'Alternance' },
|
||||||
|
{ value: 'freelance', label: 'Freelance' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ExperiencesForm({ defaultValues }: Props) {
|
||||||
|
return (
|
||||||
|
<FormShell<Experiences>
|
||||||
|
section="experiences"
|
||||||
|
defaultValues={defaultValues as never}
|
||||||
|
resolver={zodResolver(ExperiencesSchema) as never}
|
||||||
|
>
|
||||||
|
<Section title="Expériences" subtitle="// setlist">
|
||||||
|
<ArrayEditor
|
||||||
|
name="items"
|
||||||
|
label="Postes"
|
||||||
|
newItem={() =>
|
||||||
|
({
|
||||||
|
id: '',
|
||||||
|
company: '',
|
||||||
|
role: '',
|
||||||
|
period: '',
|
||||||
|
duration: '',
|
||||||
|
description: '',
|
||||||
|
missions: [],
|
||||||
|
stack: [],
|
||||||
|
type: 'cdi' as const,
|
||||||
|
current: false,
|
||||||
|
}) as never
|
||||||
|
}
|
||||||
|
itemLabel={(i) => `track #${String(i + 1).padStart(2, '0')}`}
|
||||||
|
>
|
||||||
|
{({ namePrefix }) => (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<TextField name={`${namePrefix}.id`} label="ID" hint="Slug unique" />
|
||||||
|
<TextField name={`${namePrefix}.company`} label="Entreprise" />
|
||||||
|
<TextField name={`${namePrefix}.role`} label="Rôle" />
|
||||||
|
</div>
|
||||||
|
<ImageUploadField name={`${namePrefix}.logo`} label="Logo" folder="company" />
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 items-end">
|
||||||
|
<TextField name={`${namePrefix}.period`} label="Période" placeholder="Janv 2020 — Now" />
|
||||||
|
<TextField name={`${namePrefix}.duration`} label="Durée" placeholder="4 ans" />
|
||||||
|
<SelectField name={`${namePrefix}.type`} label="Type" options={TYPE_OPTIONS} />
|
||||||
|
</div>
|
||||||
|
<TextAreaField name={`${namePrefix}.description`} label="Description" rows={3} />
|
||||||
|
<ChipsField name={`${namePrefix}.missions`} label="Missions" hint="Une par chip" />
|
||||||
|
<ChipsField name={`${namePrefix}.stack`} label="Stack" hint="Technos utilisées" />
|
||||||
|
<ToggleField name={`${namePrefix}.current`} label="Poste en cours (NOW PLAYING)" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ArrayEditor>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Compétences globales" subtitle="// patch bay">
|
||||||
|
<ChipsField name="skills.technical" label="Techniques" />
|
||||||
|
<ChipsField name="skills.soft" label="Transverses / soft" />
|
||||||
|
</Section>
|
||||||
|
</FormShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/admin/forms/FormationsForm.tsx
Normal file
55
src/components/admin/forms/FormationsForm.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { FormationsSchema, type Formations } from '../../../content/schemas/formations';
|
||||||
|
import { FormShell } from '../FormShell';
|
||||||
|
import { Section } from '../Section';
|
||||||
|
import { TextField } from '../fields/TextField';
|
||||||
|
import { TextAreaField } from '../fields/TextAreaField';
|
||||||
|
import { ArrayEditor } from '../fields/ArrayEditor';
|
||||||
|
|
||||||
|
type Props = { defaultValues: Formations };
|
||||||
|
|
||||||
|
export function FormationsForm({ defaultValues }: Props) {
|
||||||
|
return (
|
||||||
|
<FormShell<Formations>
|
||||||
|
section="formations"
|
||||||
|
defaultValues={defaultValues as never}
|
||||||
|
resolver={zodResolver(FormationsSchema) as never}
|
||||||
|
>
|
||||||
|
<Section title="Formations" subtitle="// edu system">
|
||||||
|
<ArrayEditor
|
||||||
|
name="items"
|
||||||
|
label="Cursus"
|
||||||
|
newItem={() =>
|
||||||
|
({
|
||||||
|
id: '',
|
||||||
|
title: '',
|
||||||
|
school: '',
|
||||||
|
period: '',
|
||||||
|
}) as never
|
||||||
|
}
|
||||||
|
itemLabel={(i) => `ticket #${String(i + 1).padStart(3, '0')}`}
|
||||||
|
>
|
||||||
|
{({ namePrefix }) => (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<TextField name={`${namePrefix}.id`} label="ID" hint="Slug unique" />
|
||||||
|
<TextField name={`${namePrefix}.period`} label="Période" placeholder="2018 — 2020" />
|
||||||
|
</div>
|
||||||
|
<TextField name={`${namePrefix}.title`} label="Titre / diplôme" />
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<TextField name={`${namePrefix}.school`} label="École" />
|
||||||
|
<TextField
|
||||||
|
name={`${namePrefix}.schoolUrl`}
|
||||||
|
label="Site école"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<TextAreaField name={`${namePrefix}.description`} label="Description" rows={3} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ArrayEditor>
|
||||||
|
</Section>
|
||||||
|
</FormShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/components/admin/forms/InterestsForm.tsx
Normal file
51
src/components/admin/forms/InterestsForm.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { InterestsSchema, type Interests } from '../../../content/schemas/interests';
|
||||||
|
import { FormShell } from '../FormShell';
|
||||||
|
import { Section } from '../Section';
|
||||||
|
import { TextField } from '../fields/TextField';
|
||||||
|
import { SelectField } from '../fields/SelectField';
|
||||||
|
import { EmojiField } from '../fields/EmojiField';
|
||||||
|
import { ArrayEditor } from '../fields/ArrayEditor';
|
||||||
|
|
||||||
|
type Props = { defaultValues: Interests };
|
||||||
|
|
||||||
|
const COLOR_OPTIONS = [
|
||||||
|
{ value: 'cyan', label: '◉ cyan' },
|
||||||
|
{ value: 'magenta', label: '◉ magenta' },
|
||||||
|
{ value: 'violet', label: '◉ violet' },
|
||||||
|
{ value: 'ice', label: '◉ ice' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function InterestsForm({ defaultValues }: Props) {
|
||||||
|
return (
|
||||||
|
<FormShell<Interests>
|
||||||
|
section="interests"
|
||||||
|
defaultValues={defaultValues as never}
|
||||||
|
resolver={zodResolver(InterestsSchema) as never}
|
||||||
|
>
|
||||||
|
<Section title="Centres d'intérêt" subtitle="// off decks">
|
||||||
|
<ArrayEditor
|
||||||
|
name="items"
|
||||||
|
label="Items"
|
||||||
|
newItem={() =>
|
||||||
|
({ id: '', label: '', icon: '🎧', color: 'cyan' as const }) as never
|
||||||
|
}
|
||||||
|
itemLabel={(i) => `pad #${String(i + 1).padStart(2, '0')}`}
|
||||||
|
>
|
||||||
|
{({ namePrefix }) => (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<TextField name={`${namePrefix}.id`} label="ID" hint="Slug unique" />
|
||||||
|
<TextField name={`${namePrefix}.label`} label="Label" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<EmojiField name={`${namePrefix}.icon`} label="Icône" />
|
||||||
|
<SelectField name={`${namePrefix}.color`} label="Couleur" options={COLOR_OPTIONS} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ArrayEditor>
|
||||||
|
</Section>
|
||||||
|
</FormShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
src/components/admin/forms/ProfileForm.tsx
Normal file
107
src/components/admin/forms/ProfileForm.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { ProfileSchema, type Profile } from '../../../content/schemas/profile';
|
||||||
|
import { FormShell } from '../FormShell';
|
||||||
|
import { Section } from '../Section';
|
||||||
|
import { TextField } from '../fields/TextField';
|
||||||
|
import { TextAreaField } from '../fields/TextAreaField';
|
||||||
|
import { SelectField } from '../fields/SelectField';
|
||||||
|
import { ToggleField } from '../fields/ToggleField';
|
||||||
|
import { ChipsField } from '../fields/ChipsField';
|
||||||
|
import { ImageUploadField } from '../fields/ImageUploadField';
|
||||||
|
import { FileUploadField } from '../fields/FileUploadField';
|
||||||
|
import { ArrayEditor } from '../fields/ArrayEditor';
|
||||||
|
|
||||||
|
type Props = { defaultValues: Profile };
|
||||||
|
|
||||||
|
export function ProfileForm({ defaultValues }: Props) {
|
||||||
|
return (
|
||||||
|
<FormShell<Profile>
|
||||||
|
section="profile"
|
||||||
|
defaultValues={defaultValues as never}
|
||||||
|
resolver={zodResolver(ProfileSchema) as never}
|
||||||
|
>
|
||||||
|
<Section title="Identité" subtitle="// owner">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<TextField name="name" label="Nom" />
|
||||||
|
<TextField name="title" label="Titre" />
|
||||||
|
</div>
|
||||||
|
<TextField name="tagline" label="Tagline" hint="Phrase courte affichée sous le nom" />
|
||||||
|
<TextAreaField name="bio" label="Bio" rows={5} />
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 items-end">
|
||||||
|
<ToggleField name="available" label="Disponible" hint="Affiche le badge ON AIR" />
|
||||||
|
<TextField name="availableLabel" label="Texte du badge" hint="ex : Disponible — Nantes" />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Hero" subtitle="// stage">
|
||||||
|
<ImageUploadField name="hero.photo" label="Photo principale" folder="pp" />
|
||||||
|
<ChipsField
|
||||||
|
name="hero.roles"
|
||||||
|
label="Rôles affichés"
|
||||||
|
hint="Mots-clés morphés dans le hero (ex : dev, DJ, dev)"
|
||||||
|
/>
|
||||||
|
<ArrayEditor
|
||||||
|
name="hero.photos"
|
||||||
|
label="Galerie hero"
|
||||||
|
hint="Photos additionnelles (slideshow / mosaïque)"
|
||||||
|
newItem={() => ''}
|
||||||
|
itemLabel={(i) => `photo #${String(i + 1).padStart(2, '0')}`}
|
||||||
|
>
|
||||||
|
{({ namePrefix }) => (
|
||||||
|
<ImageUploadField name={namePrefix} label="URL" folder="pp" />
|
||||||
|
)}
|
||||||
|
</ArrayEditor>
|
||||||
|
<ArrayEditor
|
||||||
|
name="hero.cta"
|
||||||
|
label="Boutons CTA"
|
||||||
|
hint="Actions principales sous le hero"
|
||||||
|
newItem={() => ({ label: '', href: '', variant: 'primary' as const, external: false })}
|
||||||
|
itemLabel={(i) => `cta #${String(i + 1).padStart(2, '0')}`}
|
||||||
|
>
|
||||||
|
{({ namePrefix }) => (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<TextField name={`${namePrefix}.label`} label="Label" />
|
||||||
|
<TextField name={`${namePrefix}.href`} label="Lien" placeholder="/projets ou https://…" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 items-end">
|
||||||
|
<SelectField
|
||||||
|
name={`${namePrefix}.variant`}
|
||||||
|
label="Style"
|
||||||
|
options={[
|
||||||
|
{ value: 'primary', label: 'Primary (acid)' },
|
||||||
|
{ value: 'ghost', label: 'Ghost (cyan)' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ToggleField
|
||||||
|
name={`${namePrefix}.external`}
|
||||||
|
label="Externe (target=_blank)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ArrayEditor>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Réseaux" subtitle="// patch bay">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<TextField name="socials.email" label="Email" type="email" placeholder="hello@example.com" />
|
||||||
|
<TextField name="socials.github" label="GitHub" type="url" placeholder="https://github.com/…" />
|
||||||
|
<TextField name="socials.gitea" label="Gitea" type="url" />
|
||||||
|
<TextField name="socials.linkedin" label="LinkedIn" type="url" />
|
||||||
|
<TextField name="socials.website" label="Site personnel" type="url" />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="CV" subtitle="// dossier">
|
||||||
|
<FileUploadField
|
||||||
|
name="cv"
|
||||||
|
label="Fichier PDF du CV"
|
||||||
|
folder="cv"
|
||||||
|
kind="document"
|
||||||
|
hint="Glisser-déposer un PDF (max 8 Mo)"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</FormShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/components/admin/forms/ProjectsForm.tsx
Normal file
79
src/components/admin/forms/ProjectsForm.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { ProjectsSchema, type Projects } from '../../../content/schemas/projects';
|
||||||
|
import { FormShell } from '../FormShell';
|
||||||
|
import { Section } from '../Section';
|
||||||
|
import { TextField } from '../fields/TextField';
|
||||||
|
import { TextAreaField } from '../fields/TextAreaField';
|
||||||
|
import { SelectField } from '../fields/SelectField';
|
||||||
|
import { ToggleField } from '../fields/ToggleField';
|
||||||
|
import { ChipsField } from '../fields/ChipsField';
|
||||||
|
import { ImageUploadField } from '../fields/ImageUploadField';
|
||||||
|
import { ArrayEditor } from '../fields/ArrayEditor';
|
||||||
|
|
||||||
|
type Props = { defaultValues: Projects };
|
||||||
|
|
||||||
|
const CATEGORY_OPTIONS = [
|
||||||
|
{ value: 'web', label: 'Web' },
|
||||||
|
{ value: 'mobile', label: 'Mobile' },
|
||||||
|
{ value: 'bot', label: 'Bot' },
|
||||||
|
{ value: 'infra', label: 'Infra' },
|
||||||
|
{ value: 'other', label: 'Autre' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: 'live', label: 'Live' },
|
||||||
|
{ value: 'archived', label: 'Archived' },
|
||||||
|
{ value: 'private', label: 'Private' },
|
||||||
|
{ value: 'wip', label: 'WIP' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ProjectsForm({ defaultValues }: Props) {
|
||||||
|
return (
|
||||||
|
<FormShell<Projects>
|
||||||
|
section="projects"
|
||||||
|
defaultValues={defaultValues as never}
|
||||||
|
resolver={zodResolver(ProjectsSchema) as never}
|
||||||
|
>
|
||||||
|
<Section title="Projets" subtitle="// crate">
|
||||||
|
<ArrayEditor
|
||||||
|
name="items"
|
||||||
|
label="Projets"
|
||||||
|
newItem={() =>
|
||||||
|
({
|
||||||
|
id: '',
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
category: 'web' as const,
|
||||||
|
status: 'live' as const,
|
||||||
|
stack: [],
|
||||||
|
featured: false,
|
||||||
|
}) as never
|
||||||
|
}
|
||||||
|
itemLabel={(i) => `skeud #${String(i + 1).padStart(2, '0')}`}
|
||||||
|
>
|
||||||
|
{({ namePrefix }) => (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<TextField name={`${namePrefix}.id`} label="ID" hint="Slug unique" />
|
||||||
|
<TextField name={`${namePrefix}.title`} label="Titre" />
|
||||||
|
</div>
|
||||||
|
<TextField name={`${namePrefix}.subtitle`} label="Sous-titre" />
|
||||||
|
<TextAreaField name={`${namePrefix}.description`} label="Description" rows={3} />
|
||||||
|
<ImageUploadField name={`${namePrefix}.image`} label="Pochette" folder="projet" />
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<SelectField name={`${namePrefix}.category`} label="Catégorie" options={CATEGORY_OPTIONS} />
|
||||||
|
<SelectField name={`${namePrefix}.status`} label="Statut" options={STATUS_OPTIONS} />
|
||||||
|
<ToggleField name={`${namePrefix}.featured`} label="Mis en avant" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<TextField name={`${namePrefix}.url`} label="URL live" type="url" />
|
||||||
|
<TextField name={`${namePrefix}.repoUrl`} label="URL repo" type="url" />
|
||||||
|
</div>
|
||||||
|
<ChipsField name={`${namePrefix}.stack`} label="Stack" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ArrayEditor>
|
||||||
|
</Section>
|
||||||
|
</FormShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/components/admin/forms/SiteForm.tsx
Normal file
39
src/components/admin/forms/SiteForm.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { SiteSchema, type Site } from '../../../content/schemas/site';
|
||||||
|
import { FormShell } from '../FormShell';
|
||||||
|
import { Section } from '../Section';
|
||||||
|
import { TextField } from '../fields/TextField';
|
||||||
|
import { TextAreaField } from '../fields/TextAreaField';
|
||||||
|
import { ToggleField } from '../fields/ToggleField';
|
||||||
|
import { ChipsField } from '../fields/ChipsField';
|
||||||
|
import { ImageUploadField } from '../fields/ImageUploadField';
|
||||||
|
|
||||||
|
type Props = { defaultValues: Site };
|
||||||
|
|
||||||
|
export function SiteForm({ defaultValues }: Props) {
|
||||||
|
return (
|
||||||
|
<FormShell<Site>
|
||||||
|
section="site"
|
||||||
|
defaultValues={defaultValues as never}
|
||||||
|
resolver={zodResolver(SiteSchema) as never}
|
||||||
|
>
|
||||||
|
<Section title="Méta" subtitle="// SEO">
|
||||||
|
<TextField name="title" label="Titre du site" />
|
||||||
|
<TextAreaField name="description" label="Description" rows={3} />
|
||||||
|
<TextField name="url" label="URL canonique" type="url" />
|
||||||
|
<ChipsField name="keywords" label="Keywords" hint="Mots-clés SEO" />
|
||||||
|
<ImageUploadField name="ogImage" label="OG image" folder="" hint="Affichée lors d'un partage social" />
|
||||||
|
<TextField name="twitter" label="Handle Twitter / X" placeholder="@johanleroy" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Features" subtitle="// flags">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<ToggleField name="features.hero3D" label="Hero 3D" />
|
||||||
|
<ToggleField name="features.smoothScroll" label="Smooth scroll (Lenis)" />
|
||||||
|
<ToggleField name="features.customCursor" label="Curseur custom" />
|
||||||
|
<ToggleField name="features.partyMode" label="Party mode (Konami)" />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</FormShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/components/admin/forms/SkillsForm.tsx
Normal file
74
src/components/admin/forms/SkillsForm.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { SkillsSchema, type Skills } from '../../../content/schemas/skills';
|
||||||
|
import { FormShell } from '../FormShell';
|
||||||
|
import { Section } from '../Section';
|
||||||
|
import { TextField } from '../fields/TextField';
|
||||||
|
import { SelectField } from '../fields/SelectField';
|
||||||
|
import { EmojiField } from '../fields/EmojiField';
|
||||||
|
import { ArrayEditor } from '../fields/ArrayEditor';
|
||||||
|
|
||||||
|
type Props = { defaultValues: Skills };
|
||||||
|
|
||||||
|
const LEVEL_OPTIONS = [
|
||||||
|
{ value: 'primary', label: 'Primary' },
|
||||||
|
{ value: 'secondary', label: 'Secondary' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function CategoryArray({ root, label, subtitle }: { root: 'primary' | 'secondary'; label: string; subtitle?: string }) {
|
||||||
|
return (
|
||||||
|
<Section title={label} subtitle={subtitle}>
|
||||||
|
<ArrayEditor
|
||||||
|
name={root}
|
||||||
|
label="Catégories"
|
||||||
|
newItem={() => ({ id: '', label: '', items: [] as { name: string; icon: string; level: 'primary' | 'secondary' }[] })}
|
||||||
|
itemLabel={(i) => `catégorie #${String(i + 1).padStart(2, '0')}`}
|
||||||
|
>
|
||||||
|
{({ namePrefix }) => (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<TextField name={`${namePrefix}.id`} label="ID" hint="Slug interne, ex : front, devops" />
|
||||||
|
<TextField name={`${namePrefix}.label`} label="Label affiché" />
|
||||||
|
</div>
|
||||||
|
<ArrayEditor
|
||||||
|
name={`${namePrefix}.items`}
|
||||||
|
label="Skills"
|
||||||
|
newItem={() => ({ name: '', icon: '⚡', level: 'primary' as const })}
|
||||||
|
itemLabel={(i) => `skill #${String(i + 1).padStart(2, '0')}`}
|
||||||
|
>
|
||||||
|
{({ namePrefix: skillName }) => (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<TextField name={`${skillName}.name`} label="Nom" />
|
||||||
|
<SelectField
|
||||||
|
name={`${skillName}.level`}
|
||||||
|
label="Niveau"
|
||||||
|
options={LEVEL_OPTIONS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<EmojiField
|
||||||
|
name={`${skillName}.icon`}
|
||||||
|
label="Icône"
|
||||||
|
hint="Emoji ou caractère court (slug iconify supporté)"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ArrayEditor>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ArrayEditor>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillsForm({ defaultValues }: Props) {
|
||||||
|
return (
|
||||||
|
<FormShell<Skills>
|
||||||
|
section="skills"
|
||||||
|
defaultValues={defaultValues as never}
|
||||||
|
resolver={zodResolver(SkillsSchema) as never}
|
||||||
|
>
|
||||||
|
<CategoryArray root="primary" label="Stack primaire" subtitle="// rack 1" />
|
||||||
|
<CategoryArray root="secondary" label="Stack secondaire" subtitle="// rack 2" />
|
||||||
|
</FormShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -86,7 +86,7 @@ export default function PatchBay({ technical, soft }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative grid grid-cols-1 md:grid-cols-[1fr_auto_1fr] gap-10 md:gap-20 bg-[color:var(--color-surface)] border border-[color:var(--color-border)] rounded-sm p-6 md:p-10"
|
className="relative grid grid-cols-1 sm:grid-cols-2 md:grid-cols-[1fr_auto_1fr] gap-6 sm:gap-10 md:gap-20 bg-[color:var(--color-surface)] border border-[color:var(--color-border)] rounded-sm p-5 sm:p-6 md:p-10"
|
||||||
>
|
>
|
||||||
{/* Left panel : technical */}
|
{/* Left panel : technical */}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export default function RackModule({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pads grid */}
|
{/* Pads grid */}
|
||||||
<div className="grid grid-cols-4 gap-2 flex-1 content-start">
|
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 flex-1 content-start">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.name}
|
key={item.name}
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export default function Setlist({ items }: Props) {
|
|||||||
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
|
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
|
||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="px-3 md:px-5 pb-6 pt-2 grid grid-cols-1 md:grid-cols-[200px_1fr] gap-6">
|
<div className="px-3 md:px-5 pb-6 pt-2 grid grid-cols-1 sm:grid-cols-[180px_1fr] md:grid-cols-[200px_1fr] gap-4 sm:gap-6">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{exp.logo && (
|
{exp.logo && (
|
||||||
<div className="w-16 h-16 shrink-0 flex items-center justify-center bg-[color:var(--color-bg)] border border-[color:var(--color-border)] rounded-sm p-2">
|
<div className="w-16 h-16 shrink-0 flex items-center justify-center bg-[color:var(--color-bg)] border border-[color:var(--color-border)] rounded-sm p-2">
|
||||||
|
|||||||
@@ -52,14 +52,14 @@ export default function SkeudCard({ project, index, bpm }: Props) {
|
|||||||
return (
|
return (
|
||||||
<Wrap
|
<Wrap
|
||||||
{...wrapProps}
|
{...wrapProps}
|
||||||
className="group relative block w-[280px] sm:w-[340px] flex-shrink-0"
|
className="group relative block w-[260px] sm:w-[300px] md:w-[340px] flex-shrink-0"
|
||||||
onMouseEnter={() => setHover(true)}
|
onMouseEnter={() => setHover(true)}
|
||||||
onMouseLeave={() => setHover(false)}
|
onMouseLeave={() => setHover(false)}
|
||||||
style={{ perspective: 1200 } as CSSProperties}
|
style={{ perspective: 1200 } as CSSProperties}
|
||||||
>
|
>
|
||||||
{/* Order number en filigrane derrière */}
|
{/* Order number en filigrane derrière */}
|
||||||
<span
|
<span
|
||||||
className="absolute -top-10 -left-2 stencil text-7xl leading-none text-[color:var(--color-border)] select-none pointer-events-none"
|
className="absolute -top-8 sm:-top-10 -left-2 stencil text-5xl sm:text-7xl leading-none text-[color:var(--color-border)] select-none pointer-events-none"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{orderLabel}
|
{orderLabel}
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ export default function SmoothScroll() {
|
|||||||
}
|
}
|
||||||
rafId = requestAnimationFrame(raf);
|
rafId = requestAnimationFrame(raf);
|
||||||
|
|
||||||
|
const resetScroll = () => {
|
||||||
|
lenis.scrollTo(0, { immediate: true, force: true });
|
||||||
|
};
|
||||||
|
document.addEventListener('astro:after-swap', resetScroll);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
document.removeEventListener('astro:after-swap', resetScroll);
|
||||||
cancelAnimationFrame(rafId);
|
cancelAnimationFrame(rafId);
|
||||||
lenis.destroy();
|
lenis.destroy();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
import { readFile, writeFile } from 'node:fs/promises';
|
import { readFile, writeFile, mkdir, copyFile, access } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { CONTENT_SCHEMAS, type ContentKey } from '../content/schemas';
|
import { CONTENT_SCHEMAS, type ContentKey } from '../content/schemas';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
const CONTENT_DIR = join(process.cwd(), 'public', 'content');
|
const DATA_CONTENT_DIR = join(process.cwd(), 'data', 'content');
|
||||||
|
const SEED_CONTENT_DIR = join(process.cwd(), 'public', 'content');
|
||||||
|
|
||||||
type ContentData<K extends ContentKey> = z.infer<(typeof CONTENT_SCHEMAS)[K]>;
|
type ContentData<K extends ContentKey> = z.infer<(typeof CONTENT_SCHEMAS)[K]>;
|
||||||
|
|
||||||
|
async function ensureContentFile(key: ContentKey): Promise<string> {
|
||||||
|
const target = join(DATA_CONTENT_DIR, `${key}.json`);
|
||||||
|
try {
|
||||||
|
await access(target);
|
||||||
|
return target;
|
||||||
|
} catch {
|
||||||
|
await mkdir(DATA_CONTENT_DIR, { recursive: true });
|
||||||
|
const seed = join(SEED_CONTENT_DIR, `${key}.json`);
|
||||||
|
await copyFile(seed, target);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadContent<K extends ContentKey>(key: K): Promise<ContentData<K>> {
|
export async function loadContent<K extends ContentKey>(key: K): Promise<ContentData<K>> {
|
||||||
const filePath = join(CONTENT_DIR, `${key}.json`);
|
const filePath = await ensureContentFile(key);
|
||||||
const raw = await readFile(filePath, 'utf-8');
|
const raw = await readFile(filePath, 'utf-8');
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
const schema = CONTENT_SCHEMAS[key];
|
const schema = CONTENT_SCHEMAS[key];
|
||||||
@@ -21,7 +35,7 @@ export async function saveContent<K extends ContentKey>(
|
|||||||
): Promise<ContentData<K>> {
|
): Promise<ContentData<K>> {
|
||||||
const schema = CONTENT_SCHEMAS[key];
|
const schema = CONTENT_SCHEMAS[key];
|
||||||
const validated = schema.parse(data) as ContentData<K>;
|
const validated = schema.parse(data) as ContentData<K>;
|
||||||
const filePath = join(CONTENT_DIR, `${key}.json`);
|
const filePath = await ensureContentFile(key);
|
||||||
await writeFile(filePath, JSON.stringify(validated, null, 2) + '\n', 'utf-8');
|
await writeFile(filePath, JSON.stringify(validated, null, 2) + '\n', 'utf-8');
|
||||||
return validated;
|
return validated;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export const prerender = false;
|
|||||||
import '../../../styles/global.css';
|
import '../../../styles/global.css';
|
||||||
import { loadContent } from '../../../lib/content';
|
import { loadContent } from '../../../lib/content';
|
||||||
import { CONTENT_SCHEMAS, type ContentKey } from '../../../content/schemas';
|
import { CONTENT_SCHEMAS, type ContentKey } from '../../../content/schemas';
|
||||||
|
import { EditPanel } from '../../../components/admin/EditPanel';
|
||||||
|
|
||||||
const { section } = Astro.params;
|
const { section } = Astro.params;
|
||||||
|
|
||||||
@@ -12,7 +13,6 @@ if (!section || !(section in CONTENT_SCHEMAS)) {
|
|||||||
|
|
||||||
const key = section as ContentKey;
|
const key = section as ContentKey;
|
||||||
const data = await loadContent(key);
|
const data = await loadContent(key);
|
||||||
const jsonString = JSON.stringify(data, null, 2);
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
@@ -22,7 +22,7 @@ const jsonString = JSON.stringify(data, null, 2);
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Admin — Édition {section}</title>
|
<title>Admin — Édition {section}</title>
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;700&family=JetBrains+Mono:wght@400;500&family=Bebas+Neue&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
@@ -38,77 +38,19 @@ const jsonString = JSON.stringify(data, null, 2);
|
|||||||
{section}
|
{section}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<a
|
||||||
<div class="flex items-center gap-3">
|
href={`/api/content/${section}`}
|
||||||
<span id="save-status" class="text-xs font-mono text-[color:var(--color-text-dim)]"></span>
|
target="_blank"
|
||||||
<button id="format-btn" class="btn-ghost text-xs py-1.5">Formater</button>
|
rel="noreferrer"
|
||||||
<button id="save-btn" class="btn-primary text-xs py-1.5">Enregistrer</button>
|
class="font-mono text-xs text-[color:var(--color-text-dim)] hover:text-[color:var(--color-cyan)]"
|
||||||
</div>
|
>
|
||||||
|
↗ JSON courant
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="container mx-auto px-4 py-6">
|
<main class="container mx-auto px-4 py-6 pb-24">
|
||||||
<p class="text-xs font-mono text-[color:var(--color-text-dim)] mb-2">
|
<EditPanel section={key} data={data} client:load />
|
||||||
Édition libre en JSON. Le contenu est validé contre le schéma Zod avant sauvegarde.
|
|
||||||
</p>
|
|
||||||
<textarea
|
|
||||||
id="editor"
|
|
||||||
class="w-full h-[calc(100vh-200px)] bg-[color:var(--color-surface)] border border-[color:var(--color-border)] focus:border-[color:var(--color-cyan)] focus:outline-none p-4 rounded text-sm font-mono text-[color:var(--color-text)] resize-none"
|
|
||||||
spellcheck="false"
|
|
||||||
>{jsonString}</textarea>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script define:vars={{ section }}>
|
|
||||||
const editor = document.getElementById('editor');
|
|
||||||
const saveBtn = document.getElementById('save-btn');
|
|
||||||
const formatBtn = document.getElementById('format-btn');
|
|
||||||
const status = document.getElementById('save-status');
|
|
||||||
|
|
||||||
formatBtn.addEventListener('click', () => {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(editor.value);
|
|
||||||
editor.value = JSON.stringify(parsed, null, 2);
|
|
||||||
status.textContent = 'formaté';
|
|
||||||
status.style.color = 'var(--color-cyan)';
|
|
||||||
} catch (e) {
|
|
||||||
status.textContent = 'JSON invalide';
|
|
||||||
status.style.color = 'var(--color-magenta)';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
saveBtn.addEventListener('click', async () => {
|
|
||||||
status.textContent = 'enregistrement…';
|
|
||||||
status.style.color = 'var(--color-text-muted)';
|
|
||||||
let body;
|
|
||||||
try {
|
|
||||||
body = JSON.parse(editor.value);
|
|
||||||
} catch (e) {
|
|
||||||
status.textContent = 'JSON invalide';
|
|
||||||
status.style.color = 'var(--color-magenta)';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const res = await fetch(`/api/content/${section}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
const result = await res.json();
|
|
||||||
if (res.ok) {
|
|
||||||
status.textContent = '✓ enregistré';
|
|
||||||
status.style.color = 'var(--color-cyan)';
|
|
||||||
setTimeout(() => { status.textContent = ''; }, 3000);
|
|
||||||
} else {
|
|
||||||
status.textContent = `✗ ${result.message || result.error}`;
|
|
||||||
status.style.color = 'var(--color-magenta)';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.addEventListener('keydown', (e) => {
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
|
||||||
e.preventDefault();
|
|
||||||
saveBtn.click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const sectionLabels: Record<string, { label: string; icon: string; description:
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{
|
{
|
||||||
sections.map((s) => {
|
sections.map((s) => {
|
||||||
const meta = sectionLabels[s] ?? { label: s, icon: '📄', description: '' };
|
const meta = sectionLabels[s] ?? { label: s, icon: '📄', description: '' };
|
||||||
|
|||||||
50
src/pages/api/files/[...path].ts
Normal file
50
src/pages/api/files/[...path].ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { readFile, stat } from 'node:fs/promises';
|
||||||
|
import { join, resolve, extname } from 'node:path';
|
||||||
|
|
||||||
|
const UPLOADS_ROOT = resolve(process.cwd(), 'data', 'uploads');
|
||||||
|
|
||||||
|
const MIME: Record<string, string> = {
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ params }) => {
|
||||||
|
const rawPath = (params.path as string | undefined) ?? '';
|
||||||
|
if (!rawPath) {
|
||||||
|
return new Response('not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = resolve(join(UPLOADS_ROOT, rawPath));
|
||||||
|
if (!target.startsWith(UPLOADS_ROOT + '/') && target !== UPLOADS_ROOT) {
|
||||||
|
return new Response('forbidden', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await stat(target);
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
return new Response('not found', { status: 404 });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return new Response('not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = extname(target).toLowerCase();
|
||||||
|
const type = MIME[ext] ?? 'application/octet-stream';
|
||||||
|
const buffer = await readFile(target);
|
||||||
|
|
||||||
|
return new Response(buffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': type,
|
||||||
|
'Content-Length': String(buffer.length),
|
||||||
|
'Cache-Control': 'public, max-age=300, must-revalidate',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -4,8 +4,15 @@ import type { APIRoute } from 'astro';
|
|||||||
import { writeFile, mkdir } from 'node:fs/promises';
|
import { writeFile, mkdir } from 'node:fs/promises';
|
||||||
import { join, extname, basename } from 'node:path';
|
import { join, extname, basename } from 'node:path';
|
||||||
|
|
||||||
const UPLOAD_DIR = join(process.cwd(), 'public', 'img');
|
const UPLOAD_ROOT = join(process.cwd(), 'data', 'uploads');
|
||||||
const ALLOWED_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.svg', '.gif']);
|
|
||||||
|
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.svg', '.gif']);
|
||||||
|
const DOCUMENT_EXT = new Set(['.pdf']);
|
||||||
|
|
||||||
|
const MAX_SIZE: Record<'image' | 'document', number> = {
|
||||||
|
image: 5 * 1024 * 1024,
|
||||||
|
document: 8 * 1024 * 1024,
|
||||||
|
};
|
||||||
|
|
||||||
function sanitize(filename: string): string {
|
function sanitize(filename: string): string {
|
||||||
const name = basename(filename).replace(/[^a-zA-Z0-9._-]/g, '_');
|
const name = basename(filename).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||||
@@ -16,25 +23,33 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const file = form.get('file');
|
const file = form.get('file');
|
||||||
const folder = (form.get('folder') as string | null) ?? '';
|
const folder = (form.get('folder') as string | null) ?? '';
|
||||||
|
const kindRaw = (form.get('kind') as string | null) ?? 'image';
|
||||||
|
const kind: 'image' | 'document' = kindRaw === 'document' ? 'document' : 'image';
|
||||||
|
|
||||||
if (!(file instanceof File)) {
|
if (!(file instanceof File)) {
|
||||||
return new Response(JSON.stringify({ error: 'no_file' }), { status: 400 });
|
return new Response(JSON.stringify({ error: 'no_file' }), { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = extname(file.name).toLowerCase();
|
const ext = extname(file.name).toLowerCase();
|
||||||
if (!ALLOWED_EXT.has(ext)) {
|
const allowed = kind === 'document' ? DOCUMENT_EXT : IMAGE_EXT;
|
||||||
|
if (!allowed.has(ext)) {
|
||||||
return new Response(JSON.stringify({ error: 'invalid_extension' }), { status: 400 });
|
return new Response(JSON.stringify({ error: 'invalid_extension' }), { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_SIZE[kind]) {
|
||||||
|
return new Response(JSON.stringify({ error: 'file_too_large' }), { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const safeFolder = folder.replace(/[^a-zA-Z0-9_-]/g, '');
|
const safeFolder = folder.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||||
const targetDir = safeFolder ? join(UPLOAD_DIR, safeFolder) : UPLOAD_DIR;
|
const bucket = kind === 'document' ? 'assets' : 'img';
|
||||||
|
const targetDir = safeFolder ? join(UPLOAD_ROOT, bucket, safeFolder) : join(UPLOAD_ROOT, bucket);
|
||||||
await mkdir(targetDir, { recursive: true });
|
await mkdir(targetDir, { recursive: true });
|
||||||
|
|
||||||
const filename = sanitize(file.name);
|
const filename = sanitize(file.name);
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
await writeFile(join(targetDir, filename), buffer);
|
await writeFile(join(targetDir, filename), buffer);
|
||||||
|
|
||||||
const url = `/img/${safeFolder ? safeFolder + '/' : ''}${filename}`;
|
const url = `/api/files/${bucket}/${safeFolder ? safeFolder + '/' : ''}${filename}`;
|
||||||
return new Response(JSON.stringify({ ok: true, url }), {
|
return new Response(JSON.stringify({ ok: true, url }), {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const yearsTotal = currentYear - firstYear;
|
|||||||
<span class="led led-red animate-led-red"></span>
|
<span class="led led-red animate-led-red"></span>
|
||||||
// setlist.current
|
// setlist.current
|
||||||
</p>
|
</p>
|
||||||
<h1 class="stencil text-5xl md:text-7xl uppercase leading-none">
|
<h1 class="stencil text-4xl sm:text-5xl md:text-7xl uppercase leading-none">
|
||||||
Mon <span class="text-[color:var(--color-magenta)] glow-text-magenta">expérience</span>
|
Mon <span class="text-[color:var(--color-magenta)] glow-text-magenta">expérience</span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +51,7 @@ const yearsTotal = currentYear - firstYear;
|
|||||||
<p class="font-mono text-xs uppercase tracking-[0.3em] text-[color:var(--color-cyan)] mb-2">
|
<p class="font-mono text-xs uppercase tracking-[0.3em] text-[color:var(--color-cyan)] mb-2">
|
||||||
// patch bay
|
// patch bay
|
||||||
</p>
|
</p>
|
||||||
<h2 class="stencil text-3xl md:text-4xl uppercase leading-none">
|
<h2 class="stencil text-2xl sm:text-3xl md:text-4xl uppercase leading-none">
|
||||||
Compétences <span class="text-[color:var(--color-cyan)] glow-text-cyan">pros</span>
|
Compétences <span class="text-[color:var(--color-cyan)] glow-text-cyan">pros</span>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ function extractLocation(school: string): string {
|
|||||||
<span class="led led-cyan"></span>
|
<span class="led led-cyan"></span>
|
||||||
// bpm log · crate digging
|
// bpm log · crate digging
|
||||||
</p>
|
</p>
|
||||||
<h1 class="stencil text-5xl md:text-7xl uppercase leading-none">
|
<h1 class="stencil text-4xl sm:text-5xl md:text-7xl uppercase leading-none">
|
||||||
Mes <span class="text-[color:var(--color-cyan)] glow-text-cyan">formations</span>
|
Mes <span class="text-[color:var(--color-cyan)] glow-text-cyan">formations</span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,8 +53,8 @@ function extractLocation(school: string): string {
|
|||||||
client:visible
|
client:visible
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="relative group transition-transform duration-500 hover:rotate-0"
|
class="formation-ticket relative group transition-transform duration-500"
|
||||||
style={`transform: rotate(${tilt}deg); max-width: 640px; margin-${isEven ? 'right' : 'left'}: auto;`}
|
style={`--tilt: ${tilt}deg; max-width: 640px; margin-${isEven ? 'right' : 'left'}: auto;`}
|
||||||
>
|
>
|
||||||
<article
|
<article
|
||||||
class="relative bg-[#F5EDE0] text-[#14130E] rounded-sm overflow-hidden shadow-[0_20px_50px_-20px_rgba(0,0,0,0.8)]"
|
class="relative bg-[#F5EDE0] text-[#14130E] rounded-sm overflow-hidden shadow-[0_20px_50px_-20px_rgba(0,0,0,0.8)]"
|
||||||
@@ -71,8 +71,8 @@ function extractLocation(school: string): string {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div class="p-5 md:p-8 grid grid-cols-1 md:grid-cols-[auto_1fr] gap-6">
|
<div class="p-5 md:p-8 grid grid-cols-1 sm:grid-cols-[auto_1fr] gap-5 sm:gap-6">
|
||||||
<div class="font-mono text-[10px] uppercase tracking-widest space-y-2 md:border-r-2 md:border-dashed md:border-[#14130E]/30 md:pr-6">
|
<div class="font-mono text-[10px] uppercase tracking-widest space-y-2 sm:border-r-2 sm:border-dashed sm:border-[#14130E]/30 sm:pr-6">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-[#14130E]/50">DATE</div>
|
<div class="text-[#14130E]/50">DATE</div>
|
||||||
<div class="stencil text-base tracking-wider">{f.period}</div>
|
<div class="stencil text-base tracking-wider">{f.period}</div>
|
||||||
@@ -141,3 +141,24 @@ function extractLocation(school: string): string {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.formation-ticket {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.formation-ticket {
|
||||||
|
transform: rotate(var(--tilt, 0deg));
|
||||||
|
}
|
||||||
|
.formation-ticket:hover {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.formation-ticket,
|
||||||
|
.formation-ticket:hover {
|
||||||
|
transform: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -32,77 +32,111 @@ const { profile, skills, interests, site } = await loadAll();
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container mx-auto px-4 relative z-10">
|
<div class="container mx-auto px-4 relative z-10">
|
||||||
<div class="max-w-3xl">
|
<div class="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_auto] gap-10 lg:gap-16 items-center">
|
||||||
<p
|
<div class="max-w-3xl order-2 lg:order-1">
|
||||||
class="font-mono text-xs uppercase tracking-[0.3em] text-[color:var(--color-cyan)] mb-4 flex items-center gap-2"
|
<p
|
||||||
>
|
class="font-mono text-xs uppercase tracking-[0.3em] text-[color:var(--color-cyan)] mb-4 flex items-center gap-2"
|
||||||
<span class="led led-cyan"></span>
|
>
|
||||||
> frequency: 174 bpm · hardtek
|
<span class="led led-cyan"></span>
|
||||||
</p>
|
> frequency: 174 bpm · hardtek
|
||||||
|
</p>
|
||||||
|
|
||||||
<h1 class="stencil text-6xl md:text-8xl lg:text-9xl leading-[0.9] mb-6 uppercase">
|
<h1 class="stencil text-5xl sm:text-6xl md:text-8xl lg:text-9xl leading-[0.9] mb-6 uppercase">
|
||||||
<SplitReveal
|
<SplitReveal
|
||||||
text="Johan"
|
text="Johan"
|
||||||
as="span"
|
as="span"
|
||||||
className="block text-[color:var(--color-text)]"
|
className="block text-[color:var(--color-text)]"
|
||||||
delay={0.1}
|
delay={0.1}
|
||||||
client:load
|
client:load
|
||||||
/>
|
/>
|
||||||
<SplitReveal
|
<SplitReveal
|
||||||
text="Leroy"
|
text="Leroy"
|
||||||
as="span"
|
as="span"
|
||||||
className="block text-[color:var(--color-acid)] glow-text-acid"
|
className="block text-[color:var(--color-acid)] glow-text-acid"
|
||||||
delay={0.45}
|
delay={0.45}
|
||||||
client:load
|
client:load
|
||||||
/>
|
/>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="font-display text-2xl md:text-4xl mb-8 min-h-[2.5rem] flex items-center gap-2"
|
class="font-display text-xl sm:text-2xl md:text-4xl mb-8 min-h-[2.5rem] flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<span class="text-[color:var(--color-text-dim)]">[</span>
|
<span class="text-[color:var(--color-text-dim)]">[</span>
|
||||||
<RolesMorph roles={profile.hero.roles} client:load />
|
<RolesMorph roles={profile.hero.roles} client:load />
|
||||||
<span class="text-[color:var(--color-text-dim)]">]</span>
|
<span class="text-[color:var(--color-text-dim)]">]</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
class="font-mono text-sm uppercase tracking-[0.25em] text-[color:var(--color-magenta)] mb-6 italic"
|
||||||
|
>
|
||||||
|
// code propre, sono sale
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="text-base sm:text-lg text-[color:var(--color-text-muted)] mb-10 max-w-2xl">
|
||||||
|
{profile.bio}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
{
|
||||||
|
profile.hero.cta.map((btn) => {
|
||||||
|
const href = btn.label.toLowerCase().includes('cv')
|
||||||
|
? (profile.cv ?? btn.href)
|
||||||
|
: btn.href;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target={btn.external ? '_blank' : undefined}
|
||||||
|
rel={btn.external ? 'noopener noreferrer' : undefined}
|
||||||
|
class:list={[btn.variant === 'primary' ? 'btn-primary' : 'btn-ghost']}
|
||||||
|
>
|
||||||
|
{btn.label}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
{profile.hero.photo && (
|
||||||
class="font-mono text-sm uppercase tracking-[0.25em] text-[color:var(--color-magenta)] mb-6 italic"
|
<div class="order-1 lg:order-2 flex justify-center lg:justify-end">
|
||||||
>
|
<div class="hero-photo relative w-56 sm:w-64 lg:w-[360px] aspect-square">
|
||||||
// code propre, sono sale
|
<span class="hero-photo__bracket hero-photo__bracket--tl"></span>
|
||||||
</p>
|
<span class="hero-photo__bracket hero-photo__bracket--tr"></span>
|
||||||
|
<span class="hero-photo__bracket hero-photo__bracket--bl"></span>
|
||||||
<p class="text-lg text-[color:var(--color-text-muted)] mb-10 max-w-2xl">
|
<span class="hero-photo__bracket hero-photo__bracket--br"></span>
|
||||||
{profile.bio}
|
<div class="hero-photo__frame">
|
||||||
</p>
|
<img
|
||||||
|
src={profile.hero.photo}
|
||||||
<div class="flex flex-wrap gap-4">
|
alt={profile.name}
|
||||||
{
|
width="360"
|
||||||
profile.hero.cta.map((btn) => (
|
height="360"
|
||||||
<a
|
loading="eager"
|
||||||
href={btn.href}
|
decoding="async"
|
||||||
target={btn.external ? '_blank' : undefined}
|
class="w-full h-full object-cover"
|
||||||
rel={btn.external ? 'noopener noreferrer' : undefined}
|
/>
|
||||||
class:list={[btn.variant === 'primary' ? 'btn-primary' : 'btn-ghost']}
|
<span class="hero-photo__scanlines" aria-hidden="true"></span>
|
||||||
>
|
<span class="hero-photo__led" aria-hidden="true">
|
||||||
{btn.label}
|
<span class="led led-acid animate-pulse"></span>
|
||||||
<svg
|
REC · 174 BPM
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
</span>
|
||||||
class="w-4 h-4"
|
</div>
|
||||||
fill="none"
|
</div>
|
||||||
viewBox="0 0 24 24"
|
</div>
|
||||||
stroke="currentColor"
|
)}
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -140,7 +174,7 @@ const { profile, skills, interests, site } = await loadAll();
|
|||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 auto-rows-fr">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 auto-rows-fr">
|
||||||
{
|
{
|
||||||
skills.primary.map((cat, i) => (
|
skills.primary.map((cat, i) => (
|
||||||
<Reveal delay={i * 0.06} className="h-full" client:visible>
|
<Reveal delay={i * 0.06} className="h-full" client:visible>
|
||||||
@@ -174,7 +208,7 @@ const { profile, skills, interests, site } = await loadAll();
|
|||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 auto-rows-fr">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 auto-rows-fr">
|
||||||
{
|
{
|
||||||
skills.secondary.map((cat, i) => (
|
skills.secondary.map((cat, i) => (
|
||||||
<Reveal delay={i * 0.06} className="h-full" client:visible>
|
<Reveal delay={i * 0.06} className="h-full" client:visible>
|
||||||
@@ -209,7 +243,7 @@ const { profile, skills, interests, site } = await loadAll();
|
|||||||
</div>
|
</div>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 auto-rows-fr">
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 sm:gap-6 auto-rows-fr">
|
||||||
{
|
{
|
||||||
interests.items.map((interest, i) => {
|
interests.items.map((interest, i) => {
|
||||||
const color =
|
const color =
|
||||||
@@ -242,3 +276,102 @@ const { profile, skills, interests, site } = await loadAll();
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hero-photo {
|
||||||
|
--bracket: 18px;
|
||||||
|
--bracket-color: var(--color-cyan);
|
||||||
|
transform: rotate(0deg);
|
||||||
|
transition: transform 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.hero-photo {
|
||||||
|
transform: rotate(-2deg);
|
||||||
|
}
|
||||||
|
.hero-photo:hover {
|
||||||
|
transform: rotate(0deg) scale(1.02);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-photo__frame {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--color-cyan) 70%, transparent);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px color-mix(in oklab, var(--color-cyan) 25%, transparent),
|
||||||
|
0 0 28px color-mix(in oklab, var(--color-cyan) 35%, transparent),
|
||||||
|
inset 0 0 60px color-mix(in oklab, var(--color-magenta) 18%, transparent);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-photo__scanlines {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
rgba(0, 240, 255, 0.06) 0px,
|
||||||
|
rgba(0, 240, 255, 0.06) 1px,
|
||||||
|
transparent 1px,
|
||||||
|
transparent 3px
|
||||||
|
);
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-photo__led {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: rgba(10, 10, 15, 0.7);
|
||||||
|
border: 1px solid color-mix(in oklab, var(--color-acid) 60%, transparent);
|
||||||
|
color: var(--color-acid);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-photo__bracket {
|
||||||
|
position: absolute;
|
||||||
|
width: var(--bracket);
|
||||||
|
height: var(--bracket);
|
||||||
|
border-color: var(--bracket-color);
|
||||||
|
border-style: solid;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.hero-photo__bracket--tl {
|
||||||
|
top: -4px;
|
||||||
|
left: -4px;
|
||||||
|
border-width: 2px 0 0 2px;
|
||||||
|
}
|
||||||
|
.hero-photo__bracket--tr {
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
border-width: 2px 2px 0 0;
|
||||||
|
}
|
||||||
|
.hero-photo__bracket--bl {
|
||||||
|
bottom: -4px;
|
||||||
|
left: -4px;
|
||||||
|
border-width: 0 0 2px 2px;
|
||||||
|
}
|
||||||
|
.hero-photo__bracket--br {
|
||||||
|
bottom: -4px;
|
||||||
|
right: -4px;
|
||||||
|
border-width: 0 2px 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.hero-photo,
|
||||||
|
.hero-photo:hover {
|
||||||
|
transform: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const others = projects.items.filter((p) => !p.featured);
|
|||||||
<span class="led led-cyan"></span>
|
<span class="led led-cyan"></span>
|
||||||
// crate v1.0 · digging
|
// crate v1.0 · digging
|
||||||
</p>
|
</p>
|
||||||
<h1 class="stencil text-5xl md:text-7xl uppercase leading-none">
|
<h1 class="stencil text-4xl sm:text-5xl md:text-7xl uppercase leading-none">
|
||||||
Mes <span class="text-[color:var(--color-cyan)] glow-text-cyan">projets</span>
|
Mes <span class="text-[color:var(--color-cyan)] glow-text-cyan">projets</span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user