first commit
10
.env.example
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Admin credentials — générer via `npm run hash:password -- monMotDePasse`
|
||||||
|
ADMIN_PASSWORD_HASH=
|
||||||
|
ADMIN_JWT_SECRET=
|
||||||
|
|
||||||
|
# Port de production (Plesk Node.js)
|
||||||
|
PORT=3100
|
||||||
|
HOST=0.0.0.0
|
||||||
|
|
||||||
|
ADMIN_PASSWORD_HASH=$2b$12$1nXI9CgStfBs/DrtSpJ2buF1hnUCUTpcx6rDZNRczlkuCHXb.AjbK
|
||||||
|
ADMIN_JWT_SECRET=wef4R2NWoSko/bnIYFOyaQamI0QqOLe3J4ZSjsGdmvfgtADIT26Y6piFoM0l8h5o
|
||||||
54
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
name: Deploy to Plesk (Node.js hosting)
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build project
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Check build output
|
||||||
|
run: ls -la ./dist
|
||||||
|
|
||||||
|
- name: Deploy via FTP to Plesk
|
||||||
|
run: |
|
||||||
|
sudo apt-get update && sudo apt-get install -y lftp
|
||||||
|
HOST=${{ secrets.FTP_HOST }}
|
||||||
|
USER=${{ secrets.FTP_USER }}
|
||||||
|
PASSWORD=${{ secrets.FTP_PASSWORD }}
|
||||||
|
TARGET_DIR=${{ secrets.FTP_TARGET_DIR }}
|
||||||
|
lftp -c "
|
||||||
|
open -u $USER,$PASSWORD $HOST
|
||||||
|
set ssl:verify-certificate no
|
||||||
|
mirror -R --delete --verbose \
|
||||||
|
--exclude-glob node_modules/ \
|
||||||
|
--exclude-glob .git/ \
|
||||||
|
--exclude-glob .astro/ \
|
||||||
|
--exclude-glob public/content/ \
|
||||||
|
./dist $TARGET_DIR/dist
|
||||||
|
mirror -R --verbose ./public $TARGET_DIR/public
|
||||||
|
put -O $TARGET_DIR package.json
|
||||||
|
put -O $TARGET_DIR package-lock.json
|
||||||
|
"
|
||||||
|
|
||||||
|
- name: Trigger Plesk restart
|
||||||
|
if: success()
|
||||||
|
run: |
|
||||||
|
echo "::notice::Build uploadé. Redémarre l'application Node.js depuis Plesk pour appliquer."
|
||||||
28
.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# build output
|
||||||
|
dist/
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# deps
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# env
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# editors
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# temp
|
||||||
|
*.log
|
||||||
4
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["astro-build.astro-vscode"],
|
||||||
|
"unwantedRecommendations": []
|
||||||
|
}
|
||||||
159
README.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# Portfolio — Johan Leroy
|
||||||
|
|
||||||
|
Site vitrine personnel en **Astro 6 hybrid** (SSG public + SSR admin), déployé sur Plesk Node.js.
|
||||||
|
|
||||||
|
🎧 Dev le jour, DJ la nuit — stack cyberpunk dark, contenu éditable sans recompilation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Astro 6** (`output: 'static'` + adapter Node standalone — hybrid via `prerender: false` sur `/admin/*` et `/api/*`)
|
||||||
|
- **React 19** (îlots interactifs)
|
||||||
|
- **Tailwind CSS 4** + custom design system cyberpunk
|
||||||
|
- **Framer Motion** + **GSAP** + **Lenis** (animations)
|
||||||
|
- **@react-three/fiber** (hero 3D disco ball)
|
||||||
|
- **Zod** (validation schémas de contenu)
|
||||||
|
- **jose** + **bcryptjs** (auth admin JWT cookie)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Développement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
→ http://localhost:3100
|
||||||
|
|
||||||
|
## Édition du contenu
|
||||||
|
|
||||||
|
Tout le contenu éditable est dans `public/content/*.json` :
|
||||||
|
|
||||||
|
| Fichier | Rôle |
|
||||||
|
|---|---|
|
||||||
|
| `profile.json` | Profil, photo, CTA hero, réseaux sociaux |
|
||||||
|
| `skills.json` | Compétences techniques (primary + secondary) |
|
||||||
|
| `experiences.json` | Parcours pro + compétences techniques & transverses |
|
||||||
|
| `formations.json` | Diplômes et formations |
|
||||||
|
| `projects.json` | Portfolio de projets |
|
||||||
|
| `interests.json` | Centres d'intérêt |
|
||||||
|
| `site.json` | Meta SEO + flags features |
|
||||||
|
|
||||||
|
Tu peux éditer :
|
||||||
|
- **Via `/admin`** (interface web protégée par mot de passe) — recommandé en prod
|
||||||
|
- **Manuellement** en éditant les JSON et en rechargeant la page
|
||||||
|
|
||||||
|
**Aucune recompilation n'est nécessaire** : les fichiers sont servis statiquement côté site public, et relus au runtime côté admin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin
|
||||||
|
|
||||||
|
### Configuration initiale
|
||||||
|
|
||||||
|
1. Générer un hash de mot de passe :
|
||||||
|
```bash
|
||||||
|
npm run hash:password -- monMotDePasse
|
||||||
|
```
|
||||||
|
2. Copier les deux lignes générées (`ADMIN_PASSWORD_HASH` et `ADMIN_JWT_SECRET`) dans un fichier `.env` (local) ou dans les variables d'environnement Plesk (prod).
|
||||||
|
|
||||||
|
### Accès
|
||||||
|
|
||||||
|
→ http://localhost:3100/admin (ou https://johanleroy.fr/admin en prod)
|
||||||
|
|
||||||
|
Login via mot de passe, cookie httpOnly 7 jours.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Déploiement Plesk Node.js
|
||||||
|
|
||||||
|
### Variables d'environnement à définir sur Plesk
|
||||||
|
|
||||||
|
| Variable | Valeur |
|
||||||
|
|---|---|
|
||||||
|
| `ADMIN_PASSWORD_HASH` | hash bcrypt généré |
|
||||||
|
| `ADMIN_JWT_SECRET` | secret JWT aléatoire |
|
||||||
|
| `PORT` | `3100` (ou ce que Plesk impose) |
|
||||||
|
| `HOST` | `0.0.0.0` |
|
||||||
|
| `NODE_ENV` | `production` |
|
||||||
|
|
||||||
|
### Paramètres Plesk Node.js
|
||||||
|
|
||||||
|
- **Node.js version** : 22
|
||||||
|
- **Application root** : racine du repo
|
||||||
|
- **Application startup file** : `dist/server/entry.mjs`
|
||||||
|
- **Application URL** : https://johanleroy.fr
|
||||||
|
|
||||||
|
### CI/CD Gitea (branch `main`)
|
||||||
|
|
||||||
|
Le workflow `.gitea/workflows/deploy.yml` déclenche un build + upload FTP sur chaque push `main`.
|
||||||
|
|
||||||
|
**Secrets Gitea à configurer :**
|
||||||
|
- `FTP_HOST`
|
||||||
|
- `FTP_USER`
|
||||||
|
- `FTP_PASSWORD`
|
||||||
|
- `FTP_TARGET_DIR` (ex : `/httpdocs/johanleroy`)
|
||||||
|
|
||||||
|
Après chaque déploiement : redémarrer l'application Node.js depuis Plesk pour prendre en compte les nouveaux fichiers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/
|
||||||
|
│ ├── Header.astro, Footer.astro, SEO.astro
|
||||||
|
│ ├── ProjectCard.astro
|
||||||
|
│ └── islands/ # composants React hydratés
|
||||||
|
│ ├── Hero3D.tsx
|
||||||
|
│ ├── RolesMorph.tsx
|
||||||
|
│ ├── Reveal.tsx
|
||||||
|
│ ├── SkillCard.tsx
|
||||||
|
│ ├── SmoothScroll.tsx
|
||||||
|
│ └── CustomCursor.tsx
|
||||||
|
├── content/schemas/ # schémas Zod (typages + validation)
|
||||||
|
├── lib/
|
||||||
|
│ ├── auth.ts # JWT cookie admin
|
||||||
|
│ └── content.ts # lecture/écriture JSON
|
||||||
|
├── layouts/BaseLayout.astro
|
||||||
|
├── middleware.ts # protection routes /admin et /api
|
||||||
|
├── pages/
|
||||||
|
│ ├── index.astro
|
||||||
|
│ ├── formations.astro
|
||||||
|
│ ├── experience.astro
|
||||||
|
│ ├── projets.astro
|
||||||
|
│ ├── 404.astro
|
||||||
|
│ ├── admin/
|
||||||
|
│ │ ├── login.astro
|
||||||
|
│ │ ├── index.astro
|
||||||
|
│ │ └── edit/[section].astro
|
||||||
|
│ └── api/
|
||||||
|
│ ├── auth/login.ts, logout.ts
|
||||||
|
│ ├── content/[section].ts
|
||||||
|
│ └── upload.ts
|
||||||
|
└── styles/global.css
|
||||||
|
|
||||||
|
public/
|
||||||
|
├── content/*.json # données éditables
|
||||||
|
├── img/ # images (icônes, entreprises, projets, photos)
|
||||||
|
└── assets/cv.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Direction artistique
|
||||||
|
|
||||||
|
**Cyberpunk Dark** — noir profond + cyan/magenta néon.
|
||||||
|
|
||||||
|
- Palette : `#0A0A0F / #00F0FF / #FF2A6D / #D1F7FF`
|
||||||
|
- Typographies : Space Grotesk (display), Inter (body), JetBrains Mono (mono)
|
||||||
|
- Effets : disco ball 3D, smooth scroll Lenis, curseur custom, glow néon, scanlines, glitch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
© 2026 Johan Leroy.
|
||||||
19
astro.config.mjs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import react from '@astrojs/react';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import node from '@astrojs/node';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
site: 'https://johanleroy.fr',
|
||||||
|
output: 'static',
|
||||||
|
adapter: node({ mode: 'standalone' }),
|
||||||
|
integrations: [react()],
|
||||||
|
vite: {
|
||||||
|
plugins: [tailwindcss()],
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3100,
|
||||||
|
host: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
513
docs/01-analyse-et-plan.md
Normal file
@@ -0,0 +1,513 @@
|
|||||||
|
# Portfolio v2 — Analyse & plan d'action
|
||||||
|
|
||||||
|
**Projet source :** `/home/johan/WebstormProjects/Portfolio_angular`
|
||||||
|
**URL live :** https://johanleroy.fr
|
||||||
|
**Date :** 20 avril 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Analyse de l'existant
|
||||||
|
|
||||||
|
### Stack actuelle
|
||||||
|
|
||||||
|
| Élément | Valeur |
|
||||||
|
|---|---|
|
||||||
|
| Framework | Angular 19.2 |
|
||||||
|
| Styling | TailwindCSS 4 |
|
||||||
|
| State / Data | Aucun (tout en dur dans les templates) |
|
||||||
|
| Animations | Particules vanilla JS maison (`home.component.ts`) |
|
||||||
|
| Icônes | PNG dans `/public/img/icon/` (pas de SVG, pas HiDPI) |
|
||||||
|
| Icônes UI | Classes FontAwesome (`fa-gamepad`, `fa-film`) **sans lib chargée → cassées** |
|
||||||
|
| Dark mode | Toggle manuel via `localStorage` |
|
||||||
|
| SEO | Service custom `seo.service.ts` (title, description, OG, canonical) |
|
||||||
|
| Router | Lazy-load `PublicModule`, view transitions activées |
|
||||||
|
| Hébergement | Statique (`.htaccess` Apache dans `/public/`) |
|
||||||
|
| Tests | Karma/Jasmine (0 test écrit) |
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/app/
|
||||||
|
├── _models/ particule.ts, seo.ts
|
||||||
|
├── _services/ seo.service.ts
|
||||||
|
└── public/
|
||||||
|
├── public-layout/ header + dark toggle + nav
|
||||||
|
├── public-routing.module.ts
|
||||||
|
├── public.module.ts
|
||||||
|
└── pages/
|
||||||
|
├── home/ (448 lignes HTML — accueil + skills + centres d'intérêt)
|
||||||
|
├── formations/ (169 lignes)
|
||||||
|
├── experience/ (417 lignes — 6 entreprises Actemium, SNCF, Cerema, Novoferm, PivotPoint, Almeria)
|
||||||
|
├── projets/ (104 lignes — 3 projets MonVoisinGeek, Techos, LPLV)
|
||||||
|
└── not-found/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Palette actuelle (`src/styles.css`)
|
||||||
|
|
||||||
|
```css
|
||||||
|
--color-primary-dark: #181818
|
||||||
|
--color-primary-blue: #0A2463
|
||||||
|
--color-primary-light: #3E92CC
|
||||||
|
--color-primary-white: #FFFAFF
|
||||||
|
--color-primary-accent: #D8315B
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problèmes identifiés
|
||||||
|
|
||||||
|
1. **Stack disproportionnée** — Angular + Zone.js pour un portfolio statique = bundle lourd (warning 500 kB dans `angular.json`), overkill pour du contenu vitrine.
|
||||||
|
2. **Décalage avec ta stack actuelle** — tu bosses maintenant sur Next.js/Nuxt/NestJS, Angular n'est plus dans aucun de tes projets en mémoire.
|
||||||
|
3. **Contenu 100 % hardcodé dans les templates** — chaque modif de texte, techno, projet ou date d'expérience impose de recompiler + redéployer.
|
||||||
|
4. **Animations minimalistes** — juste des particules en `requestAnimationFrame`, pas d'effets wow (scroll reveal, parallax, 3D, text morphing, cursors custom…).
|
||||||
|
5. **Icônes PNG** — flou sur écrans HiDPI, poids réseau élevé, pas de teinte dynamique possible.
|
||||||
|
6. **FontAwesome référencé mais jamais chargé** → les 4 icônes des centres d'intérêt sont invisibles.
|
||||||
|
7. **Tests inexistants** malgré la config Karma — bruit dans `package.json`.
|
||||||
|
8. **Pas de PWA** (manifest.json, service worker) alors que c'est trivial sur du statique.
|
||||||
|
|
||||||
|
### Ce qui marche bien et à conserver
|
||||||
|
|
||||||
|
- La structure de navigation (Accueil / Formations / Expérience / Projets).
|
||||||
|
- La palette existante (tu peux la reprendre ou l'enrichir).
|
||||||
|
- Le `SeoService` et ses meta tags dynamiques.
|
||||||
|
- Dark mode par défaut selon préférence système.
|
||||||
|
- Export statique déployé sur Plesk via Apache.
|
||||||
|
- Le CV PDF téléchargeable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Choix techno recommandé
|
||||||
|
|
||||||
|
### Option A — **Astro 5 + React islands** ⭐ recommandé
|
||||||
|
|
||||||
|
**Pourquoi :**
|
||||||
|
- 0 JS par défaut → Lighthouse 100 presque gratuit.
|
||||||
|
- Islands pattern : tu charges React + Framer Motion uniquement sur les sections animées.
|
||||||
|
- Content Collections typées (Zod) → ton JSON devient un contrat validé au build.
|
||||||
|
- SSG pur, déploiement Plesk identique à l'actuel (export statique).
|
||||||
|
- Supporte nativement les endpoints SSR si tu veux un mini-admin.
|
||||||
|
|
||||||
|
**Stack complète :**
|
||||||
|
- Astro 5 (router fichiers, layouts)
|
||||||
|
- React 19 (îlots interactifs)
|
||||||
|
- TailwindCSS 4 (continuité palette)
|
||||||
|
- Framer Motion (micro-interactions déclaratives)
|
||||||
|
- GSAP + ScrollTrigger (timelines paillettes)
|
||||||
|
- Lenis (smooth scroll)
|
||||||
|
- @react-three/fiber + @react-three/drei (effets 3D type scène accueil)
|
||||||
|
- Lucide React (icônes UI) + Simple Icons via iconify (logos techno vectoriels)
|
||||||
|
- TypeScript strict
|
||||||
|
|
||||||
|
### Option B — **Next.js 15 + App Router**
|
||||||
|
|
||||||
|
Pertinent si tu veux un vrai mini-back-office plus tard (auth JWT, API routes, ISR). Plus lourd qu'Astro pour un simple portfolio.
|
||||||
|
|
||||||
|
### Option C — **Nuxt 4**
|
||||||
|
|
||||||
|
Si tu préfères rester côté Vue. Excellent mais tu connais déjà Vue 3 (Amarea, TrackSniff), donc pas de challenge.
|
||||||
|
|
||||||
|
### Recommandation
|
||||||
|
|
||||||
|
**Astro 5** — le meilleur rapport puissance/légèreté pour un portfolio. Tu ajoutes Next.js à ton CV avec Techos, Astro te donne un 3e framework frontend moderne à afficher, et tu gagnes une killer app de perf.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Système de contenu éditable sans rebuild
|
||||||
|
|
||||||
|
### Architecture proposée
|
||||||
|
|
||||||
|
```
|
||||||
|
public/
|
||||||
|
├── content/
|
||||||
|
│ ├── profile.json (nom, titre, accroche, photo, CV)
|
||||||
|
│ ├── skills.json (catégories + items avec niveau, logo iconify)
|
||||||
|
│ ├── experiences.json (tableau d'expériences pro)
|
||||||
|
│ ├── formations.json (tableau de formations)
|
||||||
|
│ ├── projects.json (tableau de projets + images + liens)
|
||||||
|
│ ├── interests.json (centres d'intérêt)
|
||||||
|
│ └── site.json (meta SEO, couleurs, switches features)
|
||||||
|
└── img/ (assets éditables)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Principe
|
||||||
|
|
||||||
|
- Les fichiers JSON sont servis **en statique** par ton Plesk (pas dans le bundle).
|
||||||
|
- Côté client, un `ContentService` fait un `fetch('/content/profile.json')` au mount.
|
||||||
|
- Les composants React affichent les données via Suspense.
|
||||||
|
- **Modif en production = éditer le JSON sur le serveur → rechargement navigateur = MAJ immédiate. Zéro rebuild.**
|
||||||
|
|
||||||
|
### Schémas typés (Zod)
|
||||||
|
|
||||||
|
Les mêmes schémas servent :
|
||||||
|
- au build (validation SSG des données si tu veux injecter au build)
|
||||||
|
- au runtime (parsing des JSON fetchés)
|
||||||
|
- à l'admin (génération de formulaires)
|
||||||
|
|
||||||
|
Exemple `content/profile.json` :
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Johan Leroy",
|
||||||
|
"title": "Développeur Fullstack",
|
||||||
|
"tagline": "Dev le jour, DJ la nuit",
|
||||||
|
"hero": {
|
||||||
|
"photo": "/img/pp/pp3.png",
|
||||||
|
"cta": [
|
||||||
|
{ "label": "Télécharger mon CV", "href": "/assets/cv.pdf" },
|
||||||
|
{ "label": "Voir mes projets", "href": "/projets" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"socials": {
|
||||||
|
"email": "leroyjohan3@gmail.com",
|
||||||
|
"github": "https://github.com/JohanLeroy",
|
||||||
|
"gitea": "https://git.lidge.fr/johanleroy",
|
||||||
|
"website": "https://johanleroy.fr"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3 niveaux d'édition (à choisir)
|
||||||
|
|
||||||
|
| Niveau | Outil | Usage |
|
||||||
|
|---|---|---|
|
||||||
|
| **1. Terminal / SFTP** | FTP Plesk + éditeur texte | MAJ rapide, zéro surcouche. Le plus simple. |
|
||||||
|
| **2. Mini-admin web** | `/admin` protégé par mot de passe (cookie httpOnly côté endpoint Astro SSR) | Formulaire qui édite `content/*.json` en live sur le serveur |
|
||||||
|
| **3. Gitea as CMS** | Un repo `johan-portfolio-content` dédié. L'admin web pousse via l'API Gitea. | Historisation automatique, rollback possible, build-free par cron `git pull` côté Plesk. |
|
||||||
|
|
||||||
|
**Reco pour toi :** niveau 2 ou 3. Le niveau 3 est le plus élégant car tu gardes un vrai git history sans polluer le repo code.
|
||||||
|
|
||||||
|
### Ce que l'admin web permettrait
|
||||||
|
|
||||||
|
- Édition inline du profil (nom, titre, bio)
|
||||||
|
- Ajout/édition/suppression de projets (drag-drop pour réordonner)
|
||||||
|
- Ajout d'une expérience pro
|
||||||
|
- Changement du thème de couleur (live preview)
|
||||||
|
- Toggle du mode "embauche disponible" (badge vert sur le site)
|
||||||
|
- Upload de nouvelles images (vers `/public/img/` via endpoint SSR)
|
||||||
|
- Export / import du JSON (backup)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. UX "paillettes dans les yeux" — direction créative
|
||||||
|
|
||||||
|
### Accueil
|
||||||
|
|
||||||
|
- **Hero 3D** avec @react-three/fiber : scène minimaliste type "disco ball" qui tourne lentement, sensible au mouvement de souris (parallax). Nod subtil à ton univers DJ.
|
||||||
|
- **Texte morphing** (GSAP SplitText) — "Dev" se transforme en "DJ" puis en "Dev" en boucle lente.
|
||||||
|
- **Curseur custom** qui suit la souris avec un léger lag, change de forme sur les liens.
|
||||||
|
- **Gradient animé** en arrière-plan type aurore boréale (shader ou CSS `conic-gradient` animé).
|
||||||
|
|
||||||
|
### Scroll
|
||||||
|
|
||||||
|
- **Lenis smooth scroll** partout.
|
||||||
|
- **Scroll-triggered animations** : sections qui révèlent leur contenu en cascade (fade + translate-y + blur-out).
|
||||||
|
- **Pin + horizontal scroll** sur la section Projets (GSAP ScrollTrigger, type showcase Awwwards).
|
||||||
|
- **Progress bar** en haut de page.
|
||||||
|
|
||||||
|
### Section skills
|
||||||
|
|
||||||
|
- Cards avec effet **hover 3D tilt** (vanilla-tilt ou React équivalent).
|
||||||
|
- Icônes SVG des technos avec **glow au survol**.
|
||||||
|
- Compteur de maîtrise animé (ring progress) au scroll.
|
||||||
|
|
||||||
|
### Timeline expérience
|
||||||
|
|
||||||
|
- Layout type **métro parisien** (une ligne verticale animée, stations = expériences).
|
||||||
|
- Cards qui s'ouvrent en accordéon au scroll.
|
||||||
|
|
||||||
|
### Projets
|
||||||
|
|
||||||
|
- Carousel horizontal pinned avec images **zoom parallax**.
|
||||||
|
- Badge techno avec micro-logos simple-icons colorés.
|
||||||
|
- Hover : overlay gradient + CTA "voir projet".
|
||||||
|
|
||||||
|
### Detail bonus
|
||||||
|
|
||||||
|
- **Mode "party"** (easter egg) : un bouton discret qui active un strobe + particles + glitch texts → clin d'œil DJ.
|
||||||
|
- **Konami code** → affiche ton set DJ en fond sonore discret.
|
||||||
|
- **Switch 3 thèmes** : Day, Night, Neon (le mode neon = pleine paillettes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Plan d'exécution
|
||||||
|
|
||||||
|
### Phase 0 — Prérequis (décision utilisateur)
|
||||||
|
|
||||||
|
- [ ] Valider le choix Astro vs Next.js vs Nuxt.
|
||||||
|
- [ ] Valider le niveau d'édition (SFTP / admin web / Gitea).
|
||||||
|
- [ ] Choisir nom de projet (`portfolio-v2`, `johanleroy-fr`, `portfolio-astro`, …).
|
||||||
|
- [ ] Palette : garder l'actuelle ou refaire ? (je peux proposer 3 palettes).
|
||||||
|
|
||||||
|
### Phase 1 — Bootstrap (1 jour)
|
||||||
|
|
||||||
|
- Scaffold Astro + TS strict + Tailwind 4 + React integration.
|
||||||
|
- Setup ESLint + Prettier + configs héritées de Techos (cohérence).
|
||||||
|
- Créer les 6 schémas Zod + JSON de contenu initial (repris du site actuel).
|
||||||
|
- Layout global + router fichier (`/`, `/formations`, `/experience`, `/projets`).
|
||||||
|
- Migrer `SeoService` vers un composant `<SEO>` Astro.
|
||||||
|
|
||||||
|
### Phase 2 — Contenu & structure (1–2 jours)
|
||||||
|
|
||||||
|
- Extraire tout le contenu des templates actuels vers les JSON.
|
||||||
|
- Migrer les PNG d'icônes → Iconify (Simple Icons + Lucide).
|
||||||
|
- Migrer les images projets/entreprises (optimisation avec `astro:assets`).
|
||||||
|
- Pages statiques sans animations pour valider la structure.
|
||||||
|
|
||||||
|
### Phase 3 — Animations & paillettes (2–3 jours)
|
||||||
|
|
||||||
|
- Intégration Lenis + configuration smooth scroll globale.
|
||||||
|
- Installation Framer Motion + GSAP + ScrollTrigger.
|
||||||
|
- Hero 3D @react-three/fiber.
|
||||||
|
- Scroll-triggered reveals sur chaque section.
|
||||||
|
- Curseur custom + cards 3D tilt + timeline métro.
|
||||||
|
- Carousel projets pinned horizontal.
|
||||||
|
- Thèmes Day / Night / Neon.
|
||||||
|
|
||||||
|
### Phase 4 — Admin d'édition (1–2 jours, optionnel selon niveau choisi)
|
||||||
|
|
||||||
|
- Endpoint Astro SSR `/api/content/:file` (GET/PUT).
|
||||||
|
- Protection par mot de passe + cookie httpOnly.
|
||||||
|
- Page `/admin` : formulaires dynamiques générés depuis les schémas Zod.
|
||||||
|
- Upload d'images.
|
||||||
|
- Export / import JSON.
|
||||||
|
- (Variante Gitea) : intégration Gitea API pour push auto.
|
||||||
|
|
||||||
|
### Phase 5 — Polish & deploy (1 jour)
|
||||||
|
|
||||||
|
- PWA (manifest + service worker).
|
||||||
|
- Audit Lighthouse (objectif 100/100/100/100).
|
||||||
|
- Sitemap auto + robots.txt.
|
||||||
|
- Meta tags Open Graph + Twitter Cards.
|
||||||
|
- Build production.
|
||||||
|
- Déploiement Plesk (remplacement `/dist` existant).
|
||||||
|
- Redirections 301 si URLs changent.
|
||||||
|
|
||||||
|
### Phase 6 — Extras plus tard
|
||||||
|
|
||||||
|
- Blog MDX (écriture d'articles techniques).
|
||||||
|
- Page contact avec vrai formulaire (via endpoint SSR ou Formspree).
|
||||||
|
- Section "Now" (what I'm up to, mis à jour mensuel).
|
||||||
|
- Stats GitHub/Gitea live (commits, streaks).
|
||||||
|
- Page `/jeux` ou `/bacs` pour expériences DJ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Estimation totale
|
||||||
|
|
||||||
|
| Phase | Charge |
|
||||||
|
|---|---|
|
||||||
|
| 0 — Décisions | 30 min |
|
||||||
|
| 1 — Bootstrap | 1 jour |
|
||||||
|
| 2 — Contenu & structure | 1–2 jours |
|
||||||
|
| 3 — Animations | 2–3 jours |
|
||||||
|
| 4 — Admin (optionnel) | 1–2 jours |
|
||||||
|
| 5 — Polish & deploy | 1 jour |
|
||||||
|
| **Total sans admin** | **5–7 jours** |
|
||||||
|
| **Total avec admin** | **6–9 jours** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Questions initiales — réponses validées ✅
|
||||||
|
|
||||||
|
| # | Question | Réponse |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Framework | ✅ **Astro 5** |
|
||||||
|
| 2 | Système d'édition | ✅ **Admin web** (option 2) |
|
||||||
|
| 3 | Palette / identité | ✅ **Refonte DEV/DJ/Neon** |
|
||||||
|
| 4 | Hébergement | ✅ **Plesk** (stay put) — ISR à creuser selon contraintes |
|
||||||
|
| 5 | Niveau "paillettes" | ✅ **Wow mais pro** (pas d'effets dans tous les sens) |
|
||||||
|
| 6 | Contenu à rafraîchir | ✅ **Ajouter projets récents** (sauf Beehelp = pro/NDA) |
|
||||||
|
| 7 | Nom du repo | ✅ `portfolio` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Setup technique dérivé des choix
|
||||||
|
|
||||||
|
| Élément | Valeur |
|
||||||
|
|---|---|
|
||||||
|
| Mode Astro | **Hybrid** — SSG pour pages publiques, SSR pour `/admin` et `/api` |
|
||||||
|
| Déploiement | **Plesk Node.js hosting** (même pattern que `techos_api`) |
|
||||||
|
| Persistance contenu | `/public/content/*.json` édités par endpoints SSR protégés |
|
||||||
|
| Auth admin | Mot de passe + cookie httpOnly (JWT ou session simple) |
|
||||||
|
| MAJ contenu en prod | Admin web → PUT JSON → **fetch runtime côté client, zéro rebuild** |
|
||||||
|
| ISR — note Plesk | Plesk = serveur Node permanent, on a l'équivalent d'ISR gratuit. Pas d'ISR Next.js-style mais re-fetch JSON runtime = effet identique |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Direction artistique — 3 palettes néon à trancher
|
||||||
|
|
||||||
|
### Option A — **Cyberpunk Dark** (blade-runner / tekno industrielle)
|
||||||
|
```
|
||||||
|
bg: #0A0A0F (noir profond)
|
||||||
|
accent 1: #00F0FF (cyan électrique)
|
||||||
|
accent 2: #FF2A6D (rose magenta)
|
||||||
|
accent 3: #D1F7FF (blanc bleuté)
|
||||||
|
texte: #E0E0FF
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B — **Rave / Miami** (90's, fun, coloré)
|
||||||
|
```
|
||||||
|
bg: #0D0221 (violet nuit)
|
||||||
|
accent 1: #FF00C8 (pink néon)
|
||||||
|
accent 2: #00FFC8 (mint laser)
|
||||||
|
accent 3: #FFD600 (jaune flash)
|
||||||
|
texte: #FFF8F0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option C — **Tech Minimal + 1 néon** ⭐ reco pro
|
||||||
|
```
|
||||||
|
bg: #0B0B0E (charbon)
|
||||||
|
surface: #16161D
|
||||||
|
accent: #39FF14 (vert néon strobe) — UN SEUL pop
|
||||||
|
texte: #EAEAEA
|
||||||
|
secondaire: #8A8F98
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reco :** Option C pour recruteurs (le plus pro), Option A si on assume la vibe DJ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Liste projets à afficher — proposition
|
||||||
|
|
||||||
|
| # | Projet | Statut | Action |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Techos** (asso hébergement) | Live `techos-asso.fr` | ✅ Inclure |
|
||||||
|
| 2 | **TrackSniff** | Privé (invitation) | ⚠️ À trancher |
|
||||||
|
| 3 | **Amarea Tattoo** | Client (studio tatouage) | ✅ Inclure |
|
||||||
|
| 4 | **Lidge** | Perso, auto-hébergé | ✅ Inclure |
|
||||||
|
| 5 | **Portfolio v2** (méta) | En cours | ✅ Classique circulaire |
|
||||||
|
| 6 | **MonVoisinGeek / LPLV** (anciens) | Live actuellement | ⚠️ Keep or drop ? |
|
||||||
|
| ❌ | **Beehelp** | Pro / NDA | ❌ Exclu (validé) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Questions finales — réponses validées ✅
|
||||||
|
|
||||||
|
| # | Question | Réponse |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **Palette** | ✅ **Option A — Cyberpunk Dark** (`#0A0A0F / #00F0FF / #FF2A6D / #D1F7FF / #E0E0FF`) |
|
||||||
|
| 2 | TrackSniff affiché ? | ❌ **Non** (privé, on n'affiche pas) |
|
||||||
|
| 3 | Portfolio v2 (self-reference) | ❌ **Non** |
|
||||||
|
| 4 | MonVoisinGeek / LPLV | ✅ **Garder** — mais marqués **"Projet fermé / archivé"**, infos présentes, liens morts |
|
||||||
|
| 5 | Projet supplémentaire | ✅ **Ajouter "Bot Discord KSauce"** |
|
||||||
|
| 6 | Nouvelle expérience/formation | — (à compléter au moment de migrer le contenu) |
|
||||||
|
|
||||||
|
### Liste finale des projets affichés
|
||||||
|
|
||||||
|
| # | Projet | État | Lien |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | **Techos** | Live | techos-asso.fr |
|
||||||
|
| 2 | **Amarea Tattoo** | Live | (à confirmer) |
|
||||||
|
| 3 | **Lidge** | Live perso | (à confirmer) |
|
||||||
|
| 4 | **Bot Discord KSauce** | (à préciser) | — |
|
||||||
|
| 5 | **MonVoisinGeek** | 🔒 Archivé | — |
|
||||||
|
| 6 | **LPLV** | 🔒 Archivé | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Plan d'implémentation détaillé — GO
|
||||||
|
|
||||||
|
**Répertoire cible :** `/home/johan/WebstormProjects/portfolio` (nouveau, à côté de l'ancien `Portfolio_angular`)
|
||||||
|
|
||||||
|
### Étape 1 — Bootstrap Astro hybrid ⬜
|
||||||
|
|
||||||
|
- [ ] `npm create astro@latest portfolio` (template `minimal`, TS strict)
|
||||||
|
- [ ] Installer intégrations : `@astrojs/react`, `@astrojs/tailwind`, `@astrojs/node` (adapter SSR)
|
||||||
|
- [ ] Configurer `astro.config.mjs` mode `hybrid` + adapter Node standalone (Plesk)
|
||||||
|
- [ ] Setup Prettier + ESLint (aligné sur Techos)
|
||||||
|
- [ ] `.gitignore`, `.editorconfig`, `README.md`
|
||||||
|
|
||||||
|
### Étape 2 — Design system Cyberpunk Dark ⬜
|
||||||
|
|
||||||
|
- [ ] `tailwind.config` + CSS custom properties (palette Option A)
|
||||||
|
- [ ] Typographies : **heading** = Space Grotesk / **body** = Inter / **mono accent** = JetBrains Mono
|
||||||
|
- [ ] Utilitaires `glow-cyan`, `glow-pink`, `neon-border`, `scanlines`, `noise-overlay`
|
||||||
|
- [ ] Cursor custom (composant React island)
|
||||||
|
- [ ] Dark only (pas de light mode — la vibe cyberpunk ne s'y prête pas)
|
||||||
|
|
||||||
|
### Étape 3 — Schémas Zod + contenu JSON ⬜
|
||||||
|
|
||||||
|
- [ ] `src/content/schemas/` : `profile.ts`, `skills.ts`, `experiences.ts`, `formations.ts`, `projects.ts`, `interests.ts`, `site.ts`
|
||||||
|
- [ ] `public/content/*.json` : contenu extrait des templates Angular actuels + MAJ
|
||||||
|
- [ ] `ContentService` (fetch runtime) côté client
|
||||||
|
- [ ] Validation Zod au parsing (garde-fou si JSON mal formé)
|
||||||
|
|
||||||
|
### Étape 4 — Pages publiques + layout ⬜
|
||||||
|
|
||||||
|
- [ ] Layout global : `Header` (nav + logo glitch), `Footer`, `CursorCustom`, `LenisProvider`
|
||||||
|
- [ ] `/` — Hero 3D disco-ball (R3F), scroll reveals, skills, centres d'intérêt
|
||||||
|
- [ ] `/formations` — Timeline métro verticale
|
||||||
|
- [ ] `/experience` — Timeline métro + cards accordéon (6 entreprises)
|
||||||
|
- [ ] `/projets` — Carousel pinned horizontal + badges techno Iconify
|
||||||
|
- [ ] `/404` — Page glitch 404
|
||||||
|
|
||||||
|
### Étape 5 — Animations paillettes ⬜
|
||||||
|
|
||||||
|
- [ ] Lenis smooth scroll global
|
||||||
|
- [ ] Framer Motion : reveals + micro-interactions
|
||||||
|
- [ ] GSAP + ScrollTrigger : timelines pinned
|
||||||
|
- [ ] Hero R3F : disco ball parallax souris
|
||||||
|
- [ ] SplitText : morphing "Dev / DJ / Dev"
|
||||||
|
- [ ] Cards skills : 3D tilt (hover)
|
||||||
|
- [ ] Gradient conic animé en bg
|
||||||
|
|
||||||
|
### Étape 6 — Admin SSR ⬜
|
||||||
|
|
||||||
|
- [ ] `/admin/login` — form password → cookie httpOnly
|
||||||
|
- [ ] Middleware `src/middleware.ts` : protection route `/admin/*`
|
||||||
|
- [ ] `/admin` — dashboard, liste des sections éditables
|
||||||
|
- [ ] `/admin/edit/[section]` — formulaires générés depuis Zod schemas
|
||||||
|
- [ ] `/api/content/[section]` — GET/PUT (lecture + écriture JSON serveur)
|
||||||
|
- [ ] `/api/upload` — upload images vers `public/img/`
|
||||||
|
- [ ] `/api/export` + `/api/import` — backup/restore JSON
|
||||||
|
|
||||||
|
### Étape 7 — SEO + PWA + déploiement ⬜
|
||||||
|
|
||||||
|
- [ ] Composant `<SEO />` (title, description, OG, canonical, Twitter)
|
||||||
|
- [ ] `sitemap.xml` auto
|
||||||
|
- [ ] `manifest.json` + service worker (PWA)
|
||||||
|
- [ ] Redirections 301 depuis anciennes URLs si nécessaire
|
||||||
|
- [ ] Build prod + test local
|
||||||
|
- [ ] Script déploiement Plesk Node (équivalent Techos API)
|
||||||
|
- [ ] Remplacement sur `johanleroy.fr`
|
||||||
|
|
||||||
|
### Étape 8 — Post-déploiement ⬜
|
||||||
|
|
||||||
|
- [ ] Audit Lighthouse (objectif 95+)
|
||||||
|
- [ ] Test admin web en prod
|
||||||
|
- [ ] Rédiger `docs/ADMIN.md` (comment éditer le contenu)
|
||||||
|
- [ ] Snapshot initial dans git du contenu actuel
|
||||||
|
- [ ] Mettre à jour `CLAUDE.md` du projet
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**GO ✅** — scaffold démarré.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. État d'implémentation (suivi)
|
||||||
|
|
||||||
|
| Étape | Status |
|
||||||
|
|---|---|
|
||||||
|
| 1 — Bootstrap Astro hybrid (React + Tailwind 4 + Node adapter) | ✅ |
|
||||||
|
| 2 — Design system Cyberpunk Dark (palette, glow, néon, fonts) | ✅ |
|
||||||
|
| 3 — Schémas Zod + JSON contenu extrait | ✅ |
|
||||||
|
| 4 — Layout + pages publiques (/, /formations, /experience, /projets, /404) | ✅ |
|
||||||
|
| 5 — Animations (Lenis, Framer Motion, R3F hero disco, morph, curseur) | ✅ |
|
||||||
|
| 6 — Admin SSR protégé + API CRUD JSON + upload | ✅ |
|
||||||
|
| 7 — SEO + build prod + workflow Gitea + README déploiement | ✅ |
|
||||||
|
|
||||||
|
### Livrables
|
||||||
|
|
||||||
|
- `/home/johan/WebstormProjects/portfolio/` (projet Astro 6 + React 19 + Tailwind 4)
|
||||||
|
- Build production OK → `dist/server/entry.mjs` (startup Plesk Node)
|
||||||
|
- Dev testé sur **http://localhost:3100**
|
||||||
|
- Workflow `.gitea/workflows/deploy.yml` déclenché sur push `main`
|
||||||
|
- Contenu projets mis à jour : Techos, Amarea Tattoo, Lidge, Bot Discord KSauce, LPLV (archivé), MonVoisinGeek (archivé)
|
||||||
|
|
||||||
|
### Prochaines étapes côté utilisateur
|
||||||
|
|
||||||
|
1. `cd ~/WebstormProjects/portfolio && npm run dev` → vérifier sur http://localhost:3100
|
||||||
|
2. `npm run hash:password -- monMotDePasse` → copier `ADMIN_PASSWORD_HASH` + `ADMIN_JWT_SECRET` dans un `.env` local
|
||||||
|
3. Tester `/admin/login` puis `/admin`
|
||||||
|
4. Initialiser git et pousser sur `main` vers Gitea
|
||||||
|
5. Configurer les secrets Gitea (`FTP_HOST`, `FTP_USER`, `FTP_PASSWORD`, `FTP_TARGET_DIR`)
|
||||||
|
6. Configurer Plesk Node.js : startup file `dist/server/entry.mjs`, variables d'env admin, Node 22
|
||||||
|
7. Pousser sur `main` → CI déploie → redémarrer l'app Node dans Plesk
|
||||||
374
docs/02-moodboard-freeparty.md
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
# Portfolio v2 — Moodboard "Studio Live / Free Party"
|
||||||
|
|
||||||
|
> Direction artistique : **tekno free party**. Vibe sound system / squat / rave illégale. Palette cyberpunk conservée mais **poussée en acid**, typographies plus brutes, vocabulaire underground, textures warehouse.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Vocabulaire / mots-clés du site
|
||||||
|
|
||||||
|
On oublie "cv", "profil", "vinyles", on skinne tout :
|
||||||
|
|
||||||
|
| Concept générique | Version free party |
|
||||||
|
|---|---|
|
||||||
|
| Portfolio | **Sound system** |
|
||||||
|
| Mes projets | **Crate / Skeuds** |
|
||||||
|
| Téléchargez mon CV | **Dubplate `.pdf`** |
|
||||||
|
| Expérience pro | **Setlist** / **Warehouse log** |
|
||||||
|
| Formations | **Learning stack** / **BPM log** |
|
||||||
|
| Compétences | **Rack** / **Patch bay** |
|
||||||
|
| Centres d'intérêt | **Off-decks** |
|
||||||
|
| Contact | **Open mic** / **Frequencies** |
|
||||||
|
| Chargement | `BOOTING SOUND SYSTEM…` |
|
||||||
|
| 404 | `SIGNAL LOST` / `NO SKEUD FOUND` |
|
||||||
|
| Dispo pour boulot | `ON AIR` (LED verte clignotante) |
|
||||||
|
|
||||||
|
Tu gardes le style "code + tekno" : classes `.mono`, tags `[BPM:174]`, `>> mix`, `//` partout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Palette ajustée (acid free party)
|
||||||
|
|
||||||
|
On garde le cyberpunk mais on pousse **l'acid** et on ajoute du **jaune strobe** pour l'énergie rave.
|
||||||
|
|
||||||
|
```
|
||||||
|
bg: #0A0A0F (noir warehouse)
|
||||||
|
surface: #111118
|
||||||
|
accent_cyan: #00F0FF (UV light principal)
|
||||||
|
accent_acid: #C6FF00 (⭐ NOUVEAU — jaune acid strobe pour accents forts)
|
||||||
|
accent_pink: #FF2A6D (magenta dubplate)
|
||||||
|
accent_red: #FF0040 (⭐ NOUVEAU — rouge LED clip)
|
||||||
|
texte: #E0E0FF
|
||||||
|
mute: #8A8FA8
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usages :**
|
||||||
|
- cyan = base UI (nav, borders)
|
||||||
|
- acid yellow = **CTA principal + "ON AIR"** (très rave flyer)
|
||||||
|
- pink/magenta = accents secondaires
|
||||||
|
- rouge LED = warnings, statut live, status en cours
|
||||||
|
- violet/ice → on réduit l'usage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Typographies
|
||||||
|
|
||||||
|
On pousse plus loin le contraste entre mono tech et display brut :
|
||||||
|
|
||||||
|
| Usage | Font | Pourquoi |
|
||||||
|
|---|---|---|
|
||||||
|
| Display / titres | **Space Grotesk** (gardé) | Sharp, tech, se tient |
|
||||||
|
| Body | **Inter** (gardé) | Lisible |
|
||||||
|
| Mono | **JetBrains Mono** (gardé) | Code |
|
||||||
|
| ⭐ Accent "rave" | **Array** ou **Neue Haas Grotesk Display Black** | Stencil bâton, façon flyer free party / affiche underground |
|
||||||
|
| ⭐ Étiquettes stickées | **Monument Extended** ou équivalent | Giga bold pour les dates, BPM, codes |
|
||||||
|
|
||||||
|
Suggestion alternative open source sur Google Fonts : **Bebas Neue** (stencil), **Anton** (poster), **Oswald** (condensé).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Éléments communs à tout le site
|
||||||
|
|
||||||
|
### 4.1 Header / nav = **console sound system**
|
||||||
|
|
||||||
|
- En haut, fine barre noire avec :
|
||||||
|
- **Logo** : "JL" stylisé rave (stencil) avec **LED verte qui clignote** à côté = "ON AIR"
|
||||||
|
- **Nav** : les items `/crate`, `/setlist`, `/bpm-log`, `/open-mic` (ou versions FR : `/skeuds`, `/setlist`, `/formations`, `/contact`)
|
||||||
|
- **Scroll progress bar** fine en néon cyan en bas du header (indique où on en est)
|
||||||
|
- Au clic d'un item → **LED s'allume**, petit son *clic* de fader (togglable, off par défaut)
|
||||||
|
|
||||||
|
### 4.2 Background ambient
|
||||||
|
|
||||||
|
Derrière tout le site, couches superposées très subtiles :
|
||||||
|
1. **Gradient noir → surface** de base
|
||||||
|
2. **Grain / noise** fin (bruit vidéo warehouse)
|
||||||
|
3. **2-3 spots UV** (radial cyan + magenta) qui se **déplacent lentement** selon le scroll (fake parallax light)
|
||||||
|
4. **Optional :** **strobe flash** très bref (20ms, opacité 10%) toutes les 30-60s pour la vibe rave. **Respecte `prefers-reduced-motion`**
|
||||||
|
|
||||||
|
### 4.3 Transition entre pages = **crossfade EQ**
|
||||||
|
|
||||||
|
Quand tu cliques sur un lien de nav :
|
||||||
|
1. L'écran actuel reçoit un **léger blur + fade out** (simule un low-pass EQ qui ferme)
|
||||||
|
2. Le nouveau page **slide in** avec son contenu qui reveal (fade in + text split)
|
||||||
|
3. Durée totale : ~600ms
|
||||||
|
4. Réalisation : **Astro View Transitions API** (native)
|
||||||
|
|
||||||
|
### 4.4 Scroll smooth = Lenis (déjà fait)
|
||||||
|
|
||||||
|
### 4.5 Curseur
|
||||||
|
|
||||||
|
- Curseur natif conservé
|
||||||
|
- **Sur les liens/boutons** : petit halo néon cyan/acid qui apparaît (au lieu d'un curseur custom qui dérange)
|
||||||
|
- **Boutons magnétiques** : le bouton se déplace légèrement vers le curseur à l'approche (spring physics, comme Vercel / Rauno)
|
||||||
|
|
||||||
|
### 4.6 Text reveals
|
||||||
|
|
||||||
|
- Tous les gros titres : **char-by-char reveal** avec effet blur → clear (Framer Motion SplitText)
|
||||||
|
- Sous-titres : **word-by-word** fade-up
|
||||||
|
- Trigger : `whileInView`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Page / (Home)
|
||||||
|
|
||||||
|
### 5.1 Hero
|
||||||
|
|
||||||
|
**Couche 1 (background fixe)** :
|
||||||
|
- Gradient mesh animé noir → charbon
|
||||||
|
- 3 spots UV lents
|
||||||
|
- **Ligne de waveform audio** centrée en bas du hero, qui pulse doucement (fake audio reactive avec math `sin`). Couleur cyan/acid, épaisseur fine.
|
||||||
|
|
||||||
|
**Couche 2 (texte)** :
|
||||||
|
- `> frequency: 174 BPM` (petit mono cyan tout en haut, stylé tag radio)
|
||||||
|
- **Titre** : `Johan Leroy` en **ultra large**, style poster free party. Le "Leroy" en **acid yellow** avec glow (au lieu de magenta). Effet **char-by-char reveal** au load.
|
||||||
|
- Sous-titre : `[ ` + **mot qui morph `dev → DJ → skeud-head → dev`** + ` ]` dans des brackets mono
|
||||||
|
- **Tagline acid** : _"code propre, sono sale"_ en italique mono
|
||||||
|
- **CTA principal** en **jaune acid** néon : `// grab the dubplate (cv.pdf)`
|
||||||
|
- **CTA ghost** : `>> browse the crate`
|
||||||
|
|
||||||
|
**Couche 3 (stickers / details) :**
|
||||||
|
- En haut à droite, petit **badge "ON AIR"** avec LED verte (animée)
|
||||||
|
- En bas à gauche, petit tag style flyer `NANTES FR · 2019→now`
|
||||||
|
- Stickers collés style "free party sticker pack" éparpillés (logos fictifs en SVG) — très légers, opacité 20%
|
||||||
|
|
||||||
|
### 5.2 Section Stack = **Sound system rack**
|
||||||
|
|
||||||
|
**Métaphore : un rack de studio / sono.** Un panneau sombre façon plaque acier brossé, avec **modules empilés** verticalement (comme des modules Eurorack).
|
||||||
|
|
||||||
|
Chaque module = une catégorie :
|
||||||
|
- En haut du module : **label LED** (LANGUAGES, FRAMEWORKS, DATABASES…)
|
||||||
|
- **VU meter vertical** à gauche qui bouge doucement (fake)
|
||||||
|
- **Pads / knobs** : chaque techno = un carré avec son icône + label, stylé "bouton pressoir"
|
||||||
|
- Au hover du pad → **pad s'enfonce** légèrement, LED cyan s'allume, **tooltip** apparaît `[ MASTERED ]` ou `[ KNOWS ]`
|
||||||
|
- **Indicateur BPM fictif** en haut à droite du rack : `BPM 174 · HARDTEK`
|
||||||
|
|
||||||
|
**Deux racks empilés :** primary (plus gros) et secondary (plus compact).
|
||||||
|
|
||||||
|
Effet paillette : le rack a une **jauge power** qui s'allume au scroll reveal.
|
||||||
|
|
||||||
|
### 5.3 Section Off-decks (centres d'intérêt)
|
||||||
|
|
||||||
|
Cards format **bande magnétique / cassette tape** :
|
||||||
|
- Chaque card = une mini-cassette stylisée avec étiquette
|
||||||
|
- Icône au centre, label en dessous façon marker
|
||||||
|
- Au hover : la bande **tourne** légèrement (CSS transform rotateY)
|
||||||
|
|
||||||
|
### 5.4 Footer
|
||||||
|
|
||||||
|
- Séparateur = **forme d'onde audio**
|
||||||
|
- 3 colonnes : Nav, Open mic (contact), Credits
|
||||||
|
- En bas, sticker collé `"// code propre, sono sale — JL 2026"` + **icônes sound system** (enceinte, platine, casque) en SVG
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Page /projets = **Crate / skeuds**
|
||||||
|
|
||||||
|
**Métaphore : un bac à disques.** Les projets sont des **skeuds (records 12")** qu'on feuillette.
|
||||||
|
|
||||||
|
### 6.1 Layout
|
||||||
|
|
||||||
|
- **Horizontal pinned scroll** (GSAP ScrollTrigger) : quand tu scrolles verticalement, les skeuds **défilent horizontalement** comme si tu feuilletais un bac
|
||||||
|
- Chaque skeud = une **pochette vinyle** carrée, grand format (300-400px)
|
||||||
|
|
||||||
|
### 6.2 Skeud / card projet
|
||||||
|
|
||||||
|
- **Face A** par défaut visible :
|
||||||
|
- Image projet en center crop
|
||||||
|
- Cercle noir de vinyle qui **dépasse** à droite (comme un disque qui sort de sa pochette)
|
||||||
|
- Étiquette centrale du vinyle (label fictif) avec nom du projet
|
||||||
|
- Stickers overlay :
|
||||||
|
- `[LIVE]` en acid yellow si actif
|
||||||
|
- `[ARCHIVED]` en gris si fermé
|
||||||
|
- `[DUBPLATE]` si privé
|
||||||
|
- BPM fictif `[180 BPM]`
|
||||||
|
- Au hover :
|
||||||
|
- Le disque **sort complètement** de la pochette (translation X)
|
||||||
|
- Le disque **tourne** sur son axe (33⅓ rpm fake, lent)
|
||||||
|
- La pochette s'incline légèrement (3D tilt)
|
||||||
|
- Overlay apparaît avec stack + lien "play"
|
||||||
|
- Au clic :
|
||||||
|
- Transition fluide vers site externe, ou vers page détail
|
||||||
|
|
||||||
|
### 6.3 Détails graphiques
|
||||||
|
|
||||||
|
- Le bac a une texture **bois / plastique** (SVG pattern)
|
||||||
|
- **Numéro d'ordre** en gros chiffres stencil derrière chaque card `#01`, `#02`
|
||||||
|
- Filtres en haut : `[ ALL ]` `[ LIVE ]` `[ ARCHIVED ]` en petits pads
|
||||||
|
- **Compteur** en bas : `05 / 06 skeuds`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Page /experience = **Setlist**
|
||||||
|
|
||||||
|
**Métaphore : la tracklist d'un set DJ.** Chaque job = une track dans le set, avec durée, BPM, style.
|
||||||
|
|
||||||
|
### 7.1 Layout : tracklist verticale stylisée
|
||||||
|
|
||||||
|
Header de la page :
|
||||||
|
```
|
||||||
|
> setlist.current = "10_years_of_code"
|
||||||
|
> duration: 10y 0m 0d
|
||||||
|
> tracks: 6
|
||||||
|
> status: ON AIR
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensuite, liste verticale où chaque expérience est **une ligne de tracklist** :
|
||||||
|
|
||||||
|
```
|
||||||
|
01 [NOW PLAYING] Almeria ████████████ Dev fullstack · 3y · [174 BPM]
|
||||||
|
02 CEREMA ████░░░░░░░░ Technicien · 2y · [160 BPM]
|
||||||
|
03 SNCF Réseau ████░░░░░░░░ Télécom · 2y · [140 BPM]
|
||||||
|
04 [INTRO] Novoferm █░░░░░░░░░░░ Stage · 7w · [120 BPM]
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Au clic/hover d'une ligne → elle **s'expand** comme un accordéon, révèle missions + stack. Animation : expansion + le "BPM" pulse.
|
||||||
|
|
||||||
|
### 7.2 Détails
|
||||||
|
|
||||||
|
- Police mono partout dans la liste
|
||||||
|
- Ligne "NOW PLAYING" avec **fond jaune acid subtil** et **LED rouge clignotante**
|
||||||
|
- Progress bars = **waveforms** ASCII stylisées
|
||||||
|
- **Timeline header** : ligne horizontale avec années `2018 — 2026`
|
||||||
|
|
||||||
|
### 7.3 Après la tracklist, 2 blocs :
|
||||||
|
|
||||||
|
- **Patch bay** = compétences techniques (pattern rack, avec câbles SVG animés qui relient les modules) — visuellement unique
|
||||||
|
- **Soft skills** = liste simple en mono
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Page /formations = **BPM Log**
|
||||||
|
|
||||||
|
**Métaphore : un journal de bord de crate-digging.** Tu as appris comme un DJ apprend — tu creuses, tu collectionnes, tu stockes.
|
||||||
|
|
||||||
|
### 8.1 Layout
|
||||||
|
|
||||||
|
Timeline verticale simple mais **affichage type carte d'entrée de rave** :
|
||||||
|
|
||||||
|
Chaque formation = une "carte" façon **entrance ticket** :
|
||||||
|
- Fond carte papier beige/gris (distinct des autres sections, ambiance "ticket froissé")
|
||||||
|
- En-tête : `FREE PARTY INVITATION — EDU SYSTEM`
|
||||||
|
- Date / lieu : `2023 — NANTES — CAMPUS ENI`
|
||||||
|
- Titre : `CONCEPTEUR DEV. D'APPLICATIONS`
|
||||||
|
- Description
|
||||||
|
- Footer sticker : `ADMIT ONE`
|
||||||
|
- Card **rotationnée légèrement** (tilt 2-3°) pour l'effet "jeté"
|
||||||
|
|
||||||
|
Au scroll, les tickets **glissent** depuis les côtés avec une légère rotation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Page 404 = **SIGNAL LOST**
|
||||||
|
|
||||||
|
- Fond noir profond, scanlines CRT visibles
|
||||||
|
- Gros texte central : `SIGNAL LOST` + glitch chromatic aberration
|
||||||
|
- Sous-texte mono : `frequency not found — rejoin the main feed`
|
||||||
|
- CTA acid : `[<<] back to the sound system`
|
||||||
|
- En fond, petit **static noise** animé (SVG avec noise turbulence)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Micro-détails / easter eggs
|
||||||
|
|
||||||
|
- **Konami code** (↑↑↓↓←→←→BA) → active le **party mode** : strobe 1s + glitch généralisé + un émoji skeud qui traverse l'écran
|
||||||
|
- **Clic logo 5x** → affiche un **DJ set secret** (player audio caché, ton son si tu veux)
|
||||||
|
- **Boot sequence** au premier load : brève overlay `BOOTING SOUND SYSTEM… [OK]` avant de révéler le hero
|
||||||
|
- **Hover sur un lien** : léger *tick* (clic platine, togglable)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Plan d'implémentation ordonné
|
||||||
|
|
||||||
|
1. **Design system v2** — ajouter palette acid (`--color-acid: #C6FF00`, `--color-red: #FF0040`), ajouter font stencil (Anton ou Bebas Neue)
|
||||||
|
2. **View Transitions** activer côté Astro + transitions personnalisées entre pages
|
||||||
|
3. **Header skinné** — LED ON AIR, scroll progress bar, logo stencil
|
||||||
|
4. **Hero v2** — waveform SVG animée, text reveal char-by-char, tagline, stickers
|
||||||
|
5. **Sound system rack** (nouvelle UI skills) — remplace la grille actuelle
|
||||||
|
6. **Magnetic buttons + text reveals** — utilitaires globaux
|
||||||
|
7. **Crate / skeuds** (nouvelle UI projets) — horizontal pinned scroll, vinyle qui sort
|
||||||
|
8. **Setlist** (nouvelle UI experience) — tracklist, now playing, accordéons
|
||||||
|
9. **Patch bay** (nouveau bloc expérience) — rack + câbles SVG animés
|
||||||
|
10. **BPM log** (nouvelle UI formations) — tickets rave tiltés
|
||||||
|
11. **Footer skinné** — forme d'onde, stickers
|
||||||
|
12. **404 signal lost**
|
||||||
|
13. **Micro-interactions** — sons togglables, Konami, boot sequence
|
||||||
|
14. **Reduced motion** — vérifier tout se dégrade bien
|
||||||
|
15. **Perf** — audit Lighthouse
|
||||||
|
|
||||||
|
**Effort total :** 4-5 jours de dev concentré. Livrable par blocs testables.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Risques & arbitrages
|
||||||
|
|
||||||
|
- **Risque kitsch :** si on en met trop, ça devient caricatural. Ligne de crête : **1 gimmick fort par section max**, le reste sobre.
|
||||||
|
- **Risque perf :** shader + vinyles 3D + scroll pinned = lourd. On reste sur du CSS/SVG/Canvas 2D léger, pas de WebGL sauf si nécessaire.
|
||||||
|
- **Risque recruteur :** certains vieux recruteurs ne comprendront pas la métaphore. Contre-mesure : **vocabulaire bilingue** (techos + jargon), CV toujours accessible en 1 clic.
|
||||||
|
- **Risque audio :** si on ajoute vraiment des sons, **off par défaut**, toggle visible. Jamais auto-play.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Avant le GO : questions pour toi
|
||||||
|
|
||||||
|
1. **Acid yellow `#C6FF00`** comme accent principal CTA, tu valides ? (tu peux aussi choisir `#D6FF00` plus doux)
|
||||||
|
2. **Font stencil** : Bebas Neue (gratuite, moderne) / Anton / Array (payante mais ultime) ?
|
||||||
|
3. **Sons** : on les implémente dès le début (togglables, off) ou on les laisse pour plus tard ?
|
||||||
|
4. **Boot sequence** au premier load : oui ou cringe ?
|
||||||
|
5. **Vocabulaire** : on garde bilingue (FR grand public + jargon en sous-texte), ou full jargon free party ?
|
||||||
|
6. **Horizontal pinned scroll** pour les projets : oui (plus wow, mais mobile = fallback grid) / non (grid partout) ?
|
||||||
|
7. Tu veux **rester sur cette doc** comme référence pendant l'implémentation ou tu veux qu'on épure avant ?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Dis-moi tes choix + "GO" et je commence à skinner, par étapes (je te montre après chaque bloc).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Décisions validées ✅
|
||||||
|
|
||||||
|
| # | Question | Réponse validée |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Acid yellow `#C6FF00` en accent principal | ✅ Validé |
|
||||||
|
| 2 | Font stencil | ✅ **Bebas Neue** (Google Fonts, gratuite, moderne) |
|
||||||
|
| 3 | Sons (clic fader, tick platine) togglables off par défaut | ✅ Intégrés dès le début, **OFF par défaut**, toggle visible |
|
||||||
|
| 4 | Boot sequence au premier load | ✅ **On tente** — ajustable si cringe après retour |
|
||||||
|
| 5 | Vocabulaire | ✅ **FR grand public** — **rester pro**. Nav et URLs standards (`/projets`, `/formations`, `/experience`). Les skins free party **dressent** le site en accents déco (sous-titres mono `// crate v1.0`, stickers, BPM fictifs, `[NOW PLAYING]`, etc.) sans bloquer le recruteur |
|
||||||
|
| 6 | Horizontal pinned scroll projets | ✅ **OUI** — avec **fallback grid verticale sur mobile** (breakpoint `< md`) |
|
||||||
|
| 7 | Doc de référence | ✅ On la garde en référence |
|
||||||
|
|
||||||
|
### Ligne éditoriale affinée
|
||||||
|
|
||||||
|
- **Structure UX** = pro et accessible (nav FR, URLs lisibles, CTA clairs)
|
||||||
|
- **Habillage** = free party / studio (palette acid, stencil, stickers, BPM, waveforms, vinyles)
|
||||||
|
- **Sons** = **toggle** visible dans le header (icône speaker), off à l'arrivée
|
||||||
|
- **Boot sequence** = rapide (1.2s max), skippable en cliquant, n'apparaît **qu'au premier load** (stocké en `sessionStorage`), plus jamais en retour de page
|
||||||
|
- **Mobile** = fallback propre pour chaque wow-feature :
|
||||||
|
- Horizontal pinned scroll → grid verticale
|
||||||
|
- Magnetic buttons → classic hover
|
||||||
|
- Cursor halos → désactivés
|
||||||
|
- Spots UV parallax → statiques
|
||||||
|
|
||||||
|
### Plan d'exécution — ordre de livraison
|
||||||
|
|
||||||
|
| Bloc | Livrables | Effort |
|
||||||
|
|---|---|---|
|
||||||
|
| **0. Design system v2** | Palette acid (`--color-acid`, `--color-red`) + Bebas Neue chargée + utilities `glow-acid`, `stencil`, `sticker` | 0.5j |
|
||||||
|
| **1. View Transitions + transitions de page** | Crossfade EQ global entre pages | 0.25j |
|
||||||
|
| **2. Header v2** | Logo stencil "JL" + LED ON AIR + scroll progress bar + toggle sons | 0.5j |
|
||||||
|
| **3. Background ambient** | Gradient mesh + grain + 2 spots UV suivant le scroll | 0.5j |
|
||||||
|
| **4. Hero v2 + boot sequence** | Waveform, text reveal, tagline, stickers, CTA acid + boot 1re visite | 1j |
|
||||||
|
| **5. Sound system rack (skills)** | Remplace les grilles SkillCard actuelles | 1j |
|
||||||
|
| **6. Magnetic buttons + text reveals globaux** | Utilitaires réutilisables | 0.5j |
|
||||||
|
| **7. Crate (projets)** | Pinned horizontal scroll + skeuds qui sortent + fallback grid mobile | 1j |
|
||||||
|
| **8. Setlist (expérience)** | Tracklist, NOW PLAYING, accordéons, BPM | 1j |
|
||||||
|
| **9. Patch bay (compétences pro)** | Rack + câbles SVG animés | 0.5j |
|
||||||
|
| **10. BPM log (formations)** | Tickets rave tiltés | 0.5j |
|
||||||
|
| **11. Footer skinné + 404 Signal Lost** | Waveform séparateur, stickers, page 404 | 0.5j |
|
||||||
|
| **12. Easter eggs** | Konami, 5× logo, sons | 0.25j |
|
||||||
|
| **13. Audit perf + reduced motion** | Lighthouse, a11y | 0.5j |
|
||||||
|
|
||||||
|
**Total :** ~8 jours de dev concentré, livrables testables bloc par bloc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. GO ✅ — implémentation démarre au bloc 0
|
||||||
32
docs/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Docs — Portfolio v2
|
||||||
|
|
||||||
|
Documents de conception et décisions prises pendant la refonte du portfolio.
|
||||||
|
|
||||||
|
## Sommaire
|
||||||
|
|
||||||
|
| # | Fichier | Rôle |
|
||||||
|
|---|---|---|
|
||||||
|
| 01 | [Analyse & plan](./01-analyse-et-plan.md) | Audit de l'ancien site Angular + choix Astro hybrid + système d'édition JSON + stack déploiement Plesk Node.js |
|
||||||
|
| 02 | [Moodboard Free Party](./02-moodboard-freeparty.md) | Direction artistique "Studio Live / tekno free party" : palette acid, typos, vocabulaire, déclinaison page par page, plan d'implémentation 14 blocs |
|
||||||
|
|
||||||
|
## Choix principaux validés
|
||||||
|
|
||||||
|
- **Framework** : Astro 6 hybrid (SSG public + SSR `/admin` et `/api`)
|
||||||
|
- **Hébergement** : Plesk Node.js (startup `dist/server/entry.mjs`)
|
||||||
|
- **Contenu** : `public/content/*.json` éditables via `/admin`, runtime fetch (zéro rebuild)
|
||||||
|
- **Direction artistique** : Cyberpunk Dark + skin Free Party
|
||||||
|
- Palette : `#0A0A0F` / `#00F0FF` cyan / `#C6FF00` acid / `#FF2A6D` magenta / `#FF0040` LED red
|
||||||
|
- Typos : Inter, Space Grotesk, JetBrains Mono, Bebas Neue (stencil)
|
||||||
|
- **Animations** : Lenis smooth scroll, Framer Motion, View Transitions API, GSAP scroll-pinned manuel, islands React
|
||||||
|
|
||||||
|
## Structure des 14 blocs implémentés
|
||||||
|
|
||||||
|
Voir [02-moodboard-freeparty.md §11](./02-moodboard-freeparty.md) pour la liste ordonnée et [14](./02-moodboard-freeparty.md) pour le plan d'exécution détaillé.
|
||||||
|
|
||||||
|
## Racines & contraintes
|
||||||
|
|
||||||
|
- **Pro avant tout** : nav FR grand public, URLs standards, CV 1 clic
|
||||||
|
- **Jargon free party = déco** : stickers, BPM fictifs, LED, stencil — jamais bloquant
|
||||||
|
- **Responsive** : fallbacks propres pour chaque effet wow (pinned horizontal, magnetic, UV parallax)
|
||||||
|
- **A11y** : respect de `prefers-reduced-motion` partout
|
||||||
|
- **Sécurité** : admin JWT cookie httpOnly, middleware protégeant `/admin/*` et `/api/content/*`
|
||||||
7885
package-lock.json
generated
Normal file
46
package.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "portfolio",
|
||||||
|
"type": "module",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"start": "HOST=0.0.0.0 PORT=3100 node ./dist/server/entry.mjs",
|
||||||
|
"astro": "astro",
|
||||||
|
"check": "astro check",
|
||||||
|
"hash:password": "node scripts/hash-password.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/node": "^10.0.5",
|
||||||
|
"@astrojs/react": "^5.0.3",
|
||||||
|
"@iconify-icon/react": "^3.0.3",
|
||||||
|
"@react-three/drei": "^10.7.7",
|
||||||
|
"@react-three/fiber": "^9.6.0",
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"astro": "^6.1.8",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
|
"gsap": "^3.15.0",
|
||||||
|
"jose": "^6.2.2",
|
||||||
|
"lenis": "^1.3.23",
|
||||||
|
"lucide-react": "^1.8.0",
|
||||||
|
"react": "^19.2.5",
|
||||||
|
"react-dom": "^19.2.5",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"three": "^0.184.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@astrojs/check": "^0.9.8",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
|
"@types/three": "^0.184.0",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/assets/cv.pdf
Normal file
116
public/content/experiences.json
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "almeria",
|
||||||
|
"company": "Almeria",
|
||||||
|
"logo": "/img/company/almeria.png",
|
||||||
|
"role": "Développeur",
|
||||||
|
"period": "Depuis 2023",
|
||||||
|
"duration": "3 ans",
|
||||||
|
"description": "Conception et développement d'applications web et de logiciels d'intégration ERP.",
|
||||||
|
"missions": [
|
||||||
|
"Analyse, conception et développement d'applications web",
|
||||||
|
"Création de logiciels d'intégration ERP",
|
||||||
|
"Maintenance et évolution des applications existantes"
|
||||||
|
],
|
||||||
|
"stack": ["Angular", "Express.js", "NestJS", "SQL Server", "MariaDB"],
|
||||||
|
"type": "cdi",
|
||||||
|
"current": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cerema",
|
||||||
|
"company": "CEREMA",
|
||||||
|
"logo": "/img/company/cerema.png",
|
||||||
|
"role": "Technicien évaluation mesure trafic & mobilité",
|
||||||
|
"period": "2021 - 2023",
|
||||||
|
"duration": "2 ans",
|
||||||
|
"description": "Spécialisé dans l'installation de capteurs et l'analyse de données de mobilité.",
|
||||||
|
"missions": [
|
||||||
|
"Installation de capteurs provisoires dans le secteur mobilité",
|
||||||
|
"Optimisation des données dans l'innovation d'outils numériques",
|
||||||
|
"Analyse et traitement des données de trafic et mobilité"
|
||||||
|
],
|
||||||
|
"stack": ["Analyse de données", "PHP", "MariaDB"],
|
||||||
|
"type": "cdd",
|
||||||
|
"current": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sncf",
|
||||||
|
"company": "Réseau SNCF",
|
||||||
|
"logo": "/img/company/sncf.png",
|
||||||
|
"role": "Agent télécom",
|
||||||
|
"period": "2019 - 2021",
|
||||||
|
"duration": "2 ans",
|
||||||
|
"description": "Maintenance des équipements de sonorisation, d'affichage et de téléphonie sur le réseau ferroviaire.",
|
||||||
|
"missions": [
|
||||||
|
"Maintenance préventive et corrective des équipements",
|
||||||
|
"Installation et réparation des systèmes d'affichage en gare",
|
||||||
|
"Gestion des équipements téléphoniques sur les voies ferrées"
|
||||||
|
],
|
||||||
|
"stack": ["Télécommunications", "Maintenance", "Équipements audio", "Affichage dynamique"],
|
||||||
|
"type": "cdi",
|
||||||
|
"current": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "novoferm",
|
||||||
|
"company": "Novoferm",
|
||||||
|
"logo": "/img/company/novoferm.png",
|
||||||
|
"role": "Stage développeur",
|
||||||
|
"period": "2019",
|
||||||
|
"duration": "7 semaines",
|
||||||
|
"description": "Stage de développement d'une application web qui permet de récupérer des fichiers stockés dans SharePoint. Réparation et configuration de PC, imprimantes et téléphones.",
|
||||||
|
"missions": [],
|
||||||
|
"stack": [],
|
||||||
|
"type": "stage",
|
||||||
|
"current": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pivotpoint",
|
||||||
|
"company": "Pivot Point",
|
||||||
|
"logo": "/img/company/pivotpoint.png",
|
||||||
|
"role": "Stage développeur",
|
||||||
|
"period": "2018",
|
||||||
|
"duration": "1 semaine",
|
||||||
|
"description": "Stage de développement d'un site internet en PHP.",
|
||||||
|
"missions": [],
|
||||||
|
"stack": ["PHP"],
|
||||||
|
"type": "stage",
|
||||||
|
"current": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "actemium",
|
||||||
|
"company": "Actemium",
|
||||||
|
"logo": "/img/company/actemium.png",
|
||||||
|
"role": "Stage développeur",
|
||||||
|
"period": "2018",
|
||||||
|
"duration": "1 semaine",
|
||||||
|
"description": "Stage de développement d'applications Windows d'automatisation.",
|
||||||
|
"missions": [],
|
||||||
|
"stack": [],
|
||||||
|
"type": "stage",
|
||||||
|
"current": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"skills": {
|
||||||
|
"technical": [
|
||||||
|
"Développement Frontend",
|
||||||
|
"Développement Backend",
|
||||||
|
"Analyse et traitement de données",
|
||||||
|
"Mise en production",
|
||||||
|
"Analyse de base de données",
|
||||||
|
"Modélisation UML",
|
||||||
|
"Maquettage",
|
||||||
|
"Sécurité"
|
||||||
|
],
|
||||||
|
"soft": [
|
||||||
|
"Gestion de projets",
|
||||||
|
"Travail en équipe",
|
||||||
|
"Analyse de projets",
|
||||||
|
"Communication",
|
||||||
|
"Autonomie",
|
||||||
|
"Organisation",
|
||||||
|
"Adaptation",
|
||||||
|
"Veille technologique"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
35
public/content/formations.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "cda-eni",
|
||||||
|
"title": "Concepteur Développeur d'Applications",
|
||||||
|
"school": "Campus ENI Nantes",
|
||||||
|
"schoolUrl": "https://www.eni-ecole.fr",
|
||||||
|
"period": "Depuis 2023",
|
||||||
|
"description": "Formation sur la conception et le développement d'applications web et mobiles. Acquisition des compétences en architecture logicielle et gestion de projets informatiques."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bts-sn",
|
||||||
|
"title": "BTS Systèmes Numériques",
|
||||||
|
"school": "Campus Saint Félix La Salle Nantes",
|
||||||
|
"schoolUrl": "https://stfelixlasalle.fr",
|
||||||
|
"period": "2021 - 2023",
|
||||||
|
"description": "Formation technique supérieure en systèmes électroniques et informatiques, avec une spécialisation en informatique industrielle et en réseaux."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bac-pro-sn",
|
||||||
|
"title": "BAC PRO Systèmes Numériques",
|
||||||
|
"school": "Campus Saint Félix La Salle Nantes",
|
||||||
|
"schoolUrl": "https://stfelixlasalle.fr",
|
||||||
|
"period": "2018 - 2021",
|
||||||
|
"description": "Formation en systèmes électroniques et réseaux informatiques. Apprentissage des bases de l'installation et de la maintenance de systèmes numériques."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "brevet",
|
||||||
|
"title": "Diplôme National du Brevet",
|
||||||
|
"school": "Ensemble scolaire Saint Père en Retz",
|
||||||
|
"schoolUrl": "https://www.ensemble-scolaire-saint-pere.fr",
|
||||||
|
"period": "Avant 2018"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
8
public/content/interests.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{ "id": "gaming", "label": "Jeux vidéo", "icon": "lucide:gamepad-2", "color": "violet" },
|
||||||
|
{ "id": "movies", "label": "Films & Séries", "icon": "lucide:film", "color": "cyan" },
|
||||||
|
{ "id": "dj", "label": "DJing / Tekno", "icon": "lucide:disc-3", "color": "magenta" },
|
||||||
|
{ "id": "side", "label": "Projets personnels", "icon": "lucide:rocket", "color": "ice" }
|
||||||
|
]
|
||||||
|
}
|
||||||
34
public/content/profile.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "Johan Leroy",
|
||||||
|
"title": "Développeur Fullstack",
|
||||||
|
"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.",
|
||||||
|
"available": true,
|
||||||
|
"availableLabel": "Ouvert aux opportunités",
|
||||||
|
"hero": {
|
||||||
|
"photo": "/img/pp/pp3.png",
|
||||||
|
"photos": ["/img/pp/pp1.png", "/img/pp/pp2.png", "/img/pp/pp3.png"],
|
||||||
|
"roles": ["Dev", "DJ", "Dev"],
|
||||||
|
"cta": [
|
||||||
|
{
|
||||||
|
"label": "Télécharger mon CV",
|
||||||
|
"href": "/assets/cv.pdf",
|
||||||
|
"variant": "primary",
|
||||||
|
"external": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Voir mes projets",
|
||||||
|
"href": "/projets",
|
||||||
|
"variant": "ghost",
|
||||||
|
"external": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"socials": {
|
||||||
|
"email": "contact@johanleroy.fr",
|
||||||
|
"github": "https://github.com/JohanLeroy",
|
||||||
|
"gitea": "https://git.lidge.fr/johanleroy",
|
||||||
|
"website": "https://johanleroy.fr"
|
||||||
|
},
|
||||||
|
"cv": "/assets/cv.pdf"
|
||||||
|
}
|
||||||
71
public/content/projects.json
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "techos",
|
||||||
|
"title": "Techos",
|
||||||
|
"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.",
|
||||||
|
"image": "/img/projet/techos.png",
|
||||||
|
"category": "web",
|
||||||
|
"status": "live",
|
||||||
|
"url": "https://techos-asso.fr",
|
||||||
|
"stack": ["Next.js 16", "React 19", "NestJS 11", "TailwindCSS 4", "shadcn/ui", "Redis", "WHMCS API"],
|
||||||
|
"featured": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "amarea",
|
||||||
|
"title": "Amarea Tattoo",
|
||||||
|
"subtitle": "Site du studio de tatouage Amarea",
|
||||||
|
"description": "Site vitrine et gestion pour un studio de tatouage. Architecture découplée SPA + REST API avec gestion de médias.",
|
||||||
|
"image": "",
|
||||||
|
"category": "web",
|
||||||
|
"status": "live",
|
||||||
|
"stack": ["Vue 3", "Vite", "NestJS 11", "TypeORM", "MariaDB", "Tailwind 4"],
|
||||||
|
"featured": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lidge",
|
||||||
|
"title": "Lidge",
|
||||||
|
"subtitle": "Portail abonnements services auto-hébergés",
|
||||||
|
"description": "Plateforme personnelle pour gérer les abonnements à mes services auto-hébergés (Jellyfin, Cloud, Vault, Mail, Messenger, Git…) avec SSO, tickets et workflow d'accès.",
|
||||||
|
"image": "",
|
||||||
|
"category": "web",
|
||||||
|
"status": "live",
|
||||||
|
"stack": ["Nuxt 3", "NestJS 11", "TypeORM", "MariaDB", "JWT", "Nodemailer"],
|
||||||
|
"featured": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ksauce-bot",
|
||||||
|
"title": "Bot Discord KSauce",
|
||||||
|
"subtitle": "Bot communautaire Discord",
|
||||||
|
"description": "Bot Discord développé pour la communauté KSauce. Gestion de commandes, modération et fonctionnalités sur-mesure.",
|
||||||
|
"image": "",
|
||||||
|
"category": "bot",
|
||||||
|
"status": "live",
|
||||||
|
"stack": ["Node.js", "TypeScript", "Discord.js"],
|
||||||
|
"featured": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lplv",
|
||||||
|
"title": "Liberty Pour La Vie",
|
||||||
|
"subtitle": "Site associatif (archivé)",
|
||||||
|
"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",
|
||||||
|
"category": "web",
|
||||||
|
"status": "archived",
|
||||||
|
"stack": ["Java Spring Boot", "Thymeleaf", "Bootstrap", "MariaDB"],
|
||||||
|
"featured": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "monvoisingeek",
|
||||||
|
"title": "Mon Voisin Geek",
|
||||||
|
"subtitle": "Site vitrine (archivé)",
|
||||||
|
"description": "Développement d'un site web vitrine simple pour Mon Voisin Geek. Projet fermé, plus en ligne.",
|
||||||
|
"image": "/img/projet/monvoisingeek.png",
|
||||||
|
"category": "web",
|
||||||
|
"status": "archived",
|
||||||
|
"stack": ["Angular", "Tailwind CSS", "Express.js", "MariaDB"],
|
||||||
|
"featured": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
26
public/content/site.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"title": "Johan Leroy — Portfolio",
|
||||||
|
"description": "Développeur fullstack — NestJS, Nuxt, Next.js, Vue. Dev le jour, DJ la nuit.",
|
||||||
|
"url": "https://johanleroy.fr",
|
||||||
|
"keywords": [
|
||||||
|
"johan leroy",
|
||||||
|
"développeur fullstack",
|
||||||
|
"nestjs",
|
||||||
|
"nuxt",
|
||||||
|
"nextjs",
|
||||||
|
"vue",
|
||||||
|
"angular",
|
||||||
|
"tailwindcss",
|
||||||
|
"portfolio",
|
||||||
|
"développeur web",
|
||||||
|
"freelance",
|
||||||
|
"nantes"
|
||||||
|
],
|
||||||
|
"ogImage": "/img/logo/android-chrome-512x512.png",
|
||||||
|
"features": {
|
||||||
|
"hero3D": false,
|
||||||
|
"smoothScroll": true,
|
||||||
|
"customCursor": false,
|
||||||
|
"partyMode": true
|
||||||
|
}
|
||||||
|
}
|
||||||
113
public/content/skills.json
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
{
|
||||||
|
"primary": [
|
||||||
|
{
|
||||||
|
"id": "languages",
|
||||||
|
"label": "Langages",
|
||||||
|
"items": [
|
||||||
|
{ "name": "TypeScript", "icon": "simple-icons:typescript", "level": "primary" },
|
||||||
|
{ "name": "JavaScript", "icon": "simple-icons:javascript", "level": "primary" },
|
||||||
|
{ "name": "Java", "icon": "simple-icons:openjdk", "level": "primary" },
|
||||||
|
{ "name": "Python", "icon": "simple-icons:python", "level": "primary" },
|
||||||
|
{ "name": "PHP", "icon": "simple-icons:php", "level": "primary" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "frameworks",
|
||||||
|
"label": "Frameworks",
|
||||||
|
"items": [
|
||||||
|
{ "name": "NestJS", "icon": "simple-icons:nestjs", "level": "primary" },
|
||||||
|
{ "name": "Nuxt", "icon": "simple-icons:nuxt", "level": "primary" },
|
||||||
|
{ "name": "Next.js", "icon": "simple-icons:nextdotjs", "level": "primary" },
|
||||||
|
{ "name": "Vue.js", "icon": "simple-icons:vuedotjs", "level": "primary" },
|
||||||
|
{ "name": "Angular", "icon": "simple-icons:angular", "level": "primary" },
|
||||||
|
{ "name": "Tailwind CSS", "icon": "simple-icons:tailwindcss", "level": "primary" },
|
||||||
|
{ "name": "Astro", "icon": "simple-icons:astro", "level": "primary" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "databases",
|
||||||
|
"label": "Bases de données",
|
||||||
|
"items": [
|
||||||
|
{ "name": "MariaDB", "icon": "simple-icons:mariadb", "level": "primary" },
|
||||||
|
{ "name": "MySQL", "icon": "simple-icons:mysql", "level": "primary" },
|
||||||
|
{ "name": "PostgreSQL", "icon": "simple-icons:postgresql", "level": "primary" },
|
||||||
|
{ "name": "Redis", "icon": "simple-icons:redis", "level": "primary" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tools",
|
||||||
|
"label": "Outils & DevOps",
|
||||||
|
"items": [
|
||||||
|
{ "name": "Docker", "icon": "simple-icons:docker", "level": "primary" },
|
||||||
|
{ "name": "Git", "icon": "simple-icons:git", "level": "primary" },
|
||||||
|
{ "name": "AWS", "icon": "simple-icons:amazonwebservices", "level": "primary" },
|
||||||
|
{ "name": "Terraform", "icon": "simple-icons:terraform", "level": "primary" },
|
||||||
|
{ "name": "NGINX", "icon": "simple-icons:nginx", "level": "primary" },
|
||||||
|
{ "name": "Plesk", "icon": "simple-icons:plesk", "level": "primary" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ide",
|
||||||
|
"label": "IDE & Éditeurs",
|
||||||
|
"items": [
|
||||||
|
{ "name": "WebStorm", "icon": "simple-icons:webstorm", "level": "primary" },
|
||||||
|
{ "name": "PhpStorm", "icon": "simple-icons:phpstorm", "level": "primary" },
|
||||||
|
{ "name": "PyCharm", "icon": "simple-icons:pycharm", "level": "primary" },
|
||||||
|
{ "name": "Claude Code", "icon": "simple-icons:claude", "level": "primary" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "os",
|
||||||
|
"label": "Systèmes",
|
||||||
|
"items": [
|
||||||
|
{ "name": "Debian", "icon": "simple-icons:debian", "level": "primary" },
|
||||||
|
{ "name": "Ubuntu", "icon": "simple-icons:ubuntu", "level": "primary" },
|
||||||
|
{ "name": "macOS", "icon": "simple-icons:macos", "level": "primary" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"secondary": [
|
||||||
|
{
|
||||||
|
"id": "languages-2",
|
||||||
|
"label": "Autres langages",
|
||||||
|
"items": [
|
||||||
|
{ "name": "C#", "icon": "simple-icons:dotnet", "level": "secondary" },
|
||||||
|
{ "name": "C++", "icon": "simple-icons:cplusplus", "level": "secondary" },
|
||||||
|
{ "name": "Dart", "icon": "simple-icons:dart", "level": "secondary" },
|
||||||
|
{ "name": "Kotlin", "icon": "simple-icons:kotlin", "level": "secondary" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "frameworks-2",
|
||||||
|
"label": "Autres frameworks",
|
||||||
|
"items": [
|
||||||
|
{ "name": "Spring", "icon": "simple-icons:spring", "level": "secondary" },
|
||||||
|
{ "name": "Symfony", "icon": "simple-icons:symfony", "level": "secondary" },
|
||||||
|
{ "name": "Express.js", "icon": "simple-icons:express", "level": "secondary" },
|
||||||
|
{ "name": "FastAPI", "icon": "simple-icons:fastapi", "level": "secondary" },
|
||||||
|
{ "name": "Flutter", "icon": "simple-icons:flutter", "level": "secondary" },
|
||||||
|
{ "name": "GraphQL", "icon": "simple-icons:graphql", "level": "secondary" },
|
||||||
|
{ "name": "Vuetify", "icon": "simple-icons:vuetify", "level": "secondary" },
|
||||||
|
{ "name": "Bootstrap", "icon": "simple-icons:bootstrap", "level": "secondary" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "databases-2",
|
||||||
|
"label": "Autres bases",
|
||||||
|
"items": [
|
||||||
|
{ "name": "MongoDB", "icon": "simple-icons:mongodb", "level": "secondary" },
|
||||||
|
{ "name": "SQLite", "icon": "simple-icons:sqlite", "level": "secondary" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cloud",
|
||||||
|
"label": "Cloud & services",
|
||||||
|
"items": [
|
||||||
|
{ "name": "Firebase", "icon": "simple-icons:firebase", "level": "secondary" },
|
||||||
|
{ "name": "Swagger", "icon": "simple-icons:swagger", "level": "secondary" },
|
||||||
|
{ "name": "Postman", "icon": "simple-icons:postman", "level": "secondary" },
|
||||||
|
{ "name": "Stripe", "icon": "simple-icons:stripe", "level": "secondary" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
36
public/favicon.svg
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<defs>
|
||||||
|
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="1.2" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<linearGradient id="grad" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#00F0FF" />
|
||||||
|
<stop offset="100%" stop-color="#B967FF" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
x="4" y="4" width="56" height="56" rx="10"
|
||||||
|
fill="#0A0A0F"
|
||||||
|
stroke="url(#grad)"
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<g filter="url(#glow)">
|
||||||
|
<path
|
||||||
|
d="M38 14 L38 40 Q38 48 30 48 Q22 48 22 40"
|
||||||
|
stroke="#00F0FF"
|
||||||
|
stroke-width="4"
|
||||||
|
stroke-linecap="round"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<circle cx="38" cy="14" r="3" fill="#FF2A6D" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<line x1="8" y1="52" x2="56" y2="52" stroke="#00F0FF" stroke-width="0.5" opacity="0.3" />
|
||||||
|
<line x1="8" y1="54" x2="56" y2="54" stroke="#FF2A6D" stroke-width="0.5" opacity="0.3" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/img/color-palette.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
public/img/company/actemium.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/img/company/almeria.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/img/company/cerema.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
public/img/company/novoferm.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
public/img/company/pivotpoint.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/img/company/sncf.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
public/img/icon/C.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
public/img/icon/CPP.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/img/icon/FastAPI.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/img/icon/PostgreSQL.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
public/img/icon/SQL-Server.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/img/icon/SQLite.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
public/img/icon/Typescript.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/img/icon/WebStorm.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
public/img/icon/androidStudio.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
public/img/icon/angular.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/img/icon/aws.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/img/icon/azure.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
public/img/icon/bootstrap.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/img/icon/dart.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
public/img/icon/debian.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
public/img/icon/docker.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/img/icon/eclipse.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
public/img/icon/expressjs.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/img/icon/firebase.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/img/icon/flutter.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/img/icon/git.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/img/icon/java.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/img/icon/js.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/img/icon/kotlin.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
public/img/icon/mariaDB.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
public/img/icon/mongo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/img/icon/mysql.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/img/icon/php.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/img/icon/phpstorm.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
public/img/icon/postman.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
public/img/icon/python.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/img/icon/sf.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
public/img/icon/spring.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/img/icon/studio_code.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
public/img/icon/swagger.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
public/img/icon/tailwind.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/img/icon/ubuntu.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/img/icon/windows.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
public/img/logo/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
public/img/logo/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/img/logo/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
public/img/logo/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 526 B |
BIN
public/img/logo/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 881 B |
BIN
public/img/pp/origin.JPEG
Normal file
|
After Width: | Height: | Size: 862 KiB |
BIN
public/img/pp/pp1.png
Normal file
|
After Width: | Height: | Size: 801 KiB |
BIN
public/img/pp/pp2.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/img/pp/pp3.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
public/img/projet/lplv.png
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
public/img/projet/monvoisingeek.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
public/img/projet/techos.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
16
scripts/hash-password.mjs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
const password = process.argv[2];
|
||||||
|
if (!password) {
|
||||||
|
console.error('Usage: npm run hash:password -- <mot_de_passe>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = bcrypt.hashSync(password, 12);
|
||||||
|
const jwtSecret = randomBytes(48).toString('base64');
|
||||||
|
|
||||||
|
console.log('\n✅ Copier ce bloc dans ton .env ou variables Plesk :\n');
|
||||||
|
console.log(`ADMIN_PASSWORD_HASH=${hash}`);
|
||||||
|
console.log(`ADMIN_JWT_SECRET=${jwtSecret}\n`);
|
||||||
81
src/components/Ambient.astro
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
---
|
||||||
|
// Background ambient fixed behind all content
|
||||||
|
// Layers: gradient mesh base (from body .warehouse-bg) + grain SVG + 3 UV spots parallaxed on scroll
|
||||||
|
---
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="ambient"
|
||||||
|
class="fixed inset-0 -z-10 pointer-events-none overflow-hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
transition:persist
|
||||||
|
>
|
||||||
|
<!-- UV spot 1 (cyan) -->
|
||||||
|
<div
|
||||||
|
class="spot absolute rounded-full blur-3xl opacity-40 will-change-transform"
|
||||||
|
style="
|
||||||
|
width: 60vw; height: 60vw;
|
||||||
|
top: -10vw; left: -15vw;
|
||||||
|
background: radial-gradient(closest-side, rgba(0, 240, 255, 0.35), transparent 70%);
|
||||||
|
transform: translate3d(0, var(--spot-1-y, 0), 0);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- UV spot 2 (magenta) -->
|
||||||
|
<div
|
||||||
|
class="spot absolute rounded-full blur-3xl opacity-35 will-change-transform"
|
||||||
|
style="
|
||||||
|
width: 55vw; height: 55vw;
|
||||||
|
top: 40vh; right: -20vw;
|
||||||
|
background: radial-gradient(closest-side, rgba(255, 42, 109, 0.32), transparent 70%);
|
||||||
|
transform: translate3d(0, var(--spot-2-y, 0), 0);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- UV spot 3 (acid, plus discret) -->
|
||||||
|
<div
|
||||||
|
class="spot absolute rounded-full blur-3xl opacity-25 will-change-transform"
|
||||||
|
style="
|
||||||
|
width: 45vw; height: 45vw;
|
||||||
|
bottom: -10vw; left: 30vw;
|
||||||
|
background: radial-gradient(closest-side, rgba(198, 255, 0, 0.25), transparent 70%);
|
||||||
|
transform: translate3d(0, var(--spot-3-y, 0), 0);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grain / noise overlay -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 opacity-[0.08] mix-blend-overlay animate-grain"
|
||||||
|
style="
|
||||||
|
background-image: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22200%22 height=%22200%22><filter id=%22n%22><feTurbulence type=%22fractalNoise%22 baseFrequency=%220.9%22 numOctaves=%222%22 stitchTiles=%22stitch%22/></filter><rect width=%22200%22 height=%22200%22 filter=%22url(%23n)%22 opacity=%220.6%22/></svg>');
|
||||||
|
background-size: 200px 200px;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scanlines subtiles fixes -->
|
||||||
|
<div class="absolute inset-0 opacity-[0.03] scanlines"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const ambient = document.getElementById('ambient');
|
||||||
|
if (ambient && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
let rafId: number | null = null;
|
||||||
|
const tick = () => {
|
||||||
|
const y = window.scrollY;
|
||||||
|
ambient.style.setProperty('--spot-1-y', `${y * 0.15}px`);
|
||||||
|
ambient.style.setProperty('--spot-2-y', `${y * -0.22}px`);
|
||||||
|
ambient.style.setProperty('--spot-3-y', `${y * 0.18}px`);
|
||||||
|
rafId = null;
|
||||||
|
};
|
||||||
|
window.addEventListener(
|
||||||
|
'scroll',
|
||||||
|
() => {
|
||||||
|
if (rafId === null) rafId = requestAnimationFrame(tick);
|
||||||
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
146
src/components/Footer.astro
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
---
|
||||||
|
import type { Profile } from '../content/schemas';
|
||||||
|
import Waveform from './islands/Waveform';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
profile: Profile;
|
||||||
|
}
|
||||||
|
const { profile } = Astro.props;
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
---
|
||||||
|
|
||||||
|
<footer class="relative mt-24 border-t border-[color:var(--color-border)] bg-[color:var(--color-surface)]">
|
||||||
|
<!-- Waveform séparateur -->
|
||||||
|
<div class="absolute -top-10 inset-x-0 h-10 flex items-end opacity-40 pointer-events-none">
|
||||||
|
<Waveform bars={120} color="var(--color-magenta)" className="w-full h-full" client:visible />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 py-16">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-[2fr_1fr_1fr] gap-10 mb-12">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<span
|
||||||
|
class="stencil text-3xl leading-none tracking-wider flex items-baseline gap-0.5"
|
||||||
|
>
|
||||||
|
<span class="text-[color:var(--color-cyan)] glow-text-cyan">J</span>
|
||||||
|
<span class="text-[color:var(--color-acid)] glow-text-acid">L</span>
|
||||||
|
<span class="text-[color:var(--color-magenta)] glow-text-magenta text-sm">•</span>
|
||||||
|
</span>
|
||||||
|
{
|
||||||
|
profile.available && (
|
||||||
|
<span class="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-[0.2em] text-[color:var(--color-text-muted)]">
|
||||||
|
<span class="led led-green animate-led-green" />
|
||||||
|
on air
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-[color:var(--color-text-muted)] max-w-md mb-4">
|
||||||
|
{profile.tagline}
|
||||||
|
</p>
|
||||||
|
<p class="font-mono text-[10px] uppercase tracking-[0.25em] text-[color:var(--color-magenta)]">
|
||||||
|
// code propre, sono sale
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="stencil uppercase tracking-[0.25em] text-xs text-[color:var(--color-cyan)] mb-4">
|
||||||
|
navigate
|
||||||
|
</h3>
|
||||||
|
<ul class="space-y-2 text-sm font-mono">
|
||||||
|
<li>
|
||||||
|
<a href="/" class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-cyan)] transition">
|
||||||
|
> home
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/projets" class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-cyan)] transition">
|
||||||
|
> projets
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/experience" class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-cyan)] transition">
|
||||||
|
> experience
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/formations" class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-cyan)] transition">
|
||||||
|
> formations
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="stencil uppercase tracking-[0.25em] text-xs text-[color:var(--color-acid)] mb-4">
|
||||||
|
open mic
|
||||||
|
</h3>
|
||||||
|
<ul class="space-y-2 text-sm font-mono">
|
||||||
|
{
|
||||||
|
profile.socials.email && (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={`mailto:${profile.socials.email}`}
|
||||||
|
class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-acid)] transition break-all"
|
||||||
|
>
|
||||||
|
> {profile.socials.email}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
profile.socials.github && (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={profile.socials.github}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-acid)] transition"
|
||||||
|
>
|
||||||
|
> github ↗
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
profile.socials.gitea && (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={profile.socials.gitea}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-acid)] transition"
|
||||||
|
>
|
||||||
|
> gitea ↗
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
profile.socials.website && (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={profile.socials.website}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="text-[color:var(--color-text-muted)] hover:text-[color:var(--color-acid)] transition"
|
||||||
|
>
|
||||||
|
> portfolio ↗
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="pt-6 border-t border-[color:var(--color-border)] flex flex-col md:flex-row justify-between items-center gap-2 text-[10px] text-[color:var(--color-text-dim)] font-mono uppercase tracking-widest"
|
||||||
|
>
|
||||||
|
<span>© {year} johan leroy · all rights reserved</span>
|
||||||
|
<span>
|
||||||
|
<span class="text-[color:var(--color-cyan)]"></></span> built with astro · dark mode only
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
244
src/components/Header.astro
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
---
|
||||||
|
import type { Profile } from '../content/schemas';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
profile: Profile;
|
||||||
|
}
|
||||||
|
const { profile } = Astro.props;
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/', label: 'Accueil', tag: 'home' },
|
||||||
|
{ href: '/projets', label: 'Projets', tag: 'crate' },
|
||||||
|
{ href: '/experience', label: 'Expérience', tag: 'setlist' },
|
||||||
|
{ href: '/formations', label: 'Formations', tag: 'log' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const current = Astro.url.pathname;
|
||||||
|
---
|
||||||
|
|
||||||
|
<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)]"
|
||||||
|
transition:persist
|
||||||
|
>
|
||||||
|
<!-- Scroll progress bar -->
|
||||||
|
<div
|
||||||
|
id="scroll-progress"
|
||||||
|
class="absolute bottom-0 left-0 h-px bg-[color:var(--color-acid)] shadow-[0_0_8px_rgba(198,255,0,0.7)] origin-left transition-[width] duration-100 ease-out"
|
||||||
|
style="width: 0%"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 h-16 flex items-center justify-between gap-4">
|
||||||
|
<!-- Logo "JL" stencil + ON AIR -->
|
||||||
|
<a href="/" class="flex items-center gap-3 group shrink-0" aria-label="Johan Leroy — accueil">
|
||||||
|
<span
|
||||||
|
class="stencil text-2xl leading-none tracking-wider flex items-baseline gap-0.5 group-hover:scale-[1.02] transition-transform"
|
||||||
|
>
|
||||||
|
<span class="text-[color:var(--color-cyan)] glow-text-cyan">J</span>
|
||||||
|
<span class="text-[color:var(--color-acid)] glow-text-acid">L</span>
|
||||||
|
<span class="text-[color:var(--color-magenta)] glow-text-magenta text-xs">•</span>
|
||||||
|
</span>
|
||||||
|
{
|
||||||
|
profile.available && (
|
||||||
|
<span class="hidden sm:inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-[0.2em] text-[color:var(--color-text-muted)]">
|
||||||
|
<span class="led led-green animate-led-green" aria-hidden="true" />
|
||||||
|
on air
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Nav desktop -->
|
||||||
|
<nav
|
||||||
|
class="hidden md:flex items-center gap-1 font-mono text-[11px] uppercase tracking-[0.18em]"
|
||||||
|
>
|
||||||
|
{
|
||||||
|
navItems.map((item) => {
|
||||||
|
const active =
|
||||||
|
current === item.href || (item.href !== '/' && current.startsWith(item.href));
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class:list={[
|
||||||
|
'relative group flex items-center gap-2 px-3 py-2 transition-colors',
|
||||||
|
active
|
||||||
|
? 'text-[color:var(--color-cyan)]'
|
||||||
|
: 'text-[color:var(--color-text-muted)] hover:text-[color:var(--color-text)]',
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class:list={[
|
||||||
|
'led transition-all',
|
||||||
|
active ? 'led-cyan' : 'bg-[color:var(--color-border)] shadow-none',
|
||||||
|
]}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="flex flex-col items-start leading-tight">
|
||||||
|
<span class="font-display text-xs tracking-[0.15em]">{item.label}</span>
|
||||||
|
<span
|
||||||
|
class:list={[
|
||||||
|
'text-[9px] opacity-60',
|
||||||
|
active && 'text-[color:var(--color-acid)] opacity-100',
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
// {item.tag}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{active && (
|
||||||
|
<span
|
||||||
|
class="absolute -bottom-px left-3 right-3 h-px bg-[color:var(--color-cyan)]"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Controls (sound toggle + mobile menu) -->
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<!-- Sound toggle -->
|
||||||
|
<button
|
||||||
|
id="sound-toggle"
|
||||||
|
class="p-2 text-[color:var(--color-text-muted)] hover:text-[color:var(--color-acid)] transition-colors group"
|
||||||
|
aria-label="Activer/désactiver les sons"
|
||||||
|
aria-pressed="false"
|
||||||
|
title="Sons"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="sound-on hidden w-5 h-5"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<svg
|
||||||
|
class="sound-off w-5 h-5"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15zM17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Mobile menu button -->
|
||||||
|
<button
|
||||||
|
id="mobile-menu-btn"
|
||||||
|
class="md:hidden p-2 text-[color:var(--color-text)]"
|
||||||
|
aria-label="Menu"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile menu -->
|
||||||
|
<div
|
||||||
|
id="mobile-menu"
|
||||||
|
class="md:hidden hidden border-t border-[color:var(--color-border)] bg-[rgba(10,10,15,0.97)]"
|
||||||
|
>
|
||||||
|
<nav class="container mx-auto px-4 py-4 flex flex-col gap-1 font-mono uppercase tracking-wider">
|
||||||
|
{
|
||||||
|
navItems.map((item) => {
|
||||||
|
const active =
|
||||||
|
current === item.href || (item.href !== '/' && current.startsWith(item.href));
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class:list={[
|
||||||
|
'flex items-center gap-3 px-3 py-3 transition-colors',
|
||||||
|
active
|
||||||
|
? 'text-[color:var(--color-cyan)] bg-[rgba(0,240,255,0.04)]'
|
||||||
|
: 'text-[color:var(--color-text-muted)] hover:text-[color:var(--color-text)]',
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class:list={[
|
||||||
|
'led',
|
||||||
|
active ? 'led-cyan' : 'bg-[color:var(--color-border)] shadow-none',
|
||||||
|
]}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span class="font-display text-sm">{item.label}</span>
|
||||||
|
<span class="ml-auto text-[10px] opacity-50">// {item.tag}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Mobile menu
|
||||||
|
const btn = document.getElementById('mobile-menu-btn');
|
||||||
|
const menu = document.getElementById('mobile-menu');
|
||||||
|
btn?.addEventListener('click', () => {
|
||||||
|
const open = menu?.classList.toggle('hidden') === false;
|
||||||
|
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll progress bar
|
||||||
|
const progress = document.getElementById('scroll-progress');
|
||||||
|
function updateProgress() {
|
||||||
|
if (!progress) return;
|
||||||
|
const scrollTop = window.scrollY;
|
||||||
|
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||||
|
const pct = docHeight > 0 ? Math.min(100, (scrollTop / docHeight) * 100) : 0;
|
||||||
|
progress.style.width = `${pct}%`;
|
||||||
|
}
|
||||||
|
window.addEventListener('scroll', updateProgress, { passive: true });
|
||||||
|
window.addEventListener('resize', updateProgress);
|
||||||
|
updateProgress();
|
||||||
|
|
||||||
|
// Sound toggle (state in localStorage, off by default)
|
||||||
|
const soundBtn = document.getElementById('sound-toggle') as HTMLButtonElement | null;
|
||||||
|
const onIcon = soundBtn?.querySelector('.sound-on') as SVGElement | null;
|
||||||
|
const offIcon = soundBtn?.querySelector('.sound-off') as SVGElement | null;
|
||||||
|
|
||||||
|
function applySoundState(on: boolean) {
|
||||||
|
if (!soundBtn || !onIcon || !offIcon) return;
|
||||||
|
soundBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||||
|
onIcon.classList.toggle('hidden', !on);
|
||||||
|
offIcon.classList.toggle('hidden', on);
|
||||||
|
(window as unknown as { __soundOn?: boolean }).__soundOn = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = localStorage.getItem('portfolio:sound');
|
||||||
|
applySoundState(stored === 'on');
|
||||||
|
|
||||||
|
soundBtn?.addEventListener('click', () => {
|
||||||
|
const next = soundBtn.getAttribute('aria-pressed') !== 'true';
|
||||||
|
localStorage.setItem('portfolio:sound', next ? 'on' : 'off');
|
||||||
|
applySoundState(next);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
94
src/components/ProjectCard.astro
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
import type { Project } from '../content/schemas';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
const { project } = Astro.props;
|
||||||
|
const isArchived = project.status === 'archived';
|
||||||
|
const isPrivate = project.status === 'private';
|
||||||
|
const isWIP = project.status === 'wip';
|
||||||
|
const statusLabel =
|
||||||
|
project.status === 'live'
|
||||||
|
? 'Live'
|
||||||
|
: project.status === 'archived'
|
||||||
|
? 'Archivé'
|
||||||
|
: project.status === 'private'
|
||||||
|
? 'Privé'
|
||||||
|
: 'WIP';
|
||||||
|
|
||||||
|
const Tag = project.url && !isArchived && !isPrivate ? 'a' : 'div';
|
||||||
|
const tagProps = project.url && !isArchived && !isPrivate
|
||||||
|
? { href: project.url, target: '_blank', rel: 'noopener noreferrer' }
|
||||||
|
: {};
|
||||||
|
---
|
||||||
|
|
||||||
|
<Tag
|
||||||
|
{...tagProps}
|
||||||
|
class:list={[
|
||||||
|
'group card-neon block h-full overflow-hidden',
|
||||||
|
isArchived && 'opacity-70',
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div class="relative aspect-[16/9] -mx-6 -mt-6 mb-4 overflow-hidden bg-[color:var(--color-bg)]">
|
||||||
|
{project.image ? (
|
||||||
|
<img
|
||||||
|
src={project.image}
|
||||||
|
alt={project.title}
|
||||||
|
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
|
class:list={[isArchived && 'grayscale']}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div class="w-full h-full flex items-center justify-center text-[color:var(--color-text-dim)] font-mono text-xs aurora-bg">
|
||||||
|
// no preview
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class="absolute top-3 right-3 flex gap-2">
|
||||||
|
<span
|
||||||
|
class:list={[
|
||||||
|
'text-[10px] font-mono uppercase tracking-wider px-2 py-1 rounded-sm border backdrop-blur-md',
|
||||||
|
project.status === 'live' && 'border-[color:var(--color-cyan)] text-[color:var(--color-cyan)] bg-[rgba(0,240,255,0.1)]',
|
||||||
|
project.status === 'archived' && 'border-[color:var(--color-text-dim)] text-[color:var(--color-text-dim)] bg-[rgba(0,0,0,0.5)]',
|
||||||
|
project.status === 'private' && 'border-[color:var(--color-magenta)] text-[color:var(--color-magenta)] bg-[rgba(255,42,109,0.1)]',
|
||||||
|
project.status === 'wip' && 'border-[color:var(--color-violet)] text-[color:var(--color-violet)] bg-[rgba(185,103,255,0.1)]',
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="font-display text-lg mb-1 text-[color:var(--color-text)] group-hover:glow-text-cyan group-hover:text-[color:var(--color-cyan)] transition-colors">
|
||||||
|
{project.title}
|
||||||
|
</h3>
|
||||||
|
{project.subtitle && (
|
||||||
|
<p class="text-xs font-mono text-[color:var(--color-text-dim)] mb-3 uppercase tracking-wider">
|
||||||
|
{project.subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p class="text-sm text-[color:var(--color-text-muted)] mb-4 line-clamp-3">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{project.stack.length > 0 && (
|
||||||
|
<div class="flex flex-wrap gap-1.5 mb-4">
|
||||||
|
{project.stack.slice(0, 5).map((tech) => (
|
||||||
|
<span class="chip">{tech}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{project.url && !isArchived && !isPrivate && (
|
||||||
|
<span class="text-xs font-mono text-[color:var(--color-cyan)] group-hover:underline">
|
||||||
|
→ Voir le projet
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isArchived && (
|
||||||
|
<span class="text-xs font-mono text-[color:var(--color-text-dim)]">
|
||||||
|
// projet fermé
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Tag>
|
||||||
31
src/components/SEO.astro
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
import type { Site } from '../content/schemas';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
site: Site;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description, site } = Astro.props;
|
||||||
|
const pageTitle = title ? `${title} — ${site.title}` : site.title;
|
||||||
|
const pageDescription = description ?? site.description;
|
||||||
|
const canonical = new URL(Astro.url.pathname, site.url).toString();
|
||||||
|
const ogImage = new URL(site.ogImage, site.url).toString();
|
||||||
|
---
|
||||||
|
|
||||||
|
<title>{pageTitle}</title>
|
||||||
|
<meta name="description" content={pageDescription} />
|
||||||
|
<meta name="keywords" content={site.keywords.join(', ')} />
|
||||||
|
<link rel="canonical" href={canonical} />
|
||||||
|
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content={pageTitle} />
|
||||||
|
<meta property="og:description" content={pageDescription} />
|
||||||
|
<meta property="og:url" content={canonical} />
|
||||||
|
<meta property="og:image" content={ogImage} />
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content={pageTitle} />
|
||||||
|
<meta name="twitter:description" content={pageDescription} />
|
||||||
|
<meta name="twitter:image" content={ogImage} />
|
||||||
98
src/components/islands/BootSequence.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
|
||||||
|
const LINES = [
|
||||||
|
{ label: '> init.sound-system', status: 'OK', delay: 150 },
|
||||||
|
{ label: '> load.frequency: 174Hz', status: 'LOCKED', delay: 300 },
|
||||||
|
{ label: '> warmup.amps', status: 'OK', delay: 420 },
|
||||||
|
{ label: '> patch.crate → decks', status: 'READY', delay: 560 },
|
||||||
|
{ label: '> signal.online', status: 'ON AIR', delay: 720 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function BootSequence() {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (sessionStorage.getItem('portfolio:booted') === '1') return;
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
sessionStorage.setItem('portfolio:booted', '1');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisible(true);
|
||||||
|
|
||||||
|
const timers: number[] = [];
|
||||||
|
LINES.forEach((line, i) => {
|
||||||
|
timers.push(window.setTimeout(() => setStep(i + 1), line.delay));
|
||||||
|
});
|
||||||
|
timers.push(
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setVisible(false);
|
||||||
|
sessionStorage.setItem('portfolio:booted', '1');
|
||||||
|
}, 1200),
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
timers.forEach(clearTimeout);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function skip() {
|
||||||
|
sessionStorage.setItem('portfolio:booted', '1');
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{visible && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-[100] flex items-center justify-center cursor-pointer"
|
||||||
|
onClick={skip}
|
||||||
|
initial={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0, filter: 'blur(12px)' }}
|
||||||
|
transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
|
||||||
|
style={{ background: 'var(--color-bg)' }}
|
||||||
|
>
|
||||||
|
<div className="font-mono text-xs md:text-sm max-w-sm w-full px-6">
|
||||||
|
<div className="mb-6 flex items-center gap-2 text-[color:var(--color-acid)] uppercase tracking-[0.3em]">
|
||||||
|
<span className="led led-acid animate-pulse" />
|
||||||
|
<span>Booting sound system</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{LINES.slice(0, step).map((line, i) => (
|
||||||
|
<motion.li
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0, x: -8 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="flex justify-between gap-4"
|
||||||
|
>
|
||||||
|
<span className="text-[color:var(--color-text-muted)]">{line.label}</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
line.status === 'ON AIR'
|
||||||
|
? 'text-[color:var(--color-acid)]'
|
||||||
|
: 'text-[color:var(--color-cyan)]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
[{line.status}]
|
||||||
|
</span>
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
{step < LINES.length && (
|
||||||
|
<li className="flex items-center gap-1 text-[color:var(--color-text-dim)]">
|
||||||
|
<span className="animate-pulse">█</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
<p className="mt-8 text-[10px] text-[color:var(--color-text-dim)] text-center uppercase tracking-widest">
|
||||||
|
click to skip
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
src/components/islands/Crate.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import SkeudCard from './SkeudCard';
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
description: string;
|
||||||
|
image?: string;
|
||||||
|
category: string;
|
||||||
|
status: string;
|
||||||
|
url?: string;
|
||||||
|
stack: string[];
|
||||||
|
featured?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projects: Project[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Crate({ projects }: Props) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
const track = trackRef.current;
|
||||||
|
if (!container || !track) return;
|
||||||
|
// Mobile / touch : skip pinned logic, default vertical grid via CSS
|
||||||
|
if (window.matchMedia('(max-width: 767px)').matches) return;
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||||
|
|
||||||
|
const getTotalScroll = () => track.scrollWidth - window.innerWidth + 100;
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
if (!container || !track) return;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const totalScroll = getTotalScroll();
|
||||||
|
if (totalScroll <= 0) return;
|
||||||
|
|
||||||
|
if (rect.top <= 0 && rect.bottom > window.innerHeight) {
|
||||||
|
const progress = Math.min(1, Math.max(0, -rect.top / (container.offsetHeight - window.innerHeight)));
|
||||||
|
track.style.transform = `translate3d(${-progress * totalScroll}px, 0, 0)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
window.addEventListener('resize', onScroll);
|
||||||
|
onScroll();
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', onScroll);
|
||||||
|
window.removeEventListener('resize', onScroll);
|
||||||
|
};
|
||||||
|
}, [projects.length]);
|
||||||
|
|
||||||
|
// Height of container = 100vh * (project count factor) to give scroll room
|
||||||
|
const containerHeightStyle = {
|
||||||
|
height: `${Math.max(100, projects.length * 60)}vh`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop : pinned horizontal scroll */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="hidden md:block relative"
|
||||||
|
style={containerHeightStyle}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={scrollerRef}
|
||||||
|
className="sticky top-16 h-screen overflow-hidden flex items-center"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
className="flex items-center gap-10 pl-[10vw] pr-[20vw] will-change-transform"
|
||||||
|
>
|
||||||
|
{projects.map((project, i) => (
|
||||||
|
<SkeudCard key={project.id} project={project} index={i} />
|
||||||
|
))}
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 font-mono text-xs text-[color:var(--color-text-dim)] uppercase tracking-widest writing-mode-vertical"
|
||||||
|
style={{ writingMode: 'vertical-rl' }}
|
||||||
|
>
|
||||||
|
end of crate · {projects.length} skeuds
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile : vertical grid fallback */}
|
||||||
|
<div className="md:hidden grid grid-cols-1 gap-10 pt-8">
|
||||||
|
{projects.map((project, i) => (
|
||||||
|
<SkeudCard key={project.id} project={project} index={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
src/components/islands/EasterEggs.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
const KONAMI = [
|
||||||
|
'ArrowUp',
|
||||||
|
'ArrowUp',
|
||||||
|
'ArrowDown',
|
||||||
|
'ArrowDown',
|
||||||
|
'ArrowLeft',
|
||||||
|
'ArrowRight',
|
||||||
|
'ArrowLeft',
|
||||||
|
'ArrowRight',
|
||||||
|
'KeyB',
|
||||||
|
'KeyA',
|
||||||
|
];
|
||||||
|
|
||||||
|
function triggerPartyMode() {
|
||||||
|
if (document.getElementById('party-overlay')) return;
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'party-overlay';
|
||||||
|
overlay.style.cssText =
|
||||||
|
'position:fixed;inset:0;z-index:9999;pointer-events:none;background:transparent;';
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
// Strobe flashes
|
||||||
|
const strobe = document.createElement('div');
|
||||||
|
strobe.style.cssText =
|
||||||
|
'position:absolute;inset:0;background:#ffffff;opacity:0;mix-blend-mode:difference;';
|
||||||
|
overlay.appendChild(strobe);
|
||||||
|
|
||||||
|
const colors = ['#00F0FF', '#FF2A6D', '#C6FF00', '#B967FF'];
|
||||||
|
let i = 0;
|
||||||
|
const strobeInterval = setInterval(() => {
|
||||||
|
const c = colors[i++ % colors.length];
|
||||||
|
strobe.style.background = c;
|
||||||
|
strobe.style.opacity = '0.4';
|
||||||
|
setTimeout(() => (strobe.style.opacity = '0'), 60);
|
||||||
|
}, 140);
|
||||||
|
|
||||||
|
// Big text
|
||||||
|
const text = document.createElement('div');
|
||||||
|
text.textContent = 'PARTY MODE ACTIVATED';
|
||||||
|
text.style.cssText = `
|
||||||
|
position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
|
||||||
|
font-family:"Bebas Neue",Impact,sans-serif;font-size:min(12vw,8rem);
|
||||||
|
color:#C6FF00;text-shadow:0 0 30px #C6FF00;letter-spacing:0.1em;
|
||||||
|
animation:glitch-skew 0.2s infinite;pointer-events:none;
|
||||||
|
`;
|
||||||
|
overlay.appendChild(text);
|
||||||
|
|
||||||
|
// Clean up after 4 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(strobeInterval);
|
||||||
|
overlay.style.transition = 'opacity 0.5s';
|
||||||
|
overlay.style.opacity = '0';
|
||||||
|
setTimeout(() => overlay.remove(), 500);
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playTick() {
|
||||||
|
const soundOn = (window as unknown as { __soundOn?: boolean }).__soundOn;
|
||||||
|
if (!soundOn) return;
|
||||||
|
try {
|
||||||
|
const AudioCtx =
|
||||||
|
window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||||
|
const ctx = new AudioCtx();
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.type = 'square';
|
||||||
|
osc.frequency.value = 1200;
|
||||||
|
gain.gain.value = 0.02;
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.start();
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.04);
|
||||||
|
osc.stop(ctx.currentTime + 0.05);
|
||||||
|
} catch {
|
||||||
|
/* audio disabled */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EasterEggs() {
|
||||||
|
useEffect(() => {
|
||||||
|
// Konami code
|
||||||
|
let buffer: string[] = [];
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
buffer.push(e.code);
|
||||||
|
if (buffer.length > KONAMI.length) buffer.shift();
|
||||||
|
if (KONAMI.every((k, i) => buffer[i] === k)) {
|
||||||
|
triggerPartyMode();
|
||||||
|
buffer = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
|
||||||
|
// Logo 5x click
|
||||||
|
let logoClickCount = 0;
|
||||||
|
let logoTimer: number | null = null;
|
||||||
|
const logo = document.querySelector<HTMLElement>('header a[href="/"]');
|
||||||
|
function onLogoClick(e: Event) {
|
||||||
|
logoClickCount += 1;
|
||||||
|
if (logoClickCount >= 5) {
|
||||||
|
e.preventDefault();
|
||||||
|
logoClickCount = 0;
|
||||||
|
triggerPartyMode();
|
||||||
|
}
|
||||||
|
if (logoTimer) window.clearTimeout(logoTimer);
|
||||||
|
logoTimer = window.setTimeout(() => {
|
||||||
|
logoClickCount = 0;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
logo?.addEventListener('click', onLogoClick);
|
||||||
|
|
||||||
|
// Tick on hover links/buttons
|
||||||
|
function onMouseOver(e: MouseEvent) {
|
||||||
|
const t = e.target as HTMLElement;
|
||||||
|
if (!t?.closest) return;
|
||||||
|
if (t.closest('a, button, [role="button"]')) {
|
||||||
|
playTick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mouseover', onMouseOver, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKey);
|
||||||
|
logo?.removeEventListener('click', onLogoClick);
|
||||||
|
document.removeEventListener('mouseover', onMouseOver);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
71
src/components/islands/MagneticButton.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { useRef, type ReactNode, type MouseEvent } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
href?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
className?: string;
|
||||||
|
target?: string;
|
||||||
|
rel?: string;
|
||||||
|
strength?: number;
|
||||||
|
download?: boolean | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MagneticButton({
|
||||||
|
children,
|
||||||
|
href,
|
||||||
|
onClick,
|
||||||
|
className = '',
|
||||||
|
target,
|
||||||
|
rel,
|
||||||
|
strength = 0.3,
|
||||||
|
download,
|
||||||
|
}: Props) {
|
||||||
|
const ref = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
function handleMove(e: MouseEvent) {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (window.matchMedia('(pointer: coarse)').matches) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const x = e.clientX - (rect.left + rect.width / 2);
|
||||||
|
const y = e.clientY - (rect.top + rect.height / 2);
|
||||||
|
el.style.transform = `translate(${x * strength}px, ${y * strength}px)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLeave() {
|
||||||
|
if (!ref.current) return;
|
||||||
|
ref.current.style.transform = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseClass = `inline-block transition-transform duration-200 ease-out will-change-transform ${className}`;
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
ref={ref as React.RefObject<HTMLAnchorElement>}
|
||||||
|
href={href}
|
||||||
|
target={target}
|
||||||
|
rel={rel}
|
||||||
|
download={download as boolean | string | undefined}
|
||||||
|
onMouseMove={handleMove}
|
||||||
|
onMouseLeave={handleLeave}
|
||||||
|
className={baseClass}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref as React.RefObject<HTMLButtonElement>}
|
||||||
|
type="button"
|
||||||
|
onMouseMove={handleMove}
|
||||||
|
onMouseLeave={handleLeave}
|
||||||
|
onClick={onClick}
|
||||||
|
className={baseClass}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
src/components/islands/PatchBay.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
technical: string[];
|
||||||
|
soft: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PatchBay({ technical, soft }: Props) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
const svg = svgRef.current;
|
||||||
|
if (!container || !svg) return;
|
||||||
|
|
||||||
|
function drawCables() {
|
||||||
|
if (!svg || !container) return;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const width = rect.width;
|
||||||
|
const height = rect.height;
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||||
|
svg.setAttribute('width', String(width));
|
||||||
|
svg.setAttribute('height', String(height));
|
||||||
|
|
||||||
|
// Find left + right jack positions
|
||||||
|
const leftJacks = container.querySelectorAll<HTMLElement>('[data-jack="left"]');
|
||||||
|
const rightJacks = container.querySelectorAll<HTMLElement>('[data-jack="right"]');
|
||||||
|
const cr = container.getBoundingClientRect();
|
||||||
|
|
||||||
|
const leftPoints = Array.from(leftJacks).map((el) => {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return { x: r.right - cr.left, y: r.top + r.height / 2 - cr.top };
|
||||||
|
});
|
||||||
|
const rightPoints = Array.from(rightJacks).map((el) => {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
return { x: r.left - cr.left, y: r.top + r.height / 2 - cr.top };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build cable paths pairing left to right
|
||||||
|
const colors = ['var(--color-cyan)', 'var(--color-magenta)', 'var(--color-acid)'];
|
||||||
|
svg.innerHTML = '';
|
||||||
|
leftPoints.forEach((p1, i) => {
|
||||||
|
const pairIdx = i % rightPoints.length;
|
||||||
|
const p2 = rightPoints[pairIdx];
|
||||||
|
if (!p2) return;
|
||||||
|
const dx = p2.x - p1.x;
|
||||||
|
const sag = 20 + (i % 3) * 8;
|
||||||
|
const midY = (p1.y + p2.y) / 2 + sag;
|
||||||
|
const c1x = p1.x + dx * 0.4;
|
||||||
|
const c2x = p1.x + dx * 0.6;
|
||||||
|
const d = `M ${p1.x} ${p1.y} C ${c1x} ${midY}, ${c2x} ${midY}, ${p2.x} ${p2.y}`;
|
||||||
|
|
||||||
|
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||||
|
path.setAttribute('d', d);
|
||||||
|
path.setAttribute('fill', 'none');
|
||||||
|
path.setAttribute('stroke', colors[i % colors.length]);
|
||||||
|
path.setAttribute('stroke-width', '1.6');
|
||||||
|
path.setAttribute('stroke-opacity', '0.55');
|
||||||
|
path.style.filter = `drop-shadow(0 0 4px ${colors[i % colors.length]})`;
|
||||||
|
svg.appendChild(path);
|
||||||
|
|
||||||
|
// Plug tips
|
||||||
|
[p1, p2].forEach((p) => {
|
||||||
|
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||||
|
circle.setAttribute('cx', String(p.x));
|
||||||
|
circle.setAttribute('cy', String(p.y));
|
||||||
|
circle.setAttribute('r', '3');
|
||||||
|
circle.setAttribute('fill', colors[i % colors.length]);
|
||||||
|
circle.style.filter = `drop-shadow(0 0 6px ${colors[i % colors.length]})`;
|
||||||
|
svg.appendChild(circle);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
drawCables();
|
||||||
|
const ro = new ResizeObserver(drawCables);
|
||||||
|
ro.observe(container);
|
||||||
|
window.addEventListener('resize', drawCables);
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
window.removeEventListener('resize', drawCables);
|
||||||
|
};
|
||||||
|
}, [technical.length, soft.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{/* Left panel : technical */}
|
||||||
|
<div>
|
||||||
|
<h3 className="stencil text-sm uppercase tracking-[0.25em] text-[color:var(--color-cyan)] glow-text-cyan mb-4">
|
||||||
|
Techniques
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-2.5">
|
||||||
|
{technical.map((s, i) => (
|
||||||
|
<li key={s} className="flex items-center gap-3 font-mono text-xs uppercase tracking-wider">
|
||||||
|
<span className="text-[color:var(--color-text-muted)] flex-1">{s}</span>
|
||||||
|
<span
|
||||||
|
data-jack="left"
|
||||||
|
className="w-3 h-3 rounded-full border-2 border-[color:var(--color-cyan)] bg-[color:var(--color-bg)]"
|
||||||
|
style={{ boxShadow: `inset 0 0 0 1px #000` }}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Middle : SVG cables */}
|
||||||
|
<div className="hidden md:block relative min-h-full">
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
className="absolute inset-0 pointer-events-none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right panel : soft */}
|
||||||
|
<div>
|
||||||
|
<h3 className="stencil text-sm uppercase tracking-[0.25em] text-[color:var(--color-magenta)] glow-text-magenta mb-4">
|
||||||
|
Transverses
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-2.5">
|
||||||
|
{soft.map((s, i) => (
|
||||||
|
<li key={s} className="flex items-center gap-3 font-mono text-xs uppercase tracking-wider">
|
||||||
|
<span
|
||||||
|
data-jack="right"
|
||||||
|
className="w-3 h-3 rounded-full border-2 border-[color:var(--color-magenta)] bg-[color:var(--color-bg)]"
|
||||||
|
style={{ boxShadow: `inset 0 0 0 1px #000` }}
|
||||||
|
/>
|
||||||
|
<span className="text-[color:var(--color-text-muted)] flex-1">{s}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
src/components/islands/RackModule.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { Icon } from '@iconify-icon/react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
level: 'primary' | 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
items: Item[];
|
||||||
|
accent?: 'cyan' | 'acid' | 'magenta';
|
||||||
|
bpm?: number;
|
||||||
|
index?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accentMap = {
|
||||||
|
cyan: 'var(--color-cyan)',
|
||||||
|
acid: 'var(--color-acid)',
|
||||||
|
magenta: 'var(--color-magenta)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RackModule({
|
||||||
|
label,
|
||||||
|
items,
|
||||||
|
accent = 'cyan',
|
||||||
|
bpm = 174,
|
||||||
|
index = 0,
|
||||||
|
}: Props) {
|
||||||
|
const color = accentMap[accent];
|
||||||
|
const [levels, setLevels] = useState<number[]>([]);
|
||||||
|
const rafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
setLevels([0.6, 0.5, 0.7, 0.4]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const start = performance.now() + index * 137;
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const t = (now - start) / 1000;
|
||||||
|
setLevels([
|
||||||
|
0.5 + 0.3 * Math.abs(Math.sin(t * 2.1)),
|
||||||
|
0.4 + 0.35 * Math.abs(Math.sin(t * 2.8 + 1)),
|
||||||
|
0.6 + 0.25 * Math.abs(Math.sin(t * 1.5 + 2)),
|
||||||
|
0.3 + 0.4 * Math.abs(Math.sin(t * 3.2 + 0.5)),
|
||||||
|
]);
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
return () => {
|
||||||
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||||
|
};
|
||||||
|
}, [index]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="relative bg-[color:var(--color-surface)] border border-[color:var(--color-border)] rounded-sm overflow-hidden"
|
||||||
|
style={{ boxShadow: `inset 0 1px 0 rgba(255,255,255,0.03)` }}
|
||||||
|
>
|
||||||
|
{/* Top bar : LED + label + BPM */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-2.5 border-b border-[color:var(--color-border)] bg-[color:var(--color-surface-2)]"
|
||||||
|
style={{ boxShadow: `inset 0 -1px 0 ${color}22` }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span
|
||||||
|
className="led"
|
||||||
|
style={{ background: color, boxShadow: `0 0 8px ${color}` }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="font-stencil text-sm uppercase tracking-[0.25em]"
|
||||||
|
style={{ color, textShadow: `0 0 10px ${color}80` }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-widest text-[color:var(--color-text-dim)]">
|
||||||
|
[{bpm} bpm]
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body : VU meter (vertical bars) + pads grid */}
|
||||||
|
<div className="flex gap-4 p-4">
|
||||||
|
{/* VU meter */}
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-1 w-3 pt-1"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ justifyContent: 'flex-end' }}
|
||||||
|
>
|
||||||
|
{[0.9, 0.75, 0.6, 0.45, 0.3, 0.15].map((threshold, i) => {
|
||||||
|
const on = (levels[i % 4] ?? 0) > threshold;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="block w-full h-1 rounded-[1px] transition-opacity"
|
||||||
|
style={{
|
||||||
|
background: on ? color : `${color}20`,
|
||||||
|
boxShadow: on ? `0 0 6px ${color}` : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pads grid */}
|
||||||
|
<div className="grid grid-cols-4 gap-2 flex-1 content-start">
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.name}
|
||||||
|
type="button"
|
||||||
|
className="group relative aspect-square flex flex-col items-center justify-center gap-1 bg-[color:var(--color-bg)] border border-[color:var(--color-border)] rounded-sm transition-all hover:translate-y-px active:translate-y-0.5"
|
||||||
|
style={{
|
||||||
|
boxShadow: `inset 0 -2px 0 ${color}15`,
|
||||||
|
}}
|
||||||
|
title={item.name}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget.style.borderColor = `${color}aa`);
|
||||||
|
(e.currentTarget.style.boxShadow = `0 0 20px ${color}44, inset 0 -2px 0 ${color}66`);
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget.style.borderColor = '');
|
||||||
|
(e.currentTarget.style.boxShadow = `inset 0 -2px 0 ${color}15`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon={item.icon} width="20" height="20" />
|
||||||
|
<span className="text-[9px] font-mono uppercase tracking-wider text-[color:var(--color-text-muted)] group-hover:text-[color:var(--color-text)] transition-colors truncate max-w-full px-1">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="absolute top-1 right-1 w-1 h-1 rounded-full opacity-30 group-hover:opacity-100 transition-opacity"
|
||||||
|
style={{ background: color, boxShadow: `0 0 4px ${color}` }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom bar : screws / decoration */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-1.5 border-t border-[color:var(--color-border)] bg-[color:var(--color-surface-2)]">
|
||||||
|
<span className="font-mono text-[9px] text-[color:var(--color-text-dim)] uppercase tracking-widest">
|
||||||
|
CH_{String(index + 1).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[0, 1].map((i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="w-1.5 h-1.5 rounded-full bg-[color:var(--color-border)]"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/islands/Reveal.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { motion, useInView } from 'framer-motion';
|
||||||
|
import { useRef, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
delay?: number;
|
||||||
|
direction?: 'up' | 'down' | 'left' | 'right' | 'none';
|
||||||
|
once?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
up: { y: 30, opacity: 0, filter: 'blur(8px)' },
|
||||||
|
down: { y: -30, opacity: 0, filter: 'blur(8px)' },
|
||||||
|
left: { x: -30, opacity: 0, filter: 'blur(8px)' },
|
||||||
|
right: { x: 30, opacity: 0, filter: 'blur(8px)' },
|
||||||
|
none: { opacity: 0, filter: 'blur(8px)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Reveal({
|
||||||
|
children,
|
||||||
|
delay = 0,
|
||||||
|
direction = 'up',
|
||||||
|
once = true,
|
||||||
|
className = '',
|
||||||
|
}: Props) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const inView = useInView(ref, { once, margin: '-50px' });
|
||||||
|
const initial = variants[direction];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
className={className}
|
||||||
|
initial={initial}
|
||||||
|
animate={inView ? { x: 0, y: 0, opacity: 1, filter: 'blur(0px)' } : initial}
|
||||||
|
transition={{ duration: 0.8, delay, ease: [0.22, 1, 0.36, 1] }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/components/islands/RolesMorph.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
roles: string[];
|
||||||
|
interval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RolesMorph({ roles, interval = 2600 }: Props) {
|
||||||
|
const [idx, setIdx] = useState(0);
|
||||||
|
const [glitch, setGlitch] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (roles.length <= 1) return;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setGlitch(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setIdx((i) => (i + 1) % roles.length);
|
||||||
|
setGlitch(false);
|
||||||
|
}, 180);
|
||||||
|
}, interval);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [roles, interval]);
|
||||||
|
|
||||||
|
const current = roles[idx] ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-block font-display text-[color:var(--color-cyan)] glow-text-cyan transition-all duration-200 ${
|
||||||
|
glitch ? 'blur-[1px] opacity-60 skew-x-3' : ''
|
||||||
|
}`}
|
||||||
|
data-text={current}
|
||||||
|
>
|
||||||
|
{current}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
src/components/islands/Setlist.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
|
||||||
|
interface Experience {
|
||||||
|
id: string;
|
||||||
|
company: string;
|
||||||
|
logo?: string;
|
||||||
|
role: string;
|
||||||
|
period: string;
|
||||||
|
duration: string;
|
||||||
|
description: string;
|
||||||
|
missions: string[];
|
||||||
|
stack: string[];
|
||||||
|
type: string;
|
||||||
|
current?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: Experience[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function WaveformBar({ level }: { level: number }) {
|
||||||
|
// ASCII-like progress bar with blocks
|
||||||
|
const total = 12;
|
||||||
|
const filled = Math.round(level * total);
|
||||||
|
return (
|
||||||
|
<span className="font-mono tracking-tighter" aria-hidden="true">
|
||||||
|
{Array.from({ length: total }).map((_, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
opacity: i < filled ? 1 : 0.25,
|
||||||
|
color: i < filled ? 'var(--color-cyan)' : 'var(--color-text-dim)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
█
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Setlist({ items }: Props) {
|
||||||
|
const [open, setOpen] = useState<string | null>(items[0]?.id ?? null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="divide-y divide-[color:var(--color-border)] border-y border-[color:var(--color-border)]">
|
||||||
|
{items.map((exp, i) => {
|
||||||
|
const isOpen = open === exp.id;
|
||||||
|
const bpm = exp.current ? 174 : 160 - i * 10;
|
||||||
|
const level = exp.current ? 1 : Math.max(0.2, 1 - i * 0.15);
|
||||||
|
return (
|
||||||
|
<article key={exp.id} className={exp.current ? 'bg-[rgba(198,255,0,0.03)]' : ''}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(isOpen ? null : exp.id)}
|
||||||
|
className="w-full flex items-center gap-3 md:gap-6 px-3 md:px-5 py-4 text-left group hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
>
|
||||||
|
{/* Track number */}
|
||||||
|
<span className="font-mono text-xs md:text-sm text-[color:var(--color-text-dim)] w-8 tabular-nums">
|
||||||
|
{String(i + 1).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Status tag */}
|
||||||
|
<span
|
||||||
|
className="hidden sm:inline-flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-widest w-28"
|
||||||
|
style={{
|
||||||
|
color: exp.current ? 'var(--color-red)' : 'var(--color-text-dim)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{exp.current ? (
|
||||||
|
<>
|
||||||
|
<span className="led led-red animate-led-red"></span>
|
||||||
|
[now playing]
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="opacity-60">—</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Company + role */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-display text-sm md:text-base font-semibold truncate group-hover:text-[color:var(--color-cyan)] transition-colors">
|
||||||
|
{exp.company}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[10px] md:text-xs text-[color:var(--color-text-muted)] uppercase tracking-wider truncate">
|
||||||
|
{exp.role} · {exp.period}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Waveform progress */}
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<WaveformBar level={level} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BPM / duration */}
|
||||||
|
<div className="hidden sm:flex flex-col items-end gap-0.5 font-mono text-[10px] uppercase tracking-widest text-[color:var(--color-text-dim)] w-24 shrink-0">
|
||||||
|
<span>[{bpm} bpm]</span>
|
||||||
|
<span>[{exp.duration}]</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chevron */}
|
||||||
|
<span
|
||||||
|
className={`font-mono text-[color:var(--color-cyan)] transition-transform ${isOpen ? 'rotate-90' : ''}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
|
||||||
|
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="flex items-start gap-3">
|
||||||
|
{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">
|
||||||
|
<img
|
||||||
|
src={exp.logo}
|
||||||
|
alt={exp.company}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-widest text-[color:var(--color-text-dim)] space-y-1">
|
||||||
|
<div>[type: {exp.type}]</div>
|
||||||
|
<div>[duration: {exp.duration}]</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[color:var(--color-text)] mb-4">{exp.description}</p>
|
||||||
|
|
||||||
|
{exp.missions.length > 0 && (
|
||||||
|
<ul className="space-y-1.5 mb-4">
|
||||||
|
{exp.missions.map((m, idx) => (
|
||||||
|
<li key={idx} className="flex gap-2 text-sm">
|
||||||
|
<span className="font-mono text-[color:var(--color-acid)] shrink-0">›</span>
|
||||||
|
<span className="text-[color:var(--color-text-muted)]">{m}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{exp.stack.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{exp.stack.map((tech) => (
|
||||||
|
<span
|
||||||
|
key={tech}
|
||||||
|
className="font-mono text-[10px] uppercase tracking-wider px-2 py-1 text-[color:var(--color-cyan)] border border-[color:var(--color-cyan)]/30 bg-[color:var(--color-cyan)]/5 rounded-sm"
|
||||||
|
>
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
src/components/islands/SkeudCard.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
description: string;
|
||||||
|
image?: string;
|
||||||
|
category: string;
|
||||||
|
status: string;
|
||||||
|
url?: string;
|
||||||
|
stack: string[];
|
||||||
|
featured?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
index: number;
|
||||||
|
bpm?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusInfo(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'live':
|
||||||
|
return { label: 'LIVE', color: 'var(--color-acid)' };
|
||||||
|
case 'archived':
|
||||||
|
return { label: 'ARCHIVED', color: 'var(--color-text-dim)' };
|
||||||
|
case 'private':
|
||||||
|
return { label: 'DUBPLATE', color: 'var(--color-magenta)' };
|
||||||
|
case 'wip':
|
||||||
|
return { label: 'WIP', color: 'var(--color-violet)' };
|
||||||
|
default:
|
||||||
|
return { label: status.toUpperCase(), color: 'var(--color-cyan)' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SkeudCard({ project, index, bpm }: Props) {
|
||||||
|
const [hover, setHover] = useState(false);
|
||||||
|
const status = statusInfo(project.status);
|
||||||
|
const bpmValue = bpm ?? 120 + ((index * 13) % 80);
|
||||||
|
const isArchived = project.status === 'archived';
|
||||||
|
const isPrivate = project.status === 'private';
|
||||||
|
const clickable = Boolean(project.url) && !isArchived && !isPrivate;
|
||||||
|
const orderLabel = `#${String(index + 1).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const Wrap = clickable ? 'a' : 'div';
|
||||||
|
const wrapProps = clickable
|
||||||
|
? { href: project.url, target: '_blank', rel: 'noopener noreferrer' }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrap
|
||||||
|
{...wrapProps}
|
||||||
|
className="group relative block w-[280px] sm:w-[340px] flex-shrink-0"
|
||||||
|
onMouseEnter={() => setHover(true)}
|
||||||
|
onMouseLeave={() => setHover(false)}
|
||||||
|
style={{ perspective: 1200 } as CSSProperties}
|
||||||
|
>
|
||||||
|
{/* Order number en filigrane derrière */}
|
||||||
|
<span
|
||||||
|
className="absolute -top-10 -left-2 stencil text-7xl leading-none text-[color:var(--color-border)] select-none pointer-events-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{orderLabel}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Cover / pochette */}
|
||||||
|
<div
|
||||||
|
className="relative aspect-square bg-[color:var(--color-surface)] border border-[color:var(--color-border)] rounded-sm overflow-hidden transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
transform: hover ? 'rotateY(-3deg) rotateX(2deg)' : 'rotateY(0) rotateX(0)',
|
||||||
|
transformStyle: 'preserve-3d',
|
||||||
|
boxShadow: hover
|
||||||
|
? '0 30px 60px -20px rgba(0, 240, 255, 0.25), 0 0 1px rgba(0, 240, 255, 0.4)'
|
||||||
|
: '0 10px 30px -10px rgba(0,0,0,0.5)',
|
||||||
|
filter: isArchived ? 'grayscale(0.7)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project.image ? (
|
||||||
|
<img
|
||||||
|
src={project.image}
|
||||||
|
alt={project.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
style={{
|
||||||
|
transform: hover ? 'scale(1.04)' : 'scale(1)',
|
||||||
|
transition: 'transform 0.6s cubic-bezier(0.22, 1, 0.36, 1)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex flex-col items-center justify-center gap-4 warehouse-bg">
|
||||||
|
<span className="stencil text-4xl uppercase text-[color:var(--color-text-muted)]">
|
||||||
|
{project.title.slice(0, 1)}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[9px] uppercase tracking-widest text-[color:var(--color-text-dim)]">
|
||||||
|
// no cover
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stickers overlay */}
|
||||||
|
<div className="absolute top-3 left-3 flex flex-col gap-1.5">
|
||||||
|
<span
|
||||||
|
className="sticker"
|
||||||
|
style={{ color: status.color, transform: 'rotate(-3deg)' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="led mr-1.5"
|
||||||
|
style={{ background: status.color, boxShadow: `0 0 6px ${status.color}` }}
|
||||||
|
/>
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="sticker text-[color:var(--color-cyan)]"
|
||||||
|
style={{ transform: 'rotate(1deg)' }}
|
||||||
|
>
|
||||||
|
{bpmValue} BPM
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scanlines overlay */}
|
||||||
|
<div className="absolute inset-0 scanlines opacity-40 pointer-events-none"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vinyle qui sort par la droite */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute top-0 right-0 w-[88%] aspect-square pointer-events-none transition-transform duration-500 ease-out"
|
||||||
|
style={{
|
||||||
|
transform: hover
|
||||||
|
? 'translate(42%, 0) rotate(360deg)'
|
||||||
|
: 'translate(6%, 0) rotate(0)',
|
||||||
|
transitionProperty: 'transform',
|
||||||
|
transitionDuration: hover ? '1.4s' : '0.5s',
|
||||||
|
zIndex: -1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full h-full rounded-full"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'radial-gradient(circle at center, transparent 18%, #050508 19%, #0E0E14 55%, #050508 85%, #00000000 100%)',
|
||||||
|
boxShadow:
|
||||||
|
'0 0 0 1px rgba(255,255,255,0.04) inset, 0 15px 40px -8px rgba(0,0,0,0.8)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Grooves */}
|
||||||
|
<svg viewBox="0 0 100 100" className="w-full h-full">
|
||||||
|
{[40, 35, 30, 25, 20].map((r, i) => (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r={r}
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(255,255,255,0.04)"
|
||||||
|
strokeWidth="0.4"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r="11"
|
||||||
|
fill={hover ? 'var(--color-acid)' : 'var(--color-magenta)'}
|
||||||
|
style={{ transition: 'fill 0.4s' }}
|
||||||
|
/>
|
||||||
|
<circle cx="50" cy="50" r="1" fill="#000" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Infos en bas */}
|
||||||
|
<div className="mt-5 space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h3
|
||||||
|
className="font-display text-lg font-semibold group-hover:text-[color:var(--color-cyan)] transition-colors"
|
||||||
|
>
|
||||||
|
{project.title}
|
||||||
|
</h3>
|
||||||
|
<span className="font-mono text-[10px] text-[color:var(--color-text-dim)] uppercase tracking-widest">
|
||||||
|
[{project.category}]
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{project.subtitle && (
|
||||||
|
<p className="font-mono text-[10px] text-[color:var(--color-text-dim)] uppercase tracking-widest">
|
||||||
|
{project.subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-[color:var(--color-text-muted)] line-clamp-2">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
{project.stack.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 pt-1">
|
||||||
|
{project.stack.slice(0, 4).map((tech) => (
|
||||||
|
<span
|
||||||
|
key={tech}
|
||||||
|
className="font-mono text-[9px] uppercase tracking-wider px-1.5 py-0.5 text-[color:var(--color-text-muted)] border border-[color:var(--color-border)] rounded-sm"
|
||||||
|
>
|
||||||
|
{tech}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{project.stack.length > 4 && (
|
||||||
|
<span className="font-mono text-[9px] uppercase tracking-wider px-1.5 py-0.5 text-[color:var(--color-text-dim)]">
|
||||||
|
+{project.stack.length - 4}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Wrap>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/components/islands/SkillCard.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Icon } from '@iconify-icon/react';
|
||||||
|
import { useRef, type MouseEvent } from 'react';
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
level: 'primary' | 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
items: Item[];
|
||||||
|
accent?: 'cyan' | 'magenta';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SkillCard({ label, items, accent = 'cyan' }: Props) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
function onMove(e: MouseEvent<HTMLDivElement>) {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const rect = ref.current.getBoundingClientRect();
|
||||||
|
const x = (e.clientX - rect.left) / rect.width - 0.5;
|
||||||
|
const y = (e.clientY - rect.top) / rect.height - 0.5;
|
||||||
|
ref.current.style.transform = `perspective(1000px) rotateX(${-y * 6}deg) rotateY(${x * 6}deg) translateZ(0)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLeave() {
|
||||||
|
if (!ref.current) return;
|
||||||
|
ref.current.style.transform = 'perspective(1000px) rotateX(0) rotateY(0)';
|
||||||
|
}
|
||||||
|
|
||||||
|
const accentColor = accent === 'cyan' ? 'var(--color-cyan)' : 'var(--color-magenta)';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
onMouseMove={onMove}
|
||||||
|
onMouseLeave={onLeave}
|
||||||
|
className="card-neon h-full flex flex-col transition-transform duration-300 ease-out will-change-transform"
|
||||||
|
style={{ borderColor: `${accentColor}33` }}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
className="font-display uppercase text-xs tracking-[0.2em] mb-5"
|
||||||
|
style={{ color: accentColor, textShadow: `0 0 10px ${accentColor}66` }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-4 gap-x-3 gap-y-5 flex-1 content-start">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.name}
|
||||||
|
className="flex flex-col items-center text-center group"
|
||||||
|
title={item.name}
|
||||||
|
>
|
||||||
|
<div className="w-11 h-11 flex items-center justify-center rounded-md bg-[color:var(--color-bg)] border border-[color:var(--color-border)] group-hover:border-[color:var(--color-cyan)] transition-all group-hover:glow-cyan">
|
||||||
|
<Icon icon={item.icon} width="22" height="22" />
|
||||||
|
</div>
|
||||||
|
<span className="mt-1.5 text-[10px] font-mono text-[color:var(--color-text-muted)] group-hover:text-[color:var(--color-text)] transition-colors truncate max-w-full">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/components/islands/SmoothScroll.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import Lenis from 'lenis';
|
||||||
|
|
||||||
|
export default function SmoothScroll() {
|
||||||
|
useEffect(() => {
|
||||||
|
const lenis = new Lenis({
|
||||||
|
duration: 1.2,
|
||||||
|
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||||
|
smoothWheel: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let rafId: number;
|
||||||
|
function raf(time: number) {
|
||||||
|
lenis.raf(time);
|
||||||
|
rafId = requestAnimationFrame(raf);
|
||||||
|
}
|
||||||
|
rafId = requestAnimationFrame(raf);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
lenis.destroy();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
54
src/components/islands/SplitReveal.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string;
|
||||||
|
className?: string;
|
||||||
|
delay?: number;
|
||||||
|
stagger?: number;
|
||||||
|
as?: 'span' | 'h1' | 'h2' | 'h3' | 'p' | 'div';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SplitReveal({
|
||||||
|
text,
|
||||||
|
className = '',
|
||||||
|
delay = 0,
|
||||||
|
stagger = 0.035,
|
||||||
|
as = 'span',
|
||||||
|
}: Props) {
|
||||||
|
const chars = Array.from(text);
|
||||||
|
const MotionTag = motion[as] as typeof motion.span;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MotionTag
|
||||||
|
className={className}
|
||||||
|
initial="hidden"
|
||||||
|
animate="show"
|
||||||
|
aria-label={text}
|
||||||
|
variants={{
|
||||||
|
hidden: {},
|
||||||
|
show: {
|
||||||
|
transition: { staggerChildren: stagger, delayChildren: delay },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{chars.map((ch, i) => (
|
||||||
|
<motion.span
|
||||||
|
key={i}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ display: 'inline-block', whiteSpace: ch === ' ' ? 'pre' : 'normal' }}
|
||||||
|
variants={{
|
||||||
|
hidden: { opacity: 0, y: '0.4em', filter: 'blur(8px)' },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
filter: 'blur(0px)',
|
||||||
|
transition: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ch === ' ' ? '\u00A0' : ch}
|
||||||
|
</motion.span>
|
||||||
|
))}
|
||||||
|
</MotionTag>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/components/islands/Waveform.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
bars?: number;
|
||||||
|
color?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Waveform({
|
||||||
|
bars = 64,
|
||||||
|
color = 'var(--color-cyan)',
|
||||||
|
className = '',
|
||||||
|
}: Props) {
|
||||||
|
const ref = useRef<SVGSVGElement>(null);
|
||||||
|
const barRefs = useRef<(SVGRectElement | null)[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||||
|
|
||||||
|
let rafId: number;
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const t = (now - start) / 1000;
|
||||||
|
barRefs.current.forEach((bar, i) => {
|
||||||
|
if (!bar) return;
|
||||||
|
// Multi-sine combination → pulse organique
|
||||||
|
const x = i / bars;
|
||||||
|
const amp =
|
||||||
|
0.5 +
|
||||||
|
0.25 * Math.sin(t * 1.7 + x * 8) +
|
||||||
|
0.15 * Math.sin(t * 3.1 + x * 16) +
|
||||||
|
0.1 * Math.sin(t * 0.8 + x * 4);
|
||||||
|
const h = Math.max(0.08, Math.min(1, amp));
|
||||||
|
bar.setAttribute('transform', `scale(1, ${h})`);
|
||||||
|
});
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(rafId);
|
||||||
|
}, [bars]);
|
||||||
|
|
||||||
|
const barWidth = 100 / bars;
|
||||||
|
const gap = barWidth * 0.3;
|
||||||
|
const rectWidth = barWidth - gap;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
ref={ref}
|
||||||
|
viewBox="0 0 100 20"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
className={className}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{Array.from({ length: bars }).map((_, i) => (
|
||||||
|
<g key={i} transform={`translate(${i * barWidth + gap / 2}, 10)`}>
|
||||||
|
<rect
|
||||||
|
ref={(el) => {
|
||||||
|
barRefs.current[i] = el;
|
||||||
|
}}
|
||||||
|
x={0}
|
||||||
|
y={-10}
|
||||||
|
width={rectWidth}
|
||||||
|
height={20}
|
||||||
|
fill={color}
|
||||||
|
style={{
|
||||||
|
transformBox: 'fill-box',
|
||||||
|
transformOrigin: 'center',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/content/schemas/experiences.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ExperienceSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
company: z.string(),
|
||||||
|
logo: z.string().optional(),
|
||||||
|
role: z.string(),
|
||||||
|
period: z.string(),
|
||||||
|
duration: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
missions: z.array(z.string()).default([]),
|
||||||
|
stack: z.array(z.string()).default([]),
|
||||||
|
type: z.enum(['cdi', 'cdd', 'stage', 'alternance', 'freelance']).default('cdi'),
|
||||||
|
current: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ExperiencesSchema = z.object({
|
||||||
|
items: z.array(ExperienceSchema),
|
||||||
|
skills: z
|
||||||
|
.object({
|
||||||
|
technical: z.array(z.string()).default([]),
|
||||||
|
soft: z.array(z.string()).default([]),
|
||||||
|
})
|
||||||
|
.default({ technical: [], soft: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Experience = z.infer<typeof ExperienceSchema>;
|
||||||
|
export type Experiences = z.infer<typeof ExperiencesSchema>;
|
||||||
17
src/content/schemas/formations.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const FormationSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
school: z.string(),
|
||||||
|
schoolUrl: z.string().url().optional(),
|
||||||
|
period: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FormationsSchema = z.object({
|
||||||
|
items: z.array(FormationSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Formation = z.infer<typeof FormationSchema>;
|
||||||
|
export type Formations = z.infer<typeof FormationsSchema>;
|
||||||