520 lines
15 KiB
Plaintext
520 lines
15 KiB
Plaintext
---
|
|
/**
|
|
* Home = the voyage. One long timeline: hero → profile → asteroid belt with a
|
|
* beacon per project → stack constellation → contact portal.
|
|
*
|
|
* Two rendering modes from the same markup:
|
|
* - default (no JS, no WebGL, reduced motion): normal stacked sections
|
|
* - `html.cinema` (set by voyage.ts): sections become fixed overlays,
|
|
* the wrapper's height becomes the scroll timeline.
|
|
*/
|
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
|
import Eyebrow from '../components/Eyebrow.astro';
|
|
import TechIcon from '../components/TechIcon.astro';
|
|
import { Copy } from '@lucide/astro';
|
|
import { Badge, Button } from '@unkn0wndo3s/nova-design-system';
|
|
import { site, stack } from '../data/site';
|
|
import { featuredProjects, projects, type Project } from '../data/projects';
|
|
import { breadcrumbLd, profilePageLd } from '../lib/seo';
|
|
import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n';
|
|
|
|
const locale = getLocaleFromUrl(Astro.url);
|
|
const t = useTranslations(locale);
|
|
|
|
const voyageProjects: Project[] = [...featuredProjects, ...projects.filter((p) => !p.featured)];
|
|
const statusType = { live: 'success', maintained: 'info', wip: 'warning' } as const;
|
|
const statusLabel = {
|
|
live: t('work.status.live'),
|
|
maintained: t('work.status.maintained'),
|
|
wip: t('work.status.wip'),
|
|
} as const;
|
|
|
|
const pillars = [
|
|
{ title: t('home.profile.front.title'), body: t('home.profile.front.body') },
|
|
{ title: t('home.profile.back.title'), body: t('home.profile.back.body') },
|
|
{ title: t('home.profile.ops.title'), body: t('home.profile.ops.body') },
|
|
];
|
|
|
|
const dots = ['hero', 'about', 'projects', ...voyageProjects.map((p) => `p-${p.slug}`), 'stack', 'contact'];
|
|
---
|
|
|
|
<BaseLayout
|
|
title={t('meta.home.title')}
|
|
description={t('meta.home.description')}
|
|
jsonLd={[breadcrumbLd([{ name: t('nav.home'), url: localizePath('/', locale) }]), profilePageLd(new URL(Astro.url.pathname, Astro.site ?? 'https://louis-potevin.dev').href)]}
|
|
background={false}
|
|
fullBleed
|
|
>
|
|
<canvas class="voyage-canvas" data-voyage-canvas aria-hidden="true"></canvas>
|
|
|
|
<div class="voyage" data-voyage>
|
|
<!-- 01 · Hero -->
|
|
<section class="seg seg--center" data-seg="hero" data-weight="1.1" id="hero">
|
|
<div class="card card--hero">
|
|
<p class="hero-avail"><Badge type="success" variant="soft">{t('avail.badge')}</Badge> <span>{t('avail.line')}</span></p>
|
|
<h1 class="hero-title">
|
|
<span class="hero-name">{site.name}</span><span class="sr-only">, </span>
|
|
<span class="hero-role">{t('home.hero.title.role')}</span>
|
|
</h1>
|
|
<p class="hero-lead">{t('home.hero.lead')}</p>
|
|
<div class="row">
|
|
<Button type="primary" href={localizePath('/#projects', locale)}>{t('home.hero.viewProjects')}</Button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 02 · Profil -->
|
|
<section class="seg seg--left" data-seg="about" data-weight="1.3" data-side="left" id="about">
|
|
<div class="card">
|
|
<Eyebrow>{t('home.profile.eyebrow')}</Eyebrow>
|
|
<h2 class="card-title">{t('home.profile.title')}</h2>
|
|
<p class="card-body">{t('home.profile.body1')}</p>
|
|
<p class="card-body card-body--muted">{t('home.about.summary')}</p>
|
|
<ul class="pillars">
|
|
{pillars.map((p) => (
|
|
<li><strong>{p.title}</strong><span>{p.body}</span></li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 03 · Cap sur les projets -->
|
|
<section class="seg seg--center" data-seg="projects" data-weight="0.7" id="projects">
|
|
<div class="card card--center">
|
|
<Eyebrow>{t('home.projects.eyebrow')}</Eyebrow>
|
|
<h2 class="card-title card-title--big">{t('home.projects.intro.title')}</h2>
|
|
<p class="card-body">{t('home.projects.intro.body')}</p>
|
|
<div class="row row--center">
|
|
<Button type="secondary" href={localizePath('/work', locale)}>{t('home.projects.all')}</Button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 04-07 · Un segment par projet, cartes alternées -->
|
|
{voyageProjects.map((p, i) => (
|
|
<div
|
|
class={`seg seg--${i % 2 ? 'left' : 'right'}`}
|
|
data-seg={`p-${p.slug}`}
|
|
data-weight="1.15"
|
|
data-side={i % 2 ? 'left' : 'right'}
|
|
data-project
|
|
id={`p-${p.slug}`}
|
|
>
|
|
<article class="card card--project">
|
|
<p class="proj-index" aria-hidden="true">{String(i + 1).padStart(2, '0')}</p>
|
|
<div class="proj-head">
|
|
<Badge type={statusType[p.status]}>{statusLabel[p.status]}</Badge>
|
|
</div>
|
|
<h2 class="card-title">{p.name}</h2>
|
|
<p class="card-body">{p.tagline[locale]}</p>
|
|
<ul class="proj-highlights">
|
|
{p.highlights.slice(0, 2).map((h) => <li>{h[locale]}</li>)}
|
|
</ul>
|
|
<div class="row">
|
|
<Button type="primary" href={localizePath(`/work/${p.slug}`, locale)}>
|
|
{t('home.projects.view')}
|
|
</Button>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
))}
|
|
|
|
<!-- 08 · Stack -->
|
|
<section class="seg seg--center" data-seg="stack" data-weight="1.05" id="stack">
|
|
<div class="card card--center card--wide">
|
|
<Eyebrow>{t('home.stack.eyebrow')}</Eyebrow>
|
|
<h2 class="card-title">{t('home.stack.title')}</h2>
|
|
<ul class="stack-grid">
|
|
{stack.map((s) => <li><TechIcon name={s.name} icon={s.icon} /></li>)}
|
|
</ul>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- 09 · Contact (arrivée au portail) -->
|
|
<section class="seg seg--center" data-seg="contact" data-weight="1.2" id="contact">
|
|
<div class="card card--center">
|
|
<Eyebrow>{t('contact.title')}</Eyebrow>
|
|
<h2 class="card-title card-title--big">{t('home.cta.title')}</h2>
|
|
<p class="card-body">{t('home.cta.body')}</p>
|
|
<div class="row row--center">
|
|
<Button type="primary" href={`mailto:${site.email}`}>{t('contact.email.write')}</Button>
|
|
<Button type="ghost" htmlType="button" data-copy-email data-copied-label={t('contact.email.copied')}>
|
|
<Copy size={16} />
|
|
<span data-copy-label>{t('contact.email.copy')}</span>
|
|
</Button>
|
|
</div>
|
|
<p class="contact-email" data-email-text>{site.email}</p>
|
|
<p class="contact-where">{t('avail.where')}</p>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<!-- Timeline UI (cinema mode only) -->
|
|
<div class="rail" aria-hidden="true">
|
|
<div class="rail__bar" data-voyage-progress></div>
|
|
</div>
|
|
<nav class="dots" aria-label={t('voyage.progress')}>
|
|
{dots.map((id) => <button type="button" data-voyage-dot={id} aria-label={id}></button>)}
|
|
</nav>
|
|
<script>
|
|
import { ensureSmooth } from '../lib/smooth';
|
|
import type { VoyageHandle } from '../components/scene/voyage';
|
|
|
|
let handle: VoyageHandle | null = null;
|
|
|
|
async function init() {
|
|
const canvas = document.querySelector<HTMLCanvasElement>('[data-voyage-canvas]');
|
|
const container = document.querySelector<HTMLElement>('[data-voyage]');
|
|
if (!canvas || !container) return;
|
|
|
|
const lenis = ensureSmooth();
|
|
if (!lenis) return; // reduced motion → static page, canvas stays hidden
|
|
|
|
// Three.js only downloads when the cinema mode is actually possible.
|
|
const { mountVoyage } = await import('../components/scene/voyage');
|
|
handle = mountVoyage({ canvas, container, lenis });
|
|
if (!handle) return; // no WebGL → static page
|
|
|
|
// Deep links (/#about, nav anchors...) become smooth seeks on the timeline.
|
|
if (location.hash) handle.seek(location.hash.slice(1), true);
|
|
document.addEventListener('click', (e) => {
|
|
const a = (e.target as HTMLElement).closest<HTMLAnchorElement>('a[href*="#"]');
|
|
if (!a || !handle) return;
|
|
if (a.pathname !== location.pathname) return;
|
|
const id = a.hash.slice(1);
|
|
e.preventDefault();
|
|
history.replaceState(null, '', a.hash);
|
|
handle.seek(id);
|
|
});
|
|
}
|
|
|
|
function initCopy() {
|
|
const btn = document.querySelector<HTMLButtonElement>('[data-copy-email]');
|
|
const label = btn?.querySelector<HTMLElement>('[data-copy-label]');
|
|
const email = document.querySelector<HTMLElement>('[data-email-text]')?.textContent?.trim() ?? '';
|
|
btn?.addEventListener('click', async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(email);
|
|
if (label && btn.dataset.copiedLabel) {
|
|
const orig = label.textContent;
|
|
label.textContent = btn.dataset.copiedLabel;
|
|
window.setTimeout(() => (label.textContent = orig), 1800);
|
|
}
|
|
} catch {
|
|
/* clipboard unavailable - the address stays readable as text */
|
|
}
|
|
});
|
|
}
|
|
|
|
init();
|
|
initCopy();
|
|
</script>
|
|
</BaseLayout>
|
|
|
|
|
|
<style lang="scss">
|
|
@use '../styles/type' as type;
|
|
|
|
/* ---------- Canvas & timeline chrome (hidden until cinema mode) --------- */
|
|
.voyage-canvas {
|
|
display: none;
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 0;
|
|
}
|
|
.rail,
|
|
.dots {
|
|
display: none;
|
|
}
|
|
|
|
:global(html.cinema) .voyage-canvas {
|
|
display: block;
|
|
}
|
|
:global(html.cinema) .rail {
|
|
display: block;
|
|
position: fixed;
|
|
inset-inline: 0;
|
|
top: 0;
|
|
height: 2px;
|
|
z-index: 60;
|
|
background: color-mix(in srgb, var(--nds-border) 60%, transparent);
|
|
|
|
.rail__bar {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, var(--nds-primary), var(--nds-accent));
|
|
transform-origin: left;
|
|
transform: scaleX(0);
|
|
}
|
|
}
|
|
:global(html.cinema) .dots {
|
|
position: fixed;
|
|
right: clamp(10px, 2vw, 26px);
|
|
top: 50%;
|
|
translate: 0 -50%;
|
|
z-index: 60;
|
|
display: grid;
|
|
gap: 4px;
|
|
|
|
/* 22px hit area (touch target), 8px painted dot */
|
|
button {
|
|
position: relative;
|
|
width: 22px;
|
|
height: 22px;
|
|
padding: 0;
|
|
border: none;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
|
|
&::before {
|
|
content: '';
|
|
position: absolute;
|
|
inset: 50%;
|
|
width: 8px;
|
|
height: 8px;
|
|
translate: -50% -50%;
|
|
border: 1px solid var(--nds-neutral);
|
|
border-radius: var(--nds-radius-full);
|
|
transition: background-color 0.25s ease, transform 0.25s ease, border-color 0.25s ease;
|
|
}
|
|
&.is-active::before {
|
|
background: var(--nds-accent);
|
|
border-color: var(--nds-accent);
|
|
transform: scale(1.35);
|
|
}
|
|
&:hover::before {
|
|
border-color: var(--nds-text);
|
|
}
|
|
}
|
|
@media (max-width: 840px) {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
/* ------------------------- Sections: static default --------------------- */
|
|
.seg {
|
|
position: relative;
|
|
z-index: 10;
|
|
min-height: 100svh;
|
|
display: flex;
|
|
align-items: center;
|
|
padding-inline: clamp(20px, 6vw, 72px);
|
|
|
|
&.seg--center {
|
|
justify-content: center;
|
|
text-align: center;
|
|
}
|
|
&.seg--right {
|
|
justify-content: flex-end;
|
|
}
|
|
}
|
|
|
|
/* Cinema: fixed overlays, driven per-frame by voyage.ts */
|
|
:global(html.cinema) .seg {
|
|
position: fixed;
|
|
inset: 0;
|
|
min-height: 0;
|
|
visibility: hidden;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
will-change: opacity, transform;
|
|
|
|
&.is-active {
|
|
pointer-events: auto;
|
|
}
|
|
}
|
|
|
|
/* ------------------------------- Cards ---------------------------------- */
|
|
.card {
|
|
display: grid;
|
|
gap: var(--nds-spacing-md);
|
|
justify-items: start;
|
|
width: min(600px, 94vw);
|
|
padding: clamp(var(--nds-spacing-lg), 3vw, var(--nds-spacing-2xl));
|
|
background: color-mix(in srgb, var(--nds-background) 62%, transparent);
|
|
border: 1px solid color-mix(in srgb, var(--nds-border) 80%, transparent);
|
|
border-radius: var(--nds-radius-lg);
|
|
backdrop-filter: blur(10px);
|
|
box-shadow: var(--nds-shadow-md);
|
|
}
|
|
.card--center {
|
|
justify-items: center;
|
|
text-align: center;
|
|
}
|
|
.card--wide {
|
|
width: min(780px, 94vw);
|
|
}
|
|
.card--hero {
|
|
width: min(860px, 94vw);
|
|
background: transparent;
|
|
border: none;
|
|
box-shadow: none;
|
|
backdrop-filter: none;
|
|
justify-items: center;
|
|
text-align: center;
|
|
}
|
|
|
|
.row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: var(--nds-spacing-sm);
|
|
}
|
|
.row--center {
|
|
justify-content: center;
|
|
}
|
|
|
|
.card-title {
|
|
@include type.text-3xl;
|
|
margin: 0;
|
|
color: var(--nds-text);
|
|
text-wrap: balance;
|
|
}
|
|
.card-title--big {
|
|
@include type.text-4xl;
|
|
}
|
|
.card-body {
|
|
@include type.text-base;
|
|
margin: 0;
|
|
max-width: 56ch;
|
|
color: var(--nds-neutral);
|
|
}
|
|
.card-body--muted {
|
|
color: var(--nds-disabled);
|
|
}
|
|
|
|
/* Hero */
|
|
.hero-avail {
|
|
@include type.text-sm;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--nds-spacing-sm);
|
|
margin: 0;
|
|
color: var(--nds-neutral);
|
|
}
|
|
.hero-title {
|
|
display: grid;
|
|
gap: var(--nds-spacing-2xs);
|
|
margin: 0;
|
|
}
|
|
.hero-name {
|
|
@include type.text-5xl;
|
|
color: var(--nds-text);
|
|
letter-spacing: -0.01em;
|
|
}
|
|
.hero-role {
|
|
@include type.text-xl;
|
|
color: var(--nds-accent);
|
|
}
|
|
.hero-lead {
|
|
@include type.text-lg;
|
|
margin: 0;
|
|
max-width: 58ch;
|
|
color: var(--nds-neutral);
|
|
}
|
|
|
|
/* Profil */
|
|
.pillars {
|
|
display: grid;
|
|
gap: var(--nds-spacing-sm);
|
|
margin: 0;
|
|
padding: 0;
|
|
list-style: none;
|
|
|
|
li {
|
|
display: grid;
|
|
gap: 2px;
|
|
padding-left: var(--nds-spacing-md);
|
|
border-left: 2px solid var(--nds-primary);
|
|
|
|
strong {
|
|
@include type.text-label;
|
|
color: var(--nds-text);
|
|
}
|
|
span {
|
|
@include type.text-sm;
|
|
color: var(--nds-neutral);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Projets */
|
|
.card--project {
|
|
position: relative;
|
|
}
|
|
.proj-index {
|
|
@include type.text-5xl;
|
|
position: absolute;
|
|
top: -0.55em;
|
|
right: var(--nds-spacing-lg);
|
|
margin: 0;
|
|
color: color-mix(in srgb, var(--nds-accent) 26%, transparent);
|
|
font-variant-numeric: tabular-nums;
|
|
pointer-events: none;
|
|
}
|
|
.proj-highlights {
|
|
display: grid;
|
|
gap: var(--nds-spacing-2xs);
|
|
margin: 0;
|
|
padding: 0;
|
|
list-style: none;
|
|
|
|
li {
|
|
@include type.text-sm;
|
|
color: var(--nds-neutral);
|
|
padding-left: var(--nds-spacing-md);
|
|
position: relative;
|
|
|
|
&::before {
|
|
content: '◆';
|
|
position: absolute;
|
|
left: 0;
|
|
color: var(--nds-accent);
|
|
font-size: 0.6em;
|
|
top: 0.45em;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Stack */
|
|
.stack-grid {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
gap: var(--nds-spacing-sm);
|
|
margin: 0;
|
|
padding: 0;
|
|
list-style: none;
|
|
}
|
|
|
|
/* Contact */
|
|
.contact-email {
|
|
@include type.text-base;
|
|
margin: 0;
|
|
color: var(--nds-primary);
|
|
word-break: break-all;
|
|
}
|
|
.contact-where {
|
|
@include type.text-sm;
|
|
margin: 0;
|
|
color: var(--nds-disabled);
|
|
}
|
|
.sr-only {
|
|
position: absolute;
|
|
width: 1px;
|
|
height: 1px;
|
|
margin: -1px;
|
|
overflow: hidden;
|
|
clip: rect(0 0 0 0);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
@media (max-width: 840px) {
|
|
.seg,
|
|
.seg.seg--right {
|
|
justify-content: center;
|
|
}
|
|
.proj-index {
|
|
right: var(--nds-spacing-md);
|
|
}
|
|
}
|
|
</style>
|