refonte: portfolio statique Astro (design moderne développeur)
Some checks failed
Deploy production / build-deploy (push) Failing after 56s

- Remplace l'ancien Astro hybrid + admin par un site one-page statique (sortie static)
- Thème clair/sombre sans flash, contenu typé (src/data/content.ts), zéro admin
- Icônes simple-icons (SVG monochrome), polices auto-hébergées (RGPD)
- SEO complet : canonical, Open Graph, Twitter Card, JSON-LD Person, robots.txt, sitemap.xml, image OG 1200x630
- CI Gitea de déploiement FTPS vers Plesk (.gitea/workflows/prod.yml)
This commit is contained in:
Johan LEROY
2026-06-19 16:03:09 +02:00
parent dc4e3820c4
commit ce17bf590b
189 changed files with 1836 additions and 9872 deletions

View File

@@ -1,59 +0,0 @@
import { SignJWT, jwtVerify } from 'jose';
import bcrypt from 'bcryptjs';
// Astro dev (Vite) expose les .env via import.meta.env ; Node runtime prod via process.env.
// On lit les deux pour couvrir tous les cas (dev, preview, prod Plesk Node).
const env = {
...(typeof process !== 'undefined' ? process.env : {}),
...(import.meta.env as Record<string, string | undefined>),
};
const ADMIN_PASSWORD_HASH = (env.ADMIN_PASSWORD_HASH as string | undefined) ?? '';
const JWT_SECRET = (env.ADMIN_JWT_SECRET as string | undefined) ?? '';
// Lazy : on ne throw pas au chargement du module (Astro build importe ce fichier
// en SSR sans forcément avoir le .env). On throw au premier usage runtime.
let cachedKey: Uint8Array | null = null;
function getSecretKey(): Uint8Array {
if (cachedKey) return cachedKey;
if (!JWT_SECRET || JWT_SECRET.length < 32) {
throw new Error(
'[auth] ADMIN_JWT_SECRET manquant ou trop court (>= 32 chars). ' +
'Générer via `npm run hash:password -- <motdepasse>` et reporter dans .env.',
);
}
cachedKey = new TextEncoder().encode(JWT_SECRET);
return cachedKey;
}
export const ADMIN_COOKIE = 'portfolio_admin';
export async function verifyPassword(password: string): Promise<boolean> {
if (!ADMIN_PASSWORD_HASH) {
console.error('[auth] ADMIN_PASSWORD_HASH manquant dans .env');
return false;
}
return bcrypt.compare(password, ADMIN_PASSWORD_HASH);
}
export async function createSession(): Promise<string> {
return new SignJWT({ role: 'admin' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(getSecretKey());
}
export async function verifySession(token: string | undefined): Promise<boolean> {
if (!token) return false;
try {
await jwtVerify(token, getSecretKey());
return true;
} catch {
return false;
}
}
export function hashPasswordCLI(password: string): string {
return bcrypt.hashSync(password, 12);
}

View File

@@ -1,54 +0,0 @@
import { readFile, writeFile, mkdir, copyFile, access } from 'node:fs/promises';
import { join } from 'node:path';
import { CONTENT_SCHEMAS, type ContentKey } from '../content/schemas';
import type { z } from 'zod';
const DATA_CONTENT_DIR = join(process.cwd(), 'data', 'content');
const SEED_CONTENT_DIR = join(process.cwd(), 'public', 'content');
type ContentData<K extends ContentKey> = z.infer<(typeof CONTENT_SCHEMAS)[K]>;
async function ensureContentFile(key: ContentKey): Promise<string> {
const target = join(DATA_CONTENT_DIR, `${key}.json`);
try {
await access(target);
return target;
} catch {
await mkdir(DATA_CONTENT_DIR, { recursive: true });
const seed = join(SEED_CONTENT_DIR, `${key}.json`);
await copyFile(seed, target);
return target;
}
}
export async function loadContent<K extends ContentKey>(key: K): Promise<ContentData<K>> {
const filePath = await ensureContentFile(key);
const raw = await readFile(filePath, 'utf-8');
const parsed = JSON.parse(raw);
const schema = CONTENT_SCHEMAS[key];
return schema.parse(parsed) as ContentData<K>;
}
export async function saveContent<K extends ContentKey>(
key: K,
data: unknown,
): Promise<ContentData<K>> {
const schema = CONTENT_SCHEMAS[key];
const validated = schema.parse(data) as ContentData<K>;
const filePath = await ensureContentFile(key);
await writeFile(filePath, JSON.stringify(validated, null, 2) + '\n', 'utf-8');
return validated;
}
export async function loadAll() {
const [profile, skills, experiences, formations, projects, interests, site] = await Promise.all([
loadContent('profile'),
loadContent('skills'),
loadContent('experiences'),
loadContent('formations'),
loadContent('projects'),
loadContent('interests'),
loadContent('site'),
]);
return { profile, skills, experiences, formations, projects, interests, site };
}

82
src/lib/techIcons.ts Normal file
View File

@@ -0,0 +1,82 @@
/* ============================================================
Icônes des technos via le paquet `simple-icons` (chemins SVG
officiels). Rendu monochrome (currentColor) par <TechIcon/>,
donc adaptatif au thème clair/sombre. Les technos absentes de
simple-icons (Java, C#) utilisent un monogramme.
============================================================ */
import {
siTypescript, siJavascript, siPython, siPhp, siNestjs, siNuxtdotjs, siNextdotjs,
siVuedotjs, siAngular, siTailwindcss, siAstro, siMariadb, siMysql, siPostgresql,
siRedis, siDocker, siGit, siAmazonwebservices, siTerraform, siNginx, siPlesk,
siWebstorm, siPhpstorm, siPycharm, siClaude, siDebian, siUbuntu, siApple,
siCplusplus, siDart, siKotlin, siSpring, siSymfony, siExpress, siFastapi,
siFlutter, siGraphql, siVuetify, siBootstrap, siMongodb, siSqlite, siFirebase,
siSwagger, siPostman, siStripe,
} from "simple-icons";
export type TechDef = { path: string } | { mono: string };
export const techIcons: Record<string, TechDef> = {
// Langages
"TypeScript": { path: siTypescript.path },
"JavaScript": { path: siJavascript.path },
"Java": { mono: "Ja" }, // absent de simple-icons
"Python": { path: siPython.path },
"PHP": { path: siPhp.path },
// Frameworks
"NestJS": { path: siNestjs.path },
"Nuxt": { path: siNuxtdotjs.path },
"Next.js": { path: siNextdotjs.path },
"Vue.js": { path: siVuedotjs.path },
"Angular": { path: siAngular.path },
"Tailwind CSS": { path: siTailwindcss.path },
"Astro": { path: siAstro.path },
// Bases de données
"MariaDB": { path: siMariadb.path },
"MySQL": { path: siMysql.path },
"PostgreSQL": { path: siPostgresql.path },
"Redis": { path: siRedis.path },
"MongoDB": { path: siMongodb.path },
"SQLite": { path: siSqlite.path },
// Outils & DevOps
"Docker": { path: siDocker.path },
"Git": { path: siGit.path },
"AWS": { path: siAmazonwebservices.path },
"Terraform": { path: siTerraform.path },
"NGINX": { path: siNginx.path },
"Plesk": { path: siPlesk.path },
// IDE & Éditeurs
"WebStorm": { path: siWebstorm.path },
"PhpStorm": { path: siPhpstorm.path },
"PyCharm": { path: siPycharm.path },
"Claude Code": { path: siClaude.path },
// Systèmes
"Debian": { path: siDebian.path },
"Ubuntu": { path: siUbuntu.path },
"macOS": { path: siApple.path },
// Secondaire — langages
"C#": { mono: "C#" }, // absent de simple-icons
"C++": { path: siCplusplus.path },
"Dart": { path: siDart.path },
"Kotlin": { path: siKotlin.path },
// Secondaire — frameworks & libs
"Spring": { path: siSpring.path },
"Symfony": { path: siSymfony.path },
"Express.js": { path: siExpress.path },
"FastAPI": { path: siFastapi.path },
"Flutter": { path: siFlutter.path },
"GraphQL": { path: siGraphql.path },
"Vuetify": { path: siVuetify.path },
"Bootstrap": { path: siBootstrap.path },
// Secondaire — services & outils
"Firebase": { path: siFirebase.path },
"Swagger": { path: siSwagger.path },
"Postman": { path: siPostman.path },
"Stripe": { path: siStripe.path },
};
/** Monogramme de repli pour une techno sans icône (2 premières lettres). */
export function monogram(name: string): string {
const clean = name.replace(/[^A-Za-z0-9]/g, "");
return (clean.charAt(0).toUpperCase() + (clean.charAt(1) || "").toLowerCase()) || "?";
}