diff --git a/.gitignore b/.gitignore index d55cdc6..5ec0827 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,7 @@ pnpm-debug.log* # temp *.log + +# runtime data (uploads + JSON content edited via /admin) +# kept on the server, not deployed via FTP +data/ diff --git a/package-lock.json b/package-lock.json index 814bacc..54a978a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "portfolio", - "version": "0.0.1", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "portfolio", - "version": "0.0.1", + "version": "1.0.0", "dependencies": { "@astrojs/node": "^10.0.5", "@astrojs/react": "^5.0.3", + "@hookform/resolvers": "^5.2.2", "@iconify-icon/react": "^3.0.3", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.6.0", @@ -25,6 +26,7 @@ "lucide-react": "^1.8.0", "react": "^19.2.5", "react-dom": "^19.2.5", + "react-hook-form": "^7.75.0", "tailwindcss": "^4.2.2", "three": "^0.184.0", "zod": "^4.3.6" @@ -1084,6 +1086,18 @@ "node": ">=18" } }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@iconify-icon/react": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@iconify-icon/react/-/react-3.0.3.tgz", @@ -2181,6 +2195,12 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", @@ -6045,6 +6065,22 @@ "react": "^19.2.5" } }, + "node_modules/react-hook-form": { + "version": "7.75.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.75.0.tgz", + "integrity": "sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", diff --git a/package.json b/package.json index 61681ff..173916e 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dependencies": { "@astrojs/node": "^10.0.5", "@astrojs/react": "^5.0.3", + "@hookform/resolvers": "^5.2.2", "@iconify-icon/react": "^3.0.3", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.6.0", @@ -32,6 +33,7 @@ "lucide-react": "^1.8.0", "react": "^19.2.5", "react-dom": "^19.2.5", + "react-hook-form": "^7.75.0", "tailwindcss": "^4.2.2", "three": "^0.184.0", "zod": "^4.3.6" diff --git a/public/content/experiences.json b/public/content/experiences.json index 4f44e8e..4627b15 100644 --- a/public/content/experiences.json +++ b/public/content/experiences.json @@ -3,7 +3,7 @@ { "id": "almeria", "company": "Almeria", - "logo": "/img/company/almeria.png", + "logo": "/api/files/img/company/almeria.png", "role": "Développeur", "period": "Depuis 2023", "duration": "3 ans", @@ -20,7 +20,7 @@ { "id": "cerema", "company": "CEREMA", - "logo": "/img/company/cerema.png", + "logo": "/api/files/img/company/cerema.png", "role": "Technicien évaluation mesure trafic & mobilité", "period": "2021 - 2023", "duration": "2 ans", @@ -37,7 +37,7 @@ { "id": "sncf", "company": "Réseau SNCF", - "logo": "/img/company/sncf.png", + "logo": "/api/files/img/company/sncf.png", "role": "Agent télécom", "period": "2019 - 2021", "duration": "2 ans", @@ -54,7 +54,7 @@ { "id": "novoferm", "company": "Novoferm", - "logo": "/img/company/novoferm.png", + "logo": "/api/files/img/company/novoferm.png", "role": "Stage développeur", "period": "2019", "duration": "7 semaines", @@ -67,7 +67,7 @@ { "id": "pivotpoint", "company": "Pivot Point", - "logo": "/img/company/pivotpoint.png", + "logo": "/api/files/img/company/pivotpoint.png", "role": "Stage développeur", "period": "2018", "duration": "1 semaine", @@ -80,7 +80,7 @@ { "id": "actemium", "company": "Actemium", - "logo": "/img/company/actemium.png", + "logo": "/api/files/img/company/actemium.png", "role": "Stage développeur", "period": "2018", "duration": "1 semaine", diff --git a/public/content/profile.json b/public/content/profile.json index a96161a..8c1f9cc 100644 --- a/public/content/profile.json +++ b/public/content/profile.json @@ -1,18 +1,26 @@ { "name": "Johan Leroy", - "title": "Développeur Fullstack", + "title": "Développeur", "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"], + "photo": "/api/files/img/pp/pp3.png", + "photos": [ + "/api/files/img/pp/pp1.png", + "/api/files/img/pp/pp2.png", + "/api/files/img/pp/pp3.png" + ], + "roles": [ + "Dev", + "DJ", + "Dev" + ], "cta": [ { "label": "Télécharger mon CV", - "href": "/assets/cv.pdf", + "href": "/api/files/assets/cv.pdf", "variant": "primary", "external": true }, @@ -28,7 +36,8 @@ "email": "contact@johanleroy.fr", "github": "https://github.com/JohanLeroy", "gitea": "https://git.lidge.fr/johanleroy", + "linkedin": "https://www.linkedin.com/in/johan-leroy-472409180/", "website": "https://johanleroy.fr" }, - "cv": "/assets/cv.pdf" + "cv": "/api/files/assets/cv.pdf" } diff --git a/public/content/projects.json b/public/content/projects.json index 4574227..5f24ce4 100644 --- a/public/content/projects.json +++ b/public/content/projects.json @@ -5,7 +5,7 @@ "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", + "image": "/api/files/img/projet/techos.png", "category": "web", "status": "live", "url": "https://techos-asso.fr", @@ -50,7 +50,7 @@ "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", + "image": "/api/files/img/projet/lplv.png", "category": "web", "status": "archived", "stack": ["Java Spring Boot", "Thymeleaf", "Bootstrap", "MariaDB"], @@ -61,7 +61,7 @@ "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", + "image": "/api/files/img/projet/monvoisingeek.png", "category": "web", "status": "archived", "stack": ["Angular", "Tailwind CSS", "Express.js", "MariaDB"], diff --git a/public/content/site.json b/public/content/site.json index df28dee..e9d0c33 100644 --- a/public/content/site.json +++ b/public/content/site.json @@ -16,7 +16,7 @@ "freelance", "nantes" ], - "ogImage": "/img/logo/android-chrome-512x512.png", + "ogImage": "/api/files/img/logo/android-chrome-512x512.png", "features": { "hero3D": false, "smoothScroll": true, diff --git a/src/components/Footer.astro b/src/components/Footer.astro index b688e27..49203b8 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -116,6 +116,20 @@ const year = new Date().getFullYear(); ) } + { + profile.socials.linkedin && ( +
  • + + > linkedin ↗ + +
  • + ) + } { profile.socials.website && (
  • diff --git a/src/components/Header.astro b/src/components/Header.astro index ab0b5af..51e7290 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -18,7 +18,7 @@ const current = Astro.url.pathname;
    - diff --git a/src/pages/admin/index.astro b/src/pages/admin/index.astro index c78e25a..6596b5e 100644 --- a/src/pages/admin/index.astro +++ b/src/pages/admin/index.astro @@ -64,7 +64,7 @@ const sectionLabels: Record -
    +
    { sections.map((s) => { const meta = sectionLabels[s] ?? { label: s, icon: '📄', description: '' }; diff --git a/src/pages/api/files/[...path].ts b/src/pages/api/files/[...path].ts new file mode 100644 index 0000000..4391096 --- /dev/null +++ b/src/pages/api/files/[...path].ts @@ -0,0 +1,50 @@ +export const prerender = false; + +import type { APIRoute } from 'astro'; +import { readFile, stat } from 'node:fs/promises'; +import { join, resolve, extname } from 'node:path'; + +const UPLOADS_ROOT = resolve(process.cwd(), 'data', 'uploads'); + +const MIME: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + '.gif': 'image/gif', + '.pdf': 'application/pdf', +}; + +export const GET: APIRoute = async ({ params }) => { + const rawPath = (params.path as string | undefined) ?? ''; + if (!rawPath) { + return new Response('not found', { status: 404 }); + } + + const target = resolve(join(UPLOADS_ROOT, rawPath)); + if (!target.startsWith(UPLOADS_ROOT + '/') && target !== UPLOADS_ROOT) { + return new Response('forbidden', { status: 403 }); + } + + try { + const stats = await stat(target); + if (!stats.isFile()) { + return new Response('not found', { status: 404 }); + } + } catch { + return new Response('not found', { status: 404 }); + } + + const ext = extname(target).toLowerCase(); + const type = MIME[ext] ?? 'application/octet-stream'; + const buffer = await readFile(target); + + return new Response(buffer, { + headers: { + 'Content-Type': type, + 'Content-Length': String(buffer.length), + 'Cache-Control': 'public, max-age=300, must-revalidate', + }, + }); +}; diff --git a/src/pages/api/upload.ts b/src/pages/api/upload.ts index 729e72e..cc33b30 100644 --- a/src/pages/api/upload.ts +++ b/src/pages/api/upload.ts @@ -4,8 +4,15 @@ import type { APIRoute } from 'astro'; import { writeFile, mkdir } from 'node:fs/promises'; import { join, extname, basename } from 'node:path'; -const UPLOAD_DIR = join(process.cwd(), 'public', 'img'); -const ALLOWED_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.svg', '.gif']); +const UPLOAD_ROOT = join(process.cwd(), 'data', 'uploads'); + +const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.svg', '.gif']); +const DOCUMENT_EXT = new Set(['.pdf']); + +const MAX_SIZE: Record<'image' | 'document', number> = { + image: 5 * 1024 * 1024, + document: 8 * 1024 * 1024, +}; function sanitize(filename: string): string { const name = basename(filename).replace(/[^a-zA-Z0-9._-]/g, '_'); @@ -16,25 +23,33 @@ export const POST: APIRoute = async ({ request }) => { const form = await request.formData(); const file = form.get('file'); const folder = (form.get('folder') as string | null) ?? ''; + const kindRaw = (form.get('kind') as string | null) ?? 'image'; + const kind: 'image' | 'document' = kindRaw === 'document' ? 'document' : 'image'; if (!(file instanceof File)) { return new Response(JSON.stringify({ error: 'no_file' }), { status: 400 }); } const ext = extname(file.name).toLowerCase(); - if (!ALLOWED_EXT.has(ext)) { + const allowed = kind === 'document' ? DOCUMENT_EXT : IMAGE_EXT; + if (!allowed.has(ext)) { return new Response(JSON.stringify({ error: 'invalid_extension' }), { status: 400 }); } + if (file.size > MAX_SIZE[kind]) { + return new Response(JSON.stringify({ error: 'file_too_large' }), { status: 400 }); + } + const safeFolder = folder.replace(/[^a-zA-Z0-9_-]/g, ''); - const targetDir = safeFolder ? join(UPLOAD_DIR, safeFolder) : UPLOAD_DIR; + const bucket = kind === 'document' ? 'assets' : 'img'; + const targetDir = safeFolder ? join(UPLOAD_ROOT, bucket, safeFolder) : join(UPLOAD_ROOT, bucket); await mkdir(targetDir, { recursive: true }); const filename = sanitize(file.name); const buffer = Buffer.from(await file.arrayBuffer()); await writeFile(join(targetDir, filename), buffer); - const url = `/img/${safeFolder ? safeFolder + '/' : ''}${filename}`; + const url = `/api/files/${bucket}/${safeFolder ? safeFolder + '/' : ''}${filename}`; return new Response(JSON.stringify({ ok: true, url }), { headers: { 'Content-Type': 'application/json' }, }); diff --git a/src/pages/experience.astro b/src/pages/experience.astro index f772a2f..268e51e 100644 --- a/src/pages/experience.astro +++ b/src/pages/experience.astro @@ -25,7 +25,7 @@ const yearsTotal = currentYear - firstYear; // setlist.current

    -

    +

    Mon expérience

    @@ -51,7 +51,7 @@ const yearsTotal = currentYear - firstYear;

    // patch bay

    -

    +

    Compétences pros

    diff --git a/src/pages/formations.astro b/src/pages/formations.astro index 2806f11..b399f40 100644 --- a/src/pages/formations.astro +++ b/src/pages/formations.astro @@ -24,7 +24,7 @@ function extractLocation(school: string): string { // bpm log · crate digging

    -

    +

    Mes formations

    @@ -53,8 +53,8 @@ function extractLocation(school: string): string { client:visible >
    -
    -
    +
    +
    DATE
    {f.period}
    @@ -141,3 +141,24 @@ function extractLocation(school: string): string {
    + + diff --git a/src/pages/index.astro b/src/pages/index.astro index f3e806b..2409681 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -32,77 +32,111 @@ const { profile, skills, interests, site } = await loadAll();
    -
    -

    - - > frequency: 174 bpm · hardtek -

    +
    +
    +

    + + > frequency: 174 bpm · hardtek +

    -

    - - -

    +

    + + +

    -
    - [ - - ] +
    + [ + + ] +
    + +

    + // code propre, sono sale +

    + +

    + {profile.bio} +

    + +
    + { + profile.hero.cta.map((btn) => { + const href = btn.label.toLowerCase().includes('cv') + ? (profile.cv ?? btn.href) + : btn.href; + return ( + + {btn.label} + + + + + ); + }) + } +
    -

    - // code propre, sono sale -

    - -

    - {profile.bio} -

    - -
    - { - profile.hero.cta.map((btn) => ( - - {btn.label} - - - - - )) - } -
    + {profile.hero.photo && ( +
    +
    + + + + +
    + {profile.name} + + +
    +
    +
    + )}
    @@ -140,7 +174,7 @@ const { profile, skills, interests, site } = await loadAll();
    -
    +
    { skills.primary.map((cat, i) => ( @@ -174,7 +208,7 @@ const { profile, skills, interests, site } = await loadAll();
    -
    +
    { skills.secondary.map((cat, i) => ( @@ -209,7 +243,7 @@ const { profile, skills, interests, site } = await loadAll();
    -
    +
    { interests.items.map((interest, i) => { const color = @@ -242,3 +276,102 @@ const { profile, skills, interests, site } = await loadAll();
    + + diff --git a/src/pages/projets.astro b/src/pages/projets.astro index 9474dab..6bd36d8 100644 --- a/src/pages/projets.astro +++ b/src/pages/projets.astro @@ -21,7 +21,7 @@ const others = projects.items.filter((p) => !p.featured); // crate v1.0 · digging

    -

    +

    Mes projets