Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/app/[locale]/(site)/projets/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export default async function ProjectPage({ params }: Props) {
return (
<Section>
<article className="flex flex-col gap-8">
<Button asChild variant="ghost" size="sm" className="self-start">
<Button asChild variant="ghost" className="self-start">
<Link href="/projets">
<ArrowLeftIcon />
{t("back")}
Expand Down Expand Up @@ -94,6 +94,7 @@ export default async function ProjectPage({ params }: Props) {
src={image}
alt={title}
fill
priority
sizes="(min-width: 1024px) 64rem, 100vw"
className="object-cover"
/>
Expand Down
4 changes: 4 additions & 0 deletions src/components/layout/header/mobile-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Button } from "@/components/shadcn-ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
Expand All @@ -35,6 +36,9 @@ export function MobileNav() {
<SheetContent side="right">
<SheetHeader>
<SheetTitle>{t("menu")}</SheetTitle>
<SheetDescription className="sr-only">
{t("menuDescription")}
</SheetDescription>
</SheetHeader>
<div className="px-2">
<MainNav orientation="vertical" onNavigate={() => setOpen(false)} />
Expand Down
10 changes: 5 additions & 5 deletions src/components/shadcn-ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,16 @@ const buttonVariants = cva(
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
"h-8 gap-1.5 px-2.5 pointer-coarse:min-h-11 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
lg: "h-9 gap-1.5 px-2.5 pointer-coarse:min-h-11 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8 pointer-coarse:size-11",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
"size-7 rounded-[min(var(--radius-md),12px)] pointer-coarse:size-11 in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9 pointer-coarse:size-11",
},
},
defaultVariants: {
Expand Down
1 change: 1 addition & 0 deletions src/features/about/components/about.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export function About() {

return (
<Section id="about" container={false} padded={false}>
<h2 className="sr-only">{t("title")}</h2>
<div className="-mx-4 grid items-stretch gap-10 sm:-mx-8 md:grid-cols-2">
{/* Portrait */}
<div className="relative aspect-square w-full overflow-hidden md:aspect-auto md:h-full">
Expand Down
7 changes: 6 additions & 1 deletion src/features/contact/components/contact.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,19 @@ export function Contact() {
sizes="100vw"
className="object-cover object-center"
/>
{/* Scrim: a solid dark floor keeps white text above AA over any region of
the image; the centered radial vignette deepens contrast behind the
text block toward AAA without flattening the whole photo. */}
<div className="absolute inset-0 bg-black/55" />
<div className="absolute inset-0 bg-radial from-black/45 to-transparent" />
</div>

<div className="relative flex flex-col items-center gap-6 text-center">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
{t("title")}
</h2>

<p className="max-w-xl text-lg text-white/70">{t("subtitle")}</p>
<p className="max-w-xl text-lg text-white/80">{t("subtitle")}</p>

<Button
asChild
Expand Down
4 changes: 3 additions & 1 deletion src/features/graph/components/skills-graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,9 @@ export function SkillsGraph({
return {
...edge,
style: {
stroke: active ? "rgb(148 163 184 / 0.7)" : "rgb(148 163 184 / 0.12)",
stroke: active
? "color-mix(in oklch, var(--color-muted-foreground) 70%, transparent)"
: "color-mix(in oklch, var(--color-muted-foreground) 12%, transparent)",
},
};
});
Expand Down
24 changes: 24 additions & 0 deletions src/features/hero/components/hero-skills-preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"use client";

import { useEffect, useState } from "react";
import { SkillsGraph } from "@/features/graph";

/**
* Mounts the (heavy) skills graph only on md+ viewports. The Hero preview is
* already `hidden` below md via CSS, but without this gate React still mounts
* React Flow and runs the d3 force simulation on mobile, where it's never seen.
*/
export function HeroSkillsPreview({ className }: { className?: string }) {
const [show, setShow] = useState(false);

useEffect(() => {
const mq = window.matchMedia("(min-width: 768px)");
const update = () => setShow(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);

if (!show) return null;
return <SkillsGraph interactive={false} className={className} />;
}
7 changes: 2 additions & 5 deletions src/features/hero/components/hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import { Button } from "@/components/shadcn-ui/button";
import { Section } from "@/components/shared/section";
import { GithubIcon, LinkedinIcon } from "@/components/shared/social-icons";
import { siteConfig } from "@/config/site";
import { SkillsGraph } from "@/features/graph";
import { Link } from "@/i18n/navigation";
import { HeroSkillsPreview } from "./hero-skills-preview";

const socials = [
{ label: "GitHub", href: siteConfig.socials.github, icon: GithubIcon },
Expand Down Expand Up @@ -78,10 +78,7 @@ export function Hero() {

{/* Right column — non-interactive preview, links to the full graph. */}
<div className="group relative hidden aspect-square w-full md:block">
<SkillsGraph
interactive={false}
className="transition-colors group-hover:border-foreground/30"
/>
<HeroSkillsPreview className="transition-colors group-hover:border-foreground/30" />
<Link
href="/graph"
aria-label={t("viewGraph")}
Expand Down
12 changes: 2 additions & 10 deletions src/features/projects/components/project-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { GithubIcon } from "@/components/shared/social-icons";
import { Link } from "@/i18n/navigation";
import type { Locale } from "@/i18n/routing";
import type { Project } from "../data";
import { ProjectTags } from "./project-tags";

export function ProjectCard({
project,
Expand Down Expand Up @@ -88,16 +89,7 @@ export function ProjectCard({
{description[locale]}
</p>

<ul className="mt-1 flex gap-1.5 overflow-hidden">
{tags.map((tag) => (
<li
key={tag}
className="shrink-0 whitespace-nowrap rounded-md border px-2 py-0.5 text-muted-foreground text-xs"
>
{tag}
</li>
))}
</ul>
<ProjectTags tags={tags} />
</div>
</article>
);
Expand Down
54 changes: 54 additions & 0 deletions src/features/projects/components/project-tags.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"use client";

import { cn } from "@/lib/utils/cn";
import { useOneLineFit } from "../hooks/use-one-line-fit";

const tagClass =
"whitespace-nowrap rounded-md border px-2 py-0.5 text-muted-foreground text-xs";

/**
* Project tech tags kept to a single line: as many as fit are shown, the rest
* collapse into a "+N" badge (count measured by `useOneLineFit`). A visually
* hidden list keeps every tag available to assistive tech.
*/
export function ProjectTags({ tags }: { tags: readonly string[] }) {
const { wrapperRef, measureRef, visibleCount } = useOneLineFit(tags.length);
const hiddenCount = tags.length - visibleCount;

return (
<div ref={wrapperRef} className="relative mt-1">
{/* Visible single-line row (decorative; real list is the sr-only one). */}
<ul aria-hidden className="flex gap-1.5 overflow-hidden">
{tags.slice(0, visibleCount).map((tag) => (
<li key={tag} className={tagClass}>
{tag}
</li>
))}
{hiddenCount > 0 && (
<li className={cn(tagClass, "shrink-0")}>+{hiddenCount}</li>
)}
</ul>

{/* Off-screen measurement row: every tag plus a worst-case "+N" badge. */}
<ul
ref={measureRef}
aria-hidden
className="pointer-events-none invisible absolute flex gap-1.5"
>
{tags.map((tag) => (
<li key={tag} className={tagClass}>
{tag}
</li>
))}
<li className={tagClass}>+{tags.length}</li>
</ul>

{/* Accessible: all tags, read once by screen readers. */}
<ul className="sr-only">
{tags.map((tag) => (
<li key={tag}>{tag}</li>
))}
</ul>
</div>
);
}
74 changes: 74 additions & 0 deletions src/features/projects/hooks/use-one-line-fit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"use client";

import { useEffect, useRef, useState } from "react";

/**
* Measures how many of `itemCount` items fit on a single line, reserving room
* for a trailing "+N" overflow indicator when they don't all fit. Re-measures
* on container resize.
*
* Rendering contract:
* - Attach `wrapperRef` to the element whose width is the available space.
* - Attach `measureRef` to a hidden, single-line element that renders **every**
* item in order, followed by the overflow indicator as its last child. It
* stays mounted with all items so measurements never feed back into layout.
*
* Returns `visibleCount` — how many leading items to show. When it is less than
* `itemCount`, render a "+N" badge for the remainder.
*/
export function useOneLineFit(itemCount: number) {
const wrapperRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLUListElement>(null);
const [visibleCount, setVisibleCount] = useState(itemCount);

useEffect(() => {
const wrapper = wrapperRef.current;
const measure = measureRef.current;
if (!wrapper || !measure) return;

const compute = () => {
const children = Array.from(measure.children) as HTMLElement[];
// children = [...items, badge]; bail if the layer isn't rendered yet.
if (children.length <= 1) return;

const badgeWidth =
children[children.length - 1].getBoundingClientRect().width;
const itemWidths = children
.slice(0, itemCount)
.map((el) => el.getBoundingClientRect().width);
const gap = Number.parseFloat(getComputedStyle(measure).columnGap) || 0;
const available = wrapper.clientWidth;

// Greedily fit items on the line, ignoring the badge for now.
let used = 0;
let count = 0;
for (const width of itemWidths) {
const next = used + (count > 0 ? gap : 0) + width;
if (next > available) break;
used = next;
count += 1;
}

// If some items overflow, the "+N" badge must also fit on the line, so
// drop items until the visible ones plus the badge fit.
if (count < itemCount) {
while (count > 0) {
const itemsWidth =
itemWidths.slice(0, count).reduce((a, b) => a + b, 0) +
gap * (count - 1);
if (itemsWidth + gap + badgeWidth <= available) break;
count -= 1;
}
}

setVisibleCount(count);
};

const observer = new ResizeObserver(compute);
observer.observe(wrapper);
compute();
return () => observer.disconnect();
}, [itemCount]);

return { wrapperRef, measureRef, visibleCount };
}
1 change: 1 addition & 0 deletions src/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"cv": "Resume",
"openMenu": "Open menu",
"menu": "Menu",
"menuDescription": "Main site navigation",
"toggleTheme": "Toggle theme",
"light": "Light",
"dark": "Dark",
Expand Down
1 change: 1 addition & 0 deletions src/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"cv": "CV",
"openMenu": "Ouvrir le menu",
"menu": "Menu",
"menuDescription": "Navigation principale du site",
"toggleTheme": "Changer de thème",
"light": "Clair",
"dark": "Sombre",
Expand Down
14 changes: 0 additions & 14 deletions src/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -75,26 +75,12 @@ body {
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
}

/* Vite signature gradient text: cyan -> purple. Use as className. */
@utility text-gradient-vite {
background-image: var(--gradient-vite);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}

/* The Vite hero gradient as a background. */
@utility bg-gradient-vite {
background-image: var(--gradient-vite);
}

:root {
/* Vite brand colors (#41d1ff, #646cff, #bd34fe, #ffea83) */
--vite-cyan: oklch(0.8 0.13 222);
--vite-indigo: oklch(0.585 0.228 277);
--vite-purple: oklch(0.62 0.29 314);
--vite-yellow: oklch(0.93 0.11 100);
--gradient-vite: linear-gradient(120deg, var(--vite-cyan), var(--vite-purple));

color-scheme: light;
--background: oklch(1 0 0);
Expand Down
Loading