mirror of
https://github.com/Floriansylvain/compact-portfolio.git
synced 2026-08-19 11:43:19 +02:00
feat: initialize server with essential files and configurations
- Added package.json and package-lock.json for project dependencies and metadata. - Created server.js to handle HTTP requests, serve files, and implement security headers. - Implemented minification functions for HTML, CSS, and JavaScript to optimize performance. - Added style.css for styling the application with a responsive design. - Introduced script.js for handling navigation and reveal animations. - Removed unused profile.webp image.
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 334 B After Width: | Height: | Size: 1.0 KiB |
File diff suppressed because one or more lines are too long
+201
-492
@@ -4,532 +4,241 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Portfolio de Florian Sylvain, développeur web fullstack actuellement en poste." />
|
<meta name="description"
|
||||||
|
content="Portfolio de Florian Sylvain, développeur web fullstack. Démonstration de design sobre, moderne et réactif." />
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.webp" />
|
<link rel="icon" type="image/x-icon" href="/favicon.webp" />
|
||||||
<link rel="canonical" href="https://floriansylvain.fr/" />
|
<link rel="canonical" href="https://floriansylvain.fr/" />
|
||||||
|
<meta name="theme-color" content="#0e120e" />
|
||||||
<title>Portfolio Florian Sylvain - Développeur web fullstack</title>
|
<title>Portfolio Florian Sylvain - Développeur web fullstack</title>
|
||||||
|
|
||||||
<style>
|
<link rel="stylesheet" href="style.css" media="print" onload="this.media='all'; this.onload=null;" />
|
||||||
:root {
|
<noscript>
|
||||||
--background: #0e120e;
|
<link rel="stylesheet" href="style.css" />
|
||||||
--foreground: #e2f1e2;
|
</noscript>
|
||||||
--surface: #121a12;
|
|
||||||
--surface-2: #162116;
|
|
||||||
--border: #161f16;
|
|
||||||
--accent: #7fc27f;
|
|
||||||
--accent-hover: #a7e6a7;
|
|
||||||
--accent-visited: #5e8a5e;
|
|
||||||
--muted: #8aa78a;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
font-family: 'Inter', 'Segoe UI', Arial, sans-serif;
|
|
||||||
line-height: 1.6;
|
|
||||||
max-width: 1000px;
|
|
||||||
margin: auto;
|
|
||||||
padding: 16px;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
#header h1 {
|
|
||||||
font-size: clamp(1.6rem, 3.5vw, 2.25rem);
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: .2px;
|
|
||||||
color: var(--accent-hover);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#header img {
|
|
||||||
max-width: 128px;
|
|
||||||
height: auto;
|
|
||||||
display: block;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
#header div {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
table-layout: fixed;
|
|
||||||
border-collapse: separate;
|
|
||||||
border-spacing: 0;
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
#experiences col:first-child,
|
|
||||||
#projets col:first-child {
|
|
||||||
width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#experiences col:last-child,
|
|
||||||
#projets col:last-child {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: 10px 12px;
|
|
||||||
text-align: left;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
thead th {
|
|
||||||
background: var(--surface-2);
|
|
||||||
color: var(--accent);
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: .2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody td {
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody tr:nth-child(odd) td {
|
|
||||||
background: color-mix(in srgb, var(--surface) 92%, white 8%);
|
|
||||||
}
|
|
||||||
|
|
||||||
caption {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: .95em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: .06em;
|
|
||||||
padding: 8px 0 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: underline;
|
|
||||||
text-decoration-color: var(--accent);
|
|
||||||
text-decoration-thickness: 0.08em;
|
|
||||||
text-underline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
color: var(--accent-hover);
|
|
||||||
text-decoration-color: var(--accent-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
a:visited {
|
|
||||||
color: var(--accent-visited);
|
|
||||||
}
|
|
||||||
|
|
||||||
a:focus-visible {
|
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul,
|
|
||||||
ol {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav li {
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
section h2,
|
|
||||||
footer h2 {
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: clamp(1.2rem, 2.2vw, 1.4rem);
|
|
||||||
margin-top: 24px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: .2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
border-top: 1px solid #1e2a1e;
|
|
||||||
margin-top: 32px;
|
|
||||||
padding-top: 12px;
|
|
||||||
font-size: .95em;
|
|
||||||
color: var(--muted);
|
|
||||||
background: var(--background);
|
|
||||||
}
|
|
||||||
|
|
||||||
#experiences thead th,
|
|
||||||
#projets thead th {
|
|
||||||
font-size: large;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
|
||||||
body {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#header div {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
table,
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
font-size: .95em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body id="top">
|
||||||
<header id="header">
|
<header class="site-header">
|
||||||
<h1>Portfolio de Florian Sylvain</h1>
|
<div class="container inner">
|
||||||
<div>
|
<a class="brand" href="#top" aria-label="Accueil">
|
||||||
<img width="128" height="120" src="profile.webp" alt="Photo de Florian Sylvain" />
|
<span class="logo">FS</span>
|
||||||
<p>
|
<span>Florian Sylvain</span>
|
||||||
Diplômé d'un Bachelor Développeur Web à l'ESD Paris,
|
</a>
|
||||||
autoentrepreneur, je suis passionné par l'informatique.
|
<nav class="site-nav" id="primaryNav" aria-label="Navigation principale">
|
||||||
Actuellement en poste à temps plein en CDI chez DOLLEN
|
<a href="#about">À propos</a>
|
||||||
(Bonnueil-sur-Marne) en tant que développeur web fullstack.
|
<a href="#skills">Compétences</a>
|
||||||
</p>
|
<a href="#experience">Expérience</a>
|
||||||
|
<a href="#projects">Projets</a>
|
||||||
|
<a href="#contact">Contact</a>
|
||||||
|
</nav>
|
||||||
|
<button class="menu-toggle" id="menuToggle" aria-controls="primaryNav" aria-expanded="false" type="button">
|
||||||
|
<span class="bars"><span></span></span>
|
||||||
|
Menu
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav>
|
<main class="container">
|
||||||
<h2>Navigation</h2>
|
<aside class="banner reveal" role="note" aria-label="Annonce d'emploi urgente">
|
||||||
<ol>
|
<span class="badge-urgent">Urgent</span>
|
||||||
<li><a href="#competences">Compétences et qualifications</a></li>
|
<span>À la recherche d'opportunités <strong>CDI, CDD, Freelance </strong>en développement
|
||||||
<li><a href="#diplomes">Diplômes obtenus</a></li>
|
<strong>Back</strong>, <strong>Front</strong>, <strong>DevOps</strong>, etc.</span>
|
||||||
<li><a href="#experiences">Expériences professionnelles</a></li>
|
<a class="btn primary" href="#contact">↓</a>
|
||||||
<li><a href="#projets">Projets récents</a></li>
|
</aside>
|
||||||
<li><a href="#contact">Contact et coordonnées</a></li>
|
|
||||||
<li><a href="#autres">Autres informations</a></li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<main>
|
<section id="about" class="hero reveal" aria-labelledby="hero-title">
|
||||||
<section id="competences">
|
<div class="hero-card">
|
||||||
|
<p class="eyebrow">Développeur web fullstack</p>
|
||||||
|
<h1 id="hero-title">Je conçois, développe et déploie des applications web performantes et maintenables.</h1>
|
||||||
|
<p>
|
||||||
|
Diplômé d'un Bachelor Développeur Web à l'ESD Paris, autoentrepreneur et passionné d'informatique.
|
||||||
|
Actuellement en CDI chez Dollen en tant que développeur web fullstack.
|
||||||
|
</p>
|
||||||
|
<div class="cta">
|
||||||
|
<a class="btn primary" href="#projects">Voir mes projets</a>
|
||||||
|
<a class="btn" href="#contact">Me contacter</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="skills" class="card reveal">
|
||||||
<h2>Compétences et qualifications</h2>
|
<h2>Compétences et qualifications</h2>
|
||||||
<ul>
|
<p class="lead">J'aime les outils simples et le code clair.</p>
|
||||||
<li>Rédaction de spécifications et cahier de recettes.</li>
|
<div class="skill-groups">
|
||||||
<li>Maîtrise de PhP, Java, Go, Html5/Css3, Javascript, Typescript, SQL, VueJS, React, Flutter, Docker,
|
<div class="group">
|
||||||
Jenkins, Git, GCP, et de Nginx. Apprendre un nouveau language ou framework n'est absolument pas un
|
<h3>Backend</h3>
|
||||||
obstacle pour moi.</li>
|
<div class="tags">
|
||||||
<li>Maîtrise de l'anglais niveau C1</li>
|
<span class="tag">PHP</span><span class="tag">Symfony</span><span class="tag">Doctrine</span>
|
||||||
<li>Maîtrise de la méthodologie agile, avec sprints, daily...</li>
|
<span class="tag">Node</span><span class="tag">Fastify</span><span class="tag">Prisma</span>
|
||||||
</ul>
|
<span class="tag">Jest</span><span class="tag">Go</span>
|
||||||
|
<span class="tag">Java</span><span class="tag">C#</span><span class="tag">C++</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="group">
|
||||||
|
<h3>Frontend</h3>
|
||||||
|
<div class="tags">
|
||||||
|
<span class="tag">Vue</span>
|
||||||
|
<span class="tag">Nuxt</span>
|
||||||
|
<span class="tag">React</span>
|
||||||
|
<span class="tag">Next</span>
|
||||||
|
<span class="tag">TypeScript</span>
|
||||||
|
<span class="tag">HTML</span>
|
||||||
|
<span class="tag">CSS</span>
|
||||||
|
<span class="tag">Tailwind</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="group">
|
||||||
|
<h3>Mobile</h3>
|
||||||
|
<div class="tags">
|
||||||
|
<span class="tag">Flutter</span>
|
||||||
|
<span class="tag">Kotlin</span>
|
||||||
|
<span class="tag">React Native</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="group">
|
||||||
|
<h3>Base de données</h3>
|
||||||
|
<div class="tags">
|
||||||
|
<span class="tag">MySQL</span>
|
||||||
|
<span class="tag">PostgreSQL</span>
|
||||||
|
<span class="tag">SQLite</span>
|
||||||
|
<span class="tag">Supabase</span>
|
||||||
|
<span class="tag">Firebase</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="group">
|
||||||
|
<h3>DevOps</h3>
|
||||||
|
<div class="tags">
|
||||||
|
<span class="tag">Docker</span>
|
||||||
|
<span class="tag">Nginx</span>
|
||||||
|
<span class="tag">Debian</span>
|
||||||
|
<span class="tag">CI/CD</span>
|
||||||
|
<span class="tag">GitHub Actions</span>
|
||||||
|
<span class="tag">Jenkins</span>
|
||||||
|
<span class="tag">Dockploy</span>
|
||||||
|
<span class="tag">Coolify</span>
|
||||||
|
<span class="tag">GCP</span>
|
||||||
|
<span class="tag">AWS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="group">
|
||||||
|
<h3>Pratiques</h3>
|
||||||
|
<div class="tags"><span class="tag">DDD</span><span class="tag">Tests</span><span class="tag">Agile</span>
|
||||||
|
<span class="tag">Anglais C1</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="diplomes">
|
<section id="education" class="card reveal">
|
||||||
<h2>Diplômes obtenus</h2>
|
<h2>Diplômes obtenus</h2>
|
||||||
<table>
|
<div class="timeline">
|
||||||
<thead>
|
<div class="t-item revealed">
|
||||||
<tr>
|
<div class="t-role">Bachelor Développeur Web — <em>ESD Paris</em></div>
|
||||||
<th scope="col">Période</th>
|
<div class="t-meta">2022 - 2024 • Bac +3 / RNCP 6</div>
|
||||||
<th scope="col">Établissement</th>
|
</div>
|
||||||
<th scope="col">Niveau</th>
|
<div class="t-item revealed">
|
||||||
<th scope="col">Diplôme</th>
|
<div class="t-role">DUT Informatique — <em>Université Gustave Eiffel</em></div>
|
||||||
</tr>
|
<div class="t-meta">2020 - 2022 • Bac +2</div>
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
<div class="t-item revealed">
|
||||||
<tr>
|
<div class="t-role">Baccalauréat STI2D <i>(mention bien) </i> — <em> Lycée René Cassin</em></div>
|
||||||
<td>2022 - 2024</td>
|
<div class="t-meta">2017 - 2020</div>
|
||||||
<td><a href="https://ecole-du-digital.com">ESD Paris</a></td>
|
</div>
|
||||||
<td>Bac +3 / RNCP 6</td>
|
</div>
|
||||||
<td>Bachelor Développeur Web</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>2020 - 2022</td>
|
|
||||||
<td><a href="https://univ-gustave-eiffel.fr">Université Gustave Eiffel</a></td>
|
|
||||||
<td>Bac +2</td>
|
|
||||||
<td>DUT Informatique</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>2017 - 2020</td>
|
|
||||||
<td>Lycée Polyvalent René Cassin</td>
|
|
||||||
<td>Bac</td>
|
|
||||||
<td>Baccalauréat STI2D <i>(mention bien)</i></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="experiences">
|
<section id="experience" class="card reveal">
|
||||||
<h2>Expériences professionnelles</h2>
|
<h2>Expériences professionnelles</h2>
|
||||||
|
<div class="timeline">
|
||||||
|
<div class="t-item">
|
||||||
|
<div class="t-role">CDI — Développeur Web Fullstack</div>
|
||||||
|
<div class="t-org">Entreprise : Dollen • Période : Nov. 2024 - En cours</div>
|
||||||
|
<ul class="t-bullets">
|
||||||
|
<li>Conception logiciel (diagrammes de classes <strong>UML</strong>).</li>
|
||||||
|
<li>Back-office: PHP 8.4, Symfony 7.3, Doctrine, Twig, Stimulus, PHPUnit, DDD.</li>
|
||||||
|
<li>CI/CD et déploiement: Docker, Debian, Nginx, GitHub Actions.</li>
|
||||||
|
<li>Migrations: SQLite, PostgreSQL, MariaDB.</li>
|
||||||
|
<li>Peer review et gestion agile (Trello, GitHub Projects).</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table>
|
<div class="t-item">
|
||||||
<colgroup>
|
<div class="t-role">Apprenti — Développeur Fullstack</div>
|
||||||
<col>
|
<div class="t-org">Entreprise : <a href="https://lecadeaupresent.com">Présent </a> • Avr. 2023 - Sep. 2024
|
||||||
<col>
|
</div>
|
||||||
</colgroup>
|
<ul class="t-bullets">
|
||||||
<thead>
|
<li>Apps mobiles: Kotlin (interne), Flutter (B2C).</li>
|
||||||
<tr>
|
<li>API REST monolithique: TypeScript, Fastify, Prisma, Jest, MySQL, Docker, GCP, DDD.</li>
|
||||||
<th scope="col" colspan="2">CDI Développeur Web Fullstack</th>
|
<li>SPA desktop/mobile: React, TypeScript, Zustand, Chakra UI.</li>
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<th scope="row"><strong>Entreprise</strong></th>
|
|
||||||
<td><a href="https://dollen.fr">Dollen</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row"><strong>Période</strong></th>
|
|
||||||
<td>Nov. 2024 - En cours</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row"><strong>Missions</strong></th>
|
|
||||||
<td>
|
|
||||||
<ul>
|
|
||||||
<li>Conception logiciel (diagramme de classes <strong>UML</strong>)</li>
|
|
||||||
<li>
|
|
||||||
Développement d'un back office
|
|
||||||
<ul>
|
|
||||||
<li>PHP 8.4, Symfony 7.3, Doctrine, Twig, Stimulus, PHPUnit, Domain Driven
|
|
||||||
Design</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</div>
|
||||||
<li>
|
|
||||||
Déploiement et intégration continue du back-office en production et pré-production
|
|
||||||
<ul>
|
|
||||||
<li>Docker, Debian, Nginx, GitHub Action
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Migration de base de données
|
|
||||||
<ul>
|
|
||||||
<li>SQLite, PostgreSQL, MariaDB</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>Peer reviewing</li>
|
|
||||||
<li>
|
|
||||||
Gestion de projet Agile
|
|
||||||
<ul>
|
|
||||||
<li>Trello, GitHub Projects, daily sprints</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table>
|
<div class="t-item">
|
||||||
<colgroup>
|
<div class="t-role">Apprenti — Développeur R&D</div>
|
||||||
<col>
|
<div class="t-org">Entreprise : <a href="https://wattearth.com">WATT Earth</a> • Oct. 2021 - Sept. 2022</div>
|
||||||
<col>
|
<ul class="t-bullets">
|
||||||
</colgroup>
|
<li>Fonctionnalités cœur produit: VueJS, TS, PHP, Python, MySQL, Warp10, Grafana.</li>
|
||||||
<thead>
|
<li>Maquettage et conception UI (Figma).</li>
|
||||||
<tr>
|
<li>Spécifications et cahiers de recettes.</li>
|
||||||
<th scope="col" colspan="2">Apprenti Développeur Fullstack</th>
|
<li>Intégration technologies: Modbus, LoRaWAN, Zigbee…</li>
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<th scope="row"><strong>Entreprise</strong></th>
|
|
||||||
<td><a href="https://lecadeaupresent.com">Présent</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row"><strong>Période</strong></th>
|
|
||||||
<td>Avr. 2023 - Sep. 2024 (1 an et 5 mois)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row"><strong>Missions</strong></th>
|
|
||||||
<td>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
Développement d'une
|
|
||||||
<ul>
|
|
||||||
<li>application mobile interne
|
|
||||||
<ul>
|
|
||||||
<li>Kotlin</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</div>
|
||||||
<li>application mobile B2C
|
</div>
|
||||||
<ul>
|
|
||||||
<li>Flutter</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>API REST monolithique
|
|
||||||
<ul>
|
|
||||||
<li>Typescript ,Fastify ,Prisma, Jest, MySql, Docker, GCP, Domain Driven
|
|
||||||
Design</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>SPA pour Desktop et Mobile
|
|
||||||
<ul>
|
|
||||||
<li>React, Typescript, Zustand, Chakra UI</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<colgroup>
|
|
||||||
<col>
|
|
||||||
<col>
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col" colspan="2">Apprenti Développeur R&D</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<th scope="row"><strong>Entreprise</strong></th>
|
|
||||||
<td><a href="https://wattearth.com">WATT Earth</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row"><strong>Période</strong></th>
|
|
||||||
<td>Oct. 2021 - Sept. 2022 (1 an)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row"><strong>Missions</strong></th>
|
|
||||||
<td>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
Conception et développement de fonctionnalités innovantes au cœur des produits WATT
|
|
||||||
Earth :
|
|
||||||
<ul>
|
|
||||||
<li>VueJS, Typescript, PHP, Python, MySql, Warp10, Grafana</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>Maquettage et conception d'interfaces utilisateurs (<strong>Figma</strong>)</li>
|
|
||||||
<li>Réalisation de spécifications et de cahiers de recettes</li>
|
|
||||||
<li>
|
|
||||||
Intégration de technologies dans la solution de l'entreprise, correspondant aux
|
|
||||||
besoins client :
|
|
||||||
<ul>
|
|
||||||
<li>Modbus, LoRaWAN, Zigbee, ...</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="projets">
|
<section id="projects" class="card reveal">
|
||||||
<h2>Projets récents</h2>
|
<h2>Projets récents</h2>
|
||||||
<table>
|
<div class="proj-grid">
|
||||||
<colgroup>
|
<a class="proj" href="https://github.com/Floriansylvain/GoCMS" aria-label="Voir le dépôt GoCMS">
|
||||||
<col>
|
<h3>GoCMS</h3>
|
||||||
<col>
|
<p>CMS en Go pour accélérer la création de sites avec blog/SSR, architecture <abbr
|
||||||
</colgroup>
|
title="Domain Driven Design">DDD</abbr>, CI/CD sur serveur Debian (Docker, Nginx, Jenkins).</p>
|
||||||
<thead>
|
<div class="stack">
|
||||||
<tr>
|
<span class="badge">Go</span><span class="badge">SSR</span><span class="badge">Docker</span><span
|
||||||
<th scope="col" colspan="2">GoCMS</th>
|
class="badge">Nginx</span><span class="badge">SQLite</span>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
<p>Voir le dépôt →</p>
|
||||||
<tbody>
|
</a>
|
||||||
<tr>
|
|
||||||
<th scope="row"><strong>Description</strong></th>
|
|
||||||
<td>GoCMS est un projet de système de gestion de contenu développé en Go. Le but à terme de ce
|
|
||||||
projet en cours de developpement est de me faciliter la création de site web qui nécessitent
|
|
||||||
des fonctionnalités comme celles d'un Wordpress, notamment un système de blog et d'articles,
|
|
||||||
mais sans avoir recours à Wordpress ou équivalent. L'architecture du projet est en DDD,
|
|
||||||
Domain Driven Design, et le front de l'interface d'administration est généré en SSR via le
|
|
||||||
moteur de templating html de Go. J'utilise également Bootstrap pour avoir de belles
|
|
||||||
interface sans devoir fournir trop d'effort de développement front. J'héberge une démo du
|
|
||||||
projet en CI/CD via Jenkins sur mon serveur dédié Debian, contenerisé via Docker et exposé
|
|
||||||
via Nginx.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row"><strong>Stack</strong></th>
|
|
||||||
<td>Go, HTML, CSS, JS, Bootstrap, Docker, Nginx, DDD, Jenkins, Debian, SQLite, Git</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row"><strong>Dépôt</strong></th>
|
|
||||||
<td><a href="https://github.com/Floriansylvain/GoCMS">GoCMS</a></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table>
|
<a class="proj" href="https://github.com/Floriansylvain/SnippetsManager"
|
||||||
<colgroup>
|
aria-label="Voir le dépôt SnippetsManager">
|
||||||
<col>
|
<h3>SnippetsManager</h3>
|
||||||
<col>
|
<p>API inspirée de massCode pour gérer des snippets. TypeScript, Express, Prisma, MySQL, Docker, Jest.</p>
|
||||||
</colgroup>
|
<div class="stack">
|
||||||
<thead>
|
<span class="badge">TypeScript</span><span class="badge">Express</span><span
|
||||||
<tr>
|
class="badge">Prisma</span><span class="badge">MySQL</span>
|
||||||
<th scope="col" colspan="2">SnippetsManager</th>
|
</div>
|
||||||
</tr>
|
<p>Voir le dépôt →</p>
|
||||||
</thead>
|
</a>
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<th scope="row"><strong>Description</strong></th>
|
|
||||||
<td>Projet réalisé dans le cadre d'un cours de Framework Backend à l'ESD, inspiré de l'outil
|
|
||||||
open-source massCode dont le principe est de gérer des snippets (bouts de code). Il s'agit
|
|
||||||
d'une API conçu en Typescript, Express, Prisma, MySql, Docker et Jest.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row"><strong>Stack</strong></th>
|
|
||||||
<td>Typescript, Express, Prisma, MySql, Docker, Jest, NodeJS, Git, TSX</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row"><strong>Dépôt</strong></th>
|
|
||||||
<td><a href="https://github.com/Floriansylvain/SnippetsManager">SnippetsManager</a></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table>
|
<a class="proj" href="https://github.com/Floriansylvain/spotify-playlist-embed"
|
||||||
<colgroup>
|
aria-label="Voir le dépôt spotify-playlist-embed">
|
||||||
<col>
|
<h3>spotify-playlist-embed</h3>
|
||||||
<col>
|
<p>Lecteur de playlist Spotify en Next.js + TS, avec spotify-web-api-node et TailwindCSS.</p>
|
||||||
</colgroup>
|
<div class="stack">
|
||||||
<thead>
|
<span class="badge">Next.js</span><span class="badge">TypeScript</span><span
|
||||||
<tr>
|
class="badge">TailwindCSS</span><span class="badge">Spotify API</span>
|
||||||
<th scope="col" colspan="2">spotify-playlist-embed</th>
|
</div>
|
||||||
</tr>
|
<p>Voir le dépôt →</p>
|
||||||
</thead>
|
</a>
|
||||||
<tbody>
|
</div>
|
||||||
<tr>
|
|
||||||
<th scope="row"><strong>Description</strong></th>
|
|
||||||
<td>Petit projet réalisé dans le cadre d'un cours de Framework Frontend à l'ESD, il s'agit d'un
|
|
||||||
lecteur de playlist spotify réalisé en NextJS et Typescript, avec la spotify-web-apinode
|
|
||||||
ainsi que Tailwindcss.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row"><strong>Stack</strong></th>
|
|
||||||
<td>NextJS, Typescript, Tailwindcss, Spotify API, TSX, Git, Vercel</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row"><strong>Dépôt</strong></th>
|
|
||||||
<td><a
|
|
||||||
href="https://github.com/Floriansylvain/spotify-playlist-embed">spotify-playlist-embed</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="contact">
|
<section id="contact" class="card reveal">
|
||||||
<h2>Contact</h2>
|
<h2>Contact</h2>
|
||||||
<p>Vous pouvez me contacter via les réseaux sociaux suivants :</p>
|
<p>Échangeons sur vos besoins, missions ou opportunités.</p>
|
||||||
<ul>
|
<p><a href="https://linkedin.com/in/florian-sylvain-0651021a9">LinkedIn</a></p>
|
||||||
<li><a href="https://linkedin.com/in/florian-sylvain-0651021a9">LinkedIn</a></li>
|
<p class="lead">Autres moyens de contact non listés pour éviter la collecte automatisée.</p>
|
||||||
</ul>
|
|
||||||
<p>Autre moyens de contact indisponible sur ce portfolio pour éviter les crawlers malveillants et autre
|
|
||||||
récolte de données, qui résulteront en spam et démarchage incéssants.</p>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer id="autres">
|
<footer class="container reveal" id="autres">
|
||||||
<h2>Pourquoi un site aussi simple ?</h2>
|
<p>Pourquoi si simple ? Ce site reste léger et sobre. <a
|
||||||
<p>Ce site pèse moins de 14ko.
|
href="https://endtimes.dev/why-your-website-should-be-under-14kb-in-size">Pourquoi viser < 14 kB</a></p>
|
||||||
<a href="https://endtimes.dev/why-your-website-should-be-under-14kb-in-size">Why your website should be
|
<a class="to-top" href="#top" aria-label="Revenir en haut">↑</a>
|
||||||
under 14kB in size
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<a href="#header">Remonter ↑</a>
|
|
||||||
<p>Dernière mise à jour : 2025/08/23</p>
|
<p>Dernière mise à jour : 2025/08/23</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
<script src="script.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
Generated
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "compact-portfolio-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "compact-portfolio-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "compact-portfolio-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Node server to serve minified, compressed portfolio",
|
||||||
|
"main": "server.js",
|
||||||
|
"type": "commonjs",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,77 @@
|
|||||||
|
const prefersReduced =
|
||||||
|
window.matchMedia &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
|
const revealables = document.querySelectorAll(".reveal");
|
||||||
|
if (prefersReduced) {
|
||||||
|
revealables.forEach((el) => el.classList.add("revealed"));
|
||||||
|
} else {
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const e of entries)
|
||||||
|
if (e.isIntersecting) {
|
||||||
|
e.target.classList.add("revealed");
|
||||||
|
io.unobserve(e.target);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.08, rootMargin: "0px 0px -10% 0px" }
|
||||||
|
);
|
||||||
|
revealables.forEach((el) => io.observe(el));
|
||||||
|
}
|
||||||
|
|
||||||
|
const navLinks = [...document.querySelectorAll(".site-nav a")];
|
||||||
|
const sections = navLinks
|
||||||
|
.map((a) => document.querySelector(a.getAttribute("href")))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
let currentSection = null;
|
||||||
|
|
||||||
|
const spy = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((e) => {
|
||||||
|
if (e.isIntersecting) {
|
||||||
|
const id = "#" + e.target.id;
|
||||||
|
|
||||||
|
navLinks.forEach((a) => a.removeAttribute("aria-current"));
|
||||||
|
const active = navLinks.find(
|
||||||
|
(a) => a.getAttribute("href") === id
|
||||||
|
);
|
||||||
|
if (active) active.setAttribute("aria-current", "page");
|
||||||
|
|
||||||
|
if (currentSection !== id) {
|
||||||
|
currentSection = id;
|
||||||
|
history.replaceState(null, null, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ rootMargin: "-40% 0% -50% 0%", threshold: 0.01 }
|
||||||
|
);
|
||||||
|
sections.forEach((s) => s && spy.observe(s));
|
||||||
|
|
||||||
|
const menuBtn = document.getElementById("menuToggle");
|
||||||
|
const nav = document.getElementById("primaryNav");
|
||||||
|
if (menuBtn && nav) {
|
||||||
|
const closeMenu = () => {
|
||||||
|
nav.classList.remove("is-open");
|
||||||
|
menuBtn.setAttribute("aria-expanded", "false");
|
||||||
|
};
|
||||||
|
const openMenu = () => {
|
||||||
|
nav.classList.add("is-open");
|
||||||
|
menuBtn.setAttribute("aria-expanded", "true");
|
||||||
|
};
|
||||||
|
const toggleMenu = () => {
|
||||||
|
nav.classList.contains("is-open") ? closeMenu() : openMenu();
|
||||||
|
};
|
||||||
|
menuBtn.addEventListener("click", toggleMenu);
|
||||||
|
nav.addEventListener("click", (e) => {
|
||||||
|
const t = e.target;
|
||||||
|
if (t && t.matches("a")) closeMenu();
|
||||||
|
});
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
if (!nav.contains(e.target) && !menuBtn.contains(e.target)) closeMenu();
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape") closeMenu();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,595 @@
|
|||||||
|
const http = require("http");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const zlib = require("zlib");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
const ROOT = __dirname;
|
||||||
|
|
||||||
|
const cspHashes = {
|
||||||
|
scripts: new Set(),
|
||||||
|
styles: new Set(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function calculateHash(content, algorithm = "sha256") {
|
||||||
|
return crypto
|
||||||
|
.createHash(algorithm)
|
||||||
|
.update(content, "utf8")
|
||||||
|
.digest("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractInlineContent(html) {
|
||||||
|
const scriptMatches =
|
||||||
|
html.match(/<script(?![^>]*src=)[^>]*>([\s\S]*?)<\/script>/gi) || [];
|
||||||
|
const styleMatches = html.match(/<style[^>]*>([\s\S]*?)<\/style>/gi) || [];
|
||||||
|
|
||||||
|
cspHashes.scripts.clear();
|
||||||
|
cspHashes.styles.clear();
|
||||||
|
|
||||||
|
scriptMatches.forEach((match) => {
|
||||||
|
const content = match.replace(/<script[^>]*>|<\/script>/gi, "").trim();
|
||||||
|
if (content) {
|
||||||
|
const hash = calculateHash(content);
|
||||||
|
cspHashes.scripts.add(`'sha256-${hash}'`);
|
||||||
|
console.log(`Inline script hash: sha256-${hash}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
styleMatches.forEach((match) => {
|
||||||
|
const content = match.replace(/<style[^>]*>|<\/style>/gi, "").trim();
|
||||||
|
if (content) {
|
||||||
|
const hash = calculateHash(content);
|
||||||
|
cspHashes.styles.add(`'sha256-${hash}'`);
|
||||||
|
console.log(`Inline style hash: sha256-${hash}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateCSP() {
|
||||||
|
const scriptSources = Array.from(cspHashes.scripts);
|
||||||
|
const styleSources = Array.from(cspHashes.styles);
|
||||||
|
|
||||||
|
let scriptSrc = "'self'";
|
||||||
|
if (scriptSources.length > 0) {
|
||||||
|
scriptSrc += ` ${scriptSources.join(" ")}`;
|
||||||
|
}
|
||||||
|
if (scriptSources.length === 0) {
|
||||||
|
scriptSrc += " 'unsafe-inline'";
|
||||||
|
}
|
||||||
|
|
||||||
|
let styleSrc = "'self'";
|
||||||
|
if (styleSources.length > 0) {
|
||||||
|
styleSrc += ` ${styleSources.join(" ")}`;
|
||||||
|
}
|
||||||
|
if (styleSources.length === 0) {
|
||||||
|
styleSrc += " 'unsafe-inline'";
|
||||||
|
}
|
||||||
|
|
||||||
|
const csp = [
|
||||||
|
"default-src 'self'",
|
||||||
|
`script-src ${scriptSrc}`,
|
||||||
|
`style-src ${styleSrc}`,
|
||||||
|
"img-src 'self' data: https:",
|
||||||
|
"font-src 'self'",
|
||||||
|
"connect-src 'self'",
|
||||||
|
"media-src 'self'",
|
||||||
|
"object-src 'none'",
|
||||||
|
"child-src 'none'",
|
||||||
|
"worker-src 'none'",
|
||||||
|
"frame-ancestors 'none'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"manifest-src 'self'",
|
||||||
|
].join("; ");
|
||||||
|
|
||||||
|
const hashCount = scriptSources.length + styleSources.length;
|
||||||
|
console.log(`Generated CSP with ${hashCount} inline content hashes:`);
|
||||||
|
console.log(`${csp}\n`);
|
||||||
|
return csp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function minifyCSS(src) {
|
||||||
|
let out = src
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.replace(/\s*([{}:;,>+~\(\)\[\]'"*\/])\s*/g, "$1")
|
||||||
|
.replace(/;}/g, "}")
|
||||||
|
.replace(/\b0+(px|em|rem|vh|vw|%|s|ms)\b/g, "0")
|
||||||
|
.replace(/\b0+\.(\d+)/g, ".$1") // 0.5 -> .5
|
||||||
|
.replace(/#([0-9a-fA-F])\1([0-9a-fA-F])\2([0-9a-fA-F])\3\b/g, "#$1$2$3")
|
||||||
|
.replace(
|
||||||
|
/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/g,
|
||||||
|
(m, r, g, b) => {
|
||||||
|
const toHex = (n) => parseInt(n).toString(16).padStart(2, "0");
|
||||||
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.replace(/calc\(\s*([^)]+)\s*\)/g, (m, expr) => {
|
||||||
|
return `calc(${expr.replace(/\s+/g, "")})`;
|
||||||
|
})
|
||||||
|
.replace(/(['"])([a-zA-Z0-9\-_]+)\1/g, (m, q, val) => {
|
||||||
|
if (/^[a-zA-Z\-_][a-zA-Z0-9\-_]*$/.test(val)) return val;
|
||||||
|
return m;
|
||||||
|
})
|
||||||
|
.replace(
|
||||||
|
/([^{}]+)\{([^}]*)\}([^{}]+)\{([^}]*)\}/g,
|
||||||
|
(m, sel1, rules1, sel2, rules2) => {
|
||||||
|
if (rules1 === rules2) return `${sel1},${sel2}{${rules1}}`;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.replace(/[^{}]+\{\s*\}/g, "")
|
||||||
|
.replace(/(margin|padding):([^;]+)/g, (m, prop, vals) => {
|
||||||
|
const parts = vals.trim().split(/\s+/);
|
||||||
|
if (parts.length === 4) {
|
||||||
|
const [t, r, b, l] = parts;
|
||||||
|
if (t === r && r === b && b === l) return `${prop}:${t}`;
|
||||||
|
if (t === b && r === l) return `${prop}:${t} ${r}`;
|
||||||
|
} else if (parts.length === 2) {
|
||||||
|
const [tb, lr] = parts;
|
||||||
|
if (tb === lr) return `${prop}:${tb}`;
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
})
|
||||||
|
.replace(/border-radius:([^;]+)/g, (m, vals) => {
|
||||||
|
const parts = vals.trim().split(/\s+/);
|
||||||
|
if (parts.length === 4 && parts.every((p) => p === parts[0])) {
|
||||||
|
return `border-radius:${parts[0]}`;
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
})
|
||||||
|
.replace(
|
||||||
|
/-webkit-(transform|transition|animation|box-shadow|border-radius):/g,
|
||||||
|
"$1:"
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
/-moz-(transform|transition|animation|box-shadow|border-radius):/g,
|
||||||
|
"$1:"
|
||||||
|
)
|
||||||
|
.replace(/-ms-(transform|transition|animation):/g, "$1:")
|
||||||
|
.replace(
|
||||||
|
/translate\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/g,
|
||||||
|
"translate($1,$2)"
|
||||||
|
)
|
||||||
|
.replace(/scale\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/g, (m, x, y) => {
|
||||||
|
return x === y ? `scale(${x})` : `scale(${x},${y})`;
|
||||||
|
})
|
||||||
|
.replace(/@media\s+([^{]+)\s*\{/g, "@media $1{")
|
||||||
|
.replace(/linear-gradient\(\s*([^)]+)\s*\)/g, (m, args) => {
|
||||||
|
return `linear-gradient(${args.replace(/\s*,\s*/g, ",")})`;
|
||||||
|
})
|
||||||
|
.replace(/radial-gradient\(\s*([^)]+)\s*\)/g, (m, args) => {
|
||||||
|
return `radial-gradient(${args.replace(/\s*,\s*/g, ",")})`;
|
||||||
|
})
|
||||||
|
.replace(/,\s+/g, ",")
|
||||||
|
.replace(/:\s+/g, ":")
|
||||||
|
.replace(/;\s+/g, ";")
|
||||||
|
.replace(/\{\s+/g, "{")
|
||||||
|
.replace(/\s+\}/g, "}")
|
||||||
|
.replace(/>\s+/g, ">")
|
||||||
|
.replace(/\+\s+/g, "+")
|
||||||
|
.replace(/~\s+/g, "~");
|
||||||
|
|
||||||
|
return out.replace(/\r?\n/g, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function minifyHTML(src) {
|
||||||
|
let out = src
|
||||||
|
.replace(/<!--(?!\[if)[\s\S]*?-->/g, "")
|
||||||
|
.replace(/>\s+</g, "><")
|
||||||
|
.replace(/>\s+([^<]+?)\s+</g, ">$1<")
|
||||||
|
.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (m, css) => {
|
||||||
|
return m.replace(css, minifyCSS(css));
|
||||||
|
})
|
||||||
|
.replace(/\s+([\w-]+)=["']([^"'\s<>]+)["']/g, (m, attr, val) => {
|
||||||
|
if (/^[a-zA-Z0-9._:/-]+$/.test(val)) return ` ${attr}=${val}`;
|
||||||
|
return m;
|
||||||
|
})
|
||||||
|
.replace(/\s*=\s*/g, "=")
|
||||||
|
.replace(/\s+>/g, ">")
|
||||||
|
.replace(/\s{2,}/g, " ")
|
||||||
|
.replace(/<([^>]+)\s+>/g, "<$1>")
|
||||||
|
.replace(/\s+[\w-]+=["']["']/g, "")
|
||||||
|
.replace(/\s+type=["']text\/(javascript|css)["']/g, "");
|
||||||
|
|
||||||
|
return out.replace(/\r?\n/g, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function minifyJS(src) {
|
||||||
|
let out = "";
|
||||||
|
let i = 0,
|
||||||
|
n = src.length;
|
||||||
|
let inStr = false,
|
||||||
|
strQuote = "",
|
||||||
|
inTemplate = false,
|
||||||
|
escape = false;
|
||||||
|
|
||||||
|
while (i < n) {
|
||||||
|
const ch = src[i];
|
||||||
|
const next = src[i + 1];
|
||||||
|
|
||||||
|
if (inStr) {
|
||||||
|
out += ch;
|
||||||
|
if (!escape && ch === strQuote) {
|
||||||
|
inStr = false;
|
||||||
|
strQuote = "";
|
||||||
|
}
|
||||||
|
escape = !escape && ch === "\\";
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inTemplate) {
|
||||||
|
out += ch;
|
||||||
|
if (!escape && ch === "`") {
|
||||||
|
inTemplate = false;
|
||||||
|
}
|
||||||
|
escape = !escape && ch === "\\";
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '"' || ch === "'") {
|
||||||
|
inStr = true;
|
||||||
|
strQuote = ch;
|
||||||
|
out += ch;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === "`") {
|
||||||
|
inTemplate = true;
|
||||||
|
out += ch;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "/" && next === "*") {
|
||||||
|
i += 2;
|
||||||
|
while (i < n && !(src[i] === "*" && src[i + 1] === "/")) i++;
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "/" && next === "/") {
|
||||||
|
i += 2;
|
||||||
|
while (i < n && src[i] !== "\n") i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
out += ch;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalOut = "";
|
||||||
|
inStr = false;
|
||||||
|
strQuote = "";
|
||||||
|
inTemplate = false;
|
||||||
|
escape = false;
|
||||||
|
|
||||||
|
for (let j = 0; j < out.length; j++) {
|
||||||
|
const ch = out[j];
|
||||||
|
const prev = finalOut[finalOut.length - 1];
|
||||||
|
|
||||||
|
if (inStr) {
|
||||||
|
finalOut += ch;
|
||||||
|
if (!escape && ch === strQuote) {
|
||||||
|
inStr = false;
|
||||||
|
strQuote = "";
|
||||||
|
}
|
||||||
|
escape = !escape && ch === "\\";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inTemplate) {
|
||||||
|
finalOut += ch;
|
||||||
|
if (!escape && ch === "`") {
|
||||||
|
inTemplate = false;
|
||||||
|
}
|
||||||
|
escape = !escape && ch === "\\";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '"' || ch === "'") {
|
||||||
|
inStr = true;
|
||||||
|
strQuote = ch;
|
||||||
|
finalOut += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === "`") {
|
||||||
|
inTemplate = true;
|
||||||
|
finalOut += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/\s/.test(ch)) {
|
||||||
|
let k = j + 1;
|
||||||
|
while (k < out.length && /\s/.test(out[k])) k++;
|
||||||
|
const nxt = out[k];
|
||||||
|
|
||||||
|
if (
|
||||||
|
(/[a-zA-Z0-9_$]/.test(prev) && /[a-zA-Z0-9_$]/.test(nxt)) ||
|
||||||
|
(prev === "return" && /[a-zA-Z0-9_$"]/.test(nxt))
|
||||||
|
) {
|
||||||
|
finalOut += " ";
|
||||||
|
}
|
||||||
|
j = k - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[=;:,{}()<>+\-*\/\?|&!]/.test(ch) && finalOut.endsWith(" ")) {
|
||||||
|
finalOut = finalOut.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
finalOut += ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalOut
|
||||||
|
.replace(/\r?\n/g, "")
|
||||||
|
.replace(/;}/g, "}")
|
||||||
|
.replace(/===true/g, "")
|
||||||
|
.replace(/!==false/g, "")
|
||||||
|
.replace(/==true/g, "")
|
||||||
|
.replace(/!=false/g, "")
|
||||||
|
.replace(/\["([a-zA-Z_$][a-zA-Z0-9_$]*)"\]/g, ".$1")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = new Map();
|
||||||
|
|
||||||
|
function contentTypeFor(fp) {
|
||||||
|
const ext = path.extname(fp).toLowerCase();
|
||||||
|
switch (ext) {
|
||||||
|
case ".html":
|
||||||
|
return "text/html; charset=utf-8";
|
||||||
|
case ".css":
|
||||||
|
return "text/css; charset=utf-8";
|
||||||
|
case ".js":
|
||||||
|
return "application/javascript; charset=utf-8";
|
||||||
|
case ".webp":
|
||||||
|
return "image/webp";
|
||||||
|
case ".xml":
|
||||||
|
return "application/xml; charset=utf-8";
|
||||||
|
case ".txt":
|
||||||
|
return "text/plain; charset=utf-8";
|
||||||
|
default:
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheControlFor(fp) {
|
||||||
|
const ext = path.extname(fp).toLowerCase();
|
||||||
|
if (ext === ".html") return "no-cache";
|
||||||
|
if (ext === ".css" || ext === ".js")
|
||||||
|
return "public, max-age=31536000, immutable";
|
||||||
|
if (ext === ".webp") return "public, max-age=31536000, immutable";
|
||||||
|
if (ext === ".xml" || ext === ".txt") return "public, max-age=3600";
|
||||||
|
return "public, max-age=3600";
|
||||||
|
}
|
||||||
|
|
||||||
|
function etag(buf) {
|
||||||
|
let h = 2166136261 >>> 0;
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
h ^= buf[i];
|
||||||
|
h = Math.imul(h, 16777619);
|
||||||
|
}
|
||||||
|
return 'W/"' + (h >>> 0).toString(16) + "-" + buf.length + '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
function preCompress(buf) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const result = { raw: buf };
|
||||||
|
|
||||||
|
try {
|
||||||
|
zlib.brotliCompress(
|
||||||
|
buf,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
[zlib.constants.BROTLI_PARAM_QUALITY]: 11,
|
||||||
|
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: buf.length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(err, br) => {
|
||||||
|
if (!err && br) result.br = br;
|
||||||
|
|
||||||
|
zlib.gzip(
|
||||||
|
buf,
|
||||||
|
{
|
||||||
|
level: 9,
|
||||||
|
windowBits: 15,
|
||||||
|
memLevel: 9,
|
||||||
|
},
|
||||||
|
(gzErr, gz) => {
|
||||||
|
if (!gzErr && gz) result.gz = gz;
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
zlib.gzip(buf, { level: 9 }, (gzErr, gz) => {
|
||||||
|
if (!gzErr && gz) result.gz = gz;
|
||||||
|
resolve(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAndMinify(relPath, minify) {
|
||||||
|
const abs = path.join(ROOT, relPath);
|
||||||
|
const src = await fs.promises.readFile(abs);
|
||||||
|
const originalContent = src.toString("utf8");
|
||||||
|
|
||||||
|
if (relPath === "index.html") {
|
||||||
|
extractInlineContent(originalContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = minify ? Buffer.from(minify(originalContent), "utf8") : src;
|
||||||
|
const compressed = await preCompress(body);
|
||||||
|
const stats = await fs.promises.stat(abs);
|
||||||
|
|
||||||
|
const originalSize = src.length;
|
||||||
|
const minifiedSize = body.length;
|
||||||
|
const brSize = compressed.br?.length || 0;
|
||||||
|
const gzSize = compressed.gz?.length || 0;
|
||||||
|
|
||||||
|
console.log(`${relPath}:`);
|
||||||
|
console.log(` Original: ${originalSize} bytes`);
|
||||||
|
if (minify)
|
||||||
|
console.log(
|
||||||
|
` Minified: ${minifiedSize} bytes (${(
|
||||||
|
((originalSize - minifiedSize) / originalSize) *
|
||||||
|
100
|
||||||
|
).toFixed(1)}% reduction)`
|
||||||
|
);
|
||||||
|
if (brSize)
|
||||||
|
console.log(
|
||||||
|
` Brotli: ${brSize} bytes (${(
|
||||||
|
(brSize / originalSize) *
|
||||||
|
100
|
||||||
|
).toFixed(1)}% of original)`
|
||||||
|
);
|
||||||
|
if (gzSize)
|
||||||
|
console.log(
|
||||||
|
` Gzip: ${gzSize} bytes (${((gzSize / originalSize) * 100).toFixed(
|
||||||
|
1
|
||||||
|
)}% of original)`
|
||||||
|
);
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
path: relPath,
|
||||||
|
mtimeMs: stats.mtimeMs,
|
||||||
|
type: contentTypeFor(relPath),
|
||||||
|
cache: cacheControlFor(relPath),
|
||||||
|
body,
|
||||||
|
etag: etag(body),
|
||||||
|
br: compressed.br,
|
||||||
|
brEtag: compressed.br ? etag(compressed.br) : undefined,
|
||||||
|
gz: compressed.gz,
|
||||||
|
gzEtag: compressed.gz ? etag(compressed.gz) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
files.set("/" + relPath.replace(/\\/g, "/"), entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function build() {
|
||||||
|
console.log("Building and compressing files...\n");
|
||||||
|
|
||||||
|
await loadAndMinify("index.html", minifyHTML);
|
||||||
|
await loadAndMinify("style.css", minifyCSS);
|
||||||
|
await loadAndMinify("script.js", minifyJS);
|
||||||
|
|
||||||
|
for (const f of [
|
||||||
|
"favicon.webp",
|
||||||
|
"profile.webp",
|
||||||
|
"robots.txt",
|
||||||
|
"sitemap.xml",
|
||||||
|
]) {
|
||||||
|
if (fs.existsSync(path.join(ROOT, f))) {
|
||||||
|
await loadAndMinify(f, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cspPolicy = generateCSP();
|
||||||
|
files.set("__csp__", cspPolicy);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSecurityHeaders(res) {
|
||||||
|
const cspPolicy = files.get("__csp__");
|
||||||
|
|
||||||
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
|
res.setHeader("X-Frame-Options", "DENY");
|
||||||
|
res.setHeader("X-XSS-Protection", "1; mode=block");
|
||||||
|
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||||
|
res.setHeader(
|
||||||
|
"Strict-Transport-Security",
|
||||||
|
"max-age=31536000; includeSubDomains; preload"
|
||||||
|
);
|
||||||
|
res.setHeader(
|
||||||
|
"Permissions-Policy",
|
||||||
|
"geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), speaker=()"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cspPolicy) {
|
||||||
|
res.setHeader("Content-Security-Policy", cspPolicy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function negotiateEncoding(req) {
|
||||||
|
const ae = req.headers["accept-encoding"] || "";
|
||||||
|
const hasBr = /\bbr\b/.test(ae);
|
||||||
|
const hasGz = /\bgzip\b/.test(ae);
|
||||||
|
return { br: hasBr, gz: hasGz };
|
||||||
|
}
|
||||||
|
|
||||||
|
function serveEntry(req, res, entry) {
|
||||||
|
setSecurityHeaders(res);
|
||||||
|
res.setHeader("Content-Type", entry.type);
|
||||||
|
res.setHeader("Cache-Control", entry.cache);
|
||||||
|
res.setHeader("Vary", "Accept-Encoding");
|
||||||
|
|
||||||
|
const enc = negotiateEncoding(req);
|
||||||
|
let body = entry.body;
|
||||||
|
let tag = entry.etag;
|
||||||
|
|
||||||
|
if (enc.br && entry.br) {
|
||||||
|
body = entry.br;
|
||||||
|
tag = entry.brEtag;
|
||||||
|
res.setHeader("Content-Encoding", "br");
|
||||||
|
} else if (enc.gz && entry.gz) {
|
||||||
|
body = entry.gz;
|
||||||
|
tag = entry.gzEtag;
|
||||||
|
res.setHeader("Content-Encoding", "gzip");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader("ETag", tag);
|
||||||
|
if (req.headers["if-none-match"] === tag) {
|
||||||
|
res.statusCode = 304;
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notFound(req, res) {
|
||||||
|
setSecurityHeaders(res);
|
||||||
|
res.statusCode = 404;
|
||||||
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||||
|
res.end("404 Not Found");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handler(req, res) {
|
||||||
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||||
|
let pathname = url.pathname;
|
||||||
|
if (pathname === "/") pathname = "/index.html";
|
||||||
|
const entry = files.get(pathname);
|
||||||
|
if (entry) return serveEntry(req, res, entry);
|
||||||
|
|
||||||
|
const fallback = path.join(ROOT, pathname);
|
||||||
|
if (fs.existsSync(fallback) && fs.statSync(fallback).isFile()) {
|
||||||
|
const raw = fs.readFileSync(fallback);
|
||||||
|
const tmpEntry = {
|
||||||
|
type: contentTypeFor(fallback),
|
||||||
|
cache: cacheControlFor(fallback),
|
||||||
|
body: raw,
|
||||||
|
etag: etag(raw),
|
||||||
|
};
|
||||||
|
return serveEntry(req, res, tmpEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return notFound(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
await build();
|
||||||
|
const server = http.createServer(handler);
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`Server running on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
start().catch((err) => {
|
||||||
|
console.error("Failed to start server:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
/* Base */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #0e120e;
|
||||||
|
--foreground: #e2f1e2;
|
||||||
|
--muted: #8aa78a;
|
||||||
|
--surface: #101710;
|
||||||
|
--surface-2: #142014;
|
||||||
|
--border: #182418;
|
||||||
|
--accent: #7fc27f;
|
||||||
|
--accent-2: #a7e6a7;
|
||||||
|
--font-body: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
||||||
|
--font-heading: ui-rounded, system-ui, -apple-system, Segoe UI, Roboto,
|
||||||
|
Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--foreground);
|
||||||
|
background: radial-gradient(
|
||||||
|
80rem 50rem at 110% -10%,
|
||||||
|
#1a321a10 20%,
|
||||||
|
transparent 60%
|
||||||
|
),
|
||||||
|
radial-gradient(60rem 40rem at -10% 10%, #2a4a2a14 20%, transparent 60%),
|
||||||
|
var(--background);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
line-height: 1.65;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top Bar */
|
||||||
|
.site-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
backdrop-filter: saturate(1.2) blur(6px);
|
||||||
|
border-bottom: 1px solid #1b281b;
|
||||||
|
background: linear-gradient(180deg, #0f150fdd, #0f150fb0 60%, #0f150f80);
|
||||||
|
height: 70px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header .inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--foreground);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
color: #0e120e;
|
||||||
|
background: conic-gradient(
|
||||||
|
from 120deg,
|
||||||
|
var(--accent),
|
||||||
|
var(--accent-2),
|
||||||
|
var(--accent) 75%
|
||||||
|
);
|
||||||
|
box-shadow: 0 1px 0 #0008, 0 6px 24px #0006;
|
||||||
|
border: 1px solid #2a392a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a:hover,
|
||||||
|
.site-nav a[aria-current="page"] {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
right: 8px;
|
||||||
|
bottom: 6px;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||||||
|
opacity: 0;
|
||||||
|
transform: scaleX(0.5);
|
||||||
|
transition: opacity 0.25s, transform 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a:hover::after,
|
||||||
|
.site-nav a[aria-current="page"]::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile nav toggle */
|
||||||
|
.menu-toggle {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid #2a392a;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--foreground);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-toggle .bars {
|
||||||
|
width: 18px;
|
||||||
|
height: 12px;
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-toggle .bars::before,
|
||||||
|
.menu-toggle .bars::after,
|
||||||
|
.menu-toggle .bars span {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--foreground);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-toggle .bars::before {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-toggle .bars::after {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-toggle .bars span {
|
||||||
|
top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero */
|
||||||
|
.hero {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: clamp(1.9rem, 4.2vw, 2.8rem);
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: var(--accent-2);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove any image-specific styles that might still be present */
|
||||||
|
.hero img,
|
||||||
|
.hero-image,
|
||||||
|
.about-image {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sections */
|
||||||
|
section {
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: color-mix(in srgb, var(--surface) 96%, black 4%);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: clamp(1.2rem, 2.2vw, 1.4rem);
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.lead {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skills (grouped tags) */
|
||||||
|
.skill-groups {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
background: linear-gradient(180deg, #111a11, #0d130d);
|
||||||
|
border: 1px solid #223222;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--accent-2);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
border: 1px solid #263626;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeline */
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: #204020;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-item {
|
||||||
|
position: relative;
|
||||||
|
margin: 14px 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-item::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -1px;
|
||||||
|
top: 0.45rem;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px #0f150f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-role {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-org a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.t-bullets {
|
||||||
|
margin: 0.3rem 0 0.2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Projects */
|
||||||
|
.proj-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proj {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(180deg, #131b13, #0f150f);
|
||||||
|
padding: 14px;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proj:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 14px 40px #0006;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
border: 1px solid #263626;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: var(--accent);
|
||||||
|
text-decoration-thickness: 0.08em;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--accent-2);
|
||||||
|
text-decoration-color: var(--accent-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:visited {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Urgent banner */
|
||||||
|
.banner {
|
||||||
|
background: linear-gradient(180deg, #1a231a, #121812);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-urgent {
|
||||||
|
background: #ff5858;
|
||||||
|
color: #0e120e;
|
||||||
|
border: 1px solid #ff9a9a;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner .btn {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: var(--accent);
|
||||||
|
text-decoration-thickness: 0.08em;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--accent-2);
|
||||||
|
text-decoration-color: var(--accent-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:visited {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clickable project cards */
|
||||||
|
.proj {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proj:focus-visible {
|
||||||
|
outline: 2px solid var(--accent-2);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
footer {
|
||||||
|
border-top: 1px solid #1e2a1e;
|
||||||
|
margin-top: 38px;
|
||||||
|
padding: 18px 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer .to-top {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reveal + Scrollspy */
|
||||||
|
.reveal {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
transition: opacity 0.6s ease, transform 0.6s ease;
|
||||||
|
will-change: opacity, transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revealed {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 840px) {
|
||||||
|
.hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav {
|
||||||
|
position: fixed;
|
||||||
|
left: 16px;
|
||||||
|
right: 16px;
|
||||||
|
top: 78px; /* 70px header height + 8px gap */
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
gap: 0;
|
||||||
|
display: none;
|
||||||
|
box-shadow: 0 10px 30px #0006;
|
||||||
|
z-index: 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav.is-open {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a {
|
||||||
|
display: block;
|
||||||
|
padding: 12px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user