feat: rebuild mintel.me as a single-page freelance site
The site now presents one freelance developer on one page: hero, introduction, four typical cases (developer for a product, make an app reliable, automate a process, a website) with what you get, how it runs and what it costs, a process automation section, technology and project fit, how I work, two live references, questions and contact. - Hero and closing headline are drawn from running binary digits. - Each case explains its four steps with an animated drawing; the automation section shows example workflows with work travelling between systems; a pipeline run shows what a test catches before a release. - Contact is one email address, kept in parts and assembled in the browser so it never appears in the page source (src/domain/email.ts, spec first). - Imprint and privacy pages added. - The old site (blog, case studies, contact form, fixed-price pages, video rendering and related scripts) is removed. Its URLs redirect to the homepage. - The deploy smoke test checks the legal pages and the redirects instead of the removed case study. - Dev scripts use docker compose v2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Image from "next/image";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export type WorkItem = {
|
||||
readonly name: string;
|
||||
readonly href: string;
|
||||
readonly client: string;
|
||||
readonly text: string;
|
||||
readonly did: readonly string[];
|
||||
readonly tech: readonly string[];
|
||||
readonly desktop: {
|
||||
readonly src: string;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
};
|
||||
readonly mobile: {
|
||||
readonly src: string;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
};
|
||||
};
|
||||
|
||||
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em]";
|
||||
|
||||
/** How far a tall screenshot has to move so its end reaches the bottom of the frame. */
|
||||
const travel = (
|
||||
shot: { width: number; height: number },
|
||||
frameAspect: number,
|
||||
): string => {
|
||||
const visible = (shot.width / frameAspect / shot.height) * 100;
|
||||
return `-${Math.max(0, 100 - visible).toFixed(2)}%`;
|
||||
};
|
||||
|
||||
/** A screenshot that scrolls slowly through its frame while it is on screen. */
|
||||
const ScrollingShot: React.FC<{
|
||||
shot: { src: string; width: number; height: number };
|
||||
frameAspect: number;
|
||||
alt: string;
|
||||
sizes: string;
|
||||
active: boolean;
|
||||
seconds: number;
|
||||
}> = ({ shot, frameAspect, alt, sizes, active, seconds }) => (
|
||||
<motion.div
|
||||
animate={
|
||||
active ? { y: ["0%", travel(shot, frameAspect), "0%"] } : { y: "0%" }
|
||||
}
|
||||
transition={
|
||||
active
|
||||
? {
|
||||
duration: seconds,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
times: [0, 0.5, 1],
|
||||
}
|
||||
: { duration: 0.6 }
|
||||
}
|
||||
>
|
||||
<Image
|
||||
src={shot.src}
|
||||
alt={alt}
|
||||
width={shot.width}
|
||||
height={shot.height}
|
||||
sizes={sizes}
|
||||
className="block h-auto w-full"
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const WorkStage: React.FC<{ item: WorkItem }> = ({ item }) => {
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const [inView, setInView] = React.useState(false);
|
||||
const [still, setStill] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setStill(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setInView(entry?.isIntersecting ?? false),
|
||||
{ threshold: 0.3 },
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const active = inView && !still;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="relative overflow-hidden rounded-3xl bg-slate-900 px-5 pb-0 pt-8 md:px-12 md:pt-14"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-60"
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(#1e293b 1px, transparent 1px)",
|
||||
backgroundSize: "20px 20px",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative mx-auto max-w-4xl">
|
||||
{/* Desktop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-10%" }}
|
||||
transition={{ duration: 0.9, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="overflow-hidden rounded-t-2xl border border-b-0 border-slate-700 bg-slate-950"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 border-b border-slate-800 px-4 py-3">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-slate-700" />
|
||||
<span className="ml-3 rounded-full bg-slate-800 px-3 py-1 font-mono text-[10px] tracking-[0.1em] text-slate-400">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="aspect-[16/10] overflow-hidden">
|
||||
<ScrollingShot
|
||||
shot={item.desktop}
|
||||
frameAspect={16 / 10}
|
||||
alt={`${item.name} on a desktop screen`}
|
||||
sizes="(min-width: 1024px) 900px, 100vw"
|
||||
active={active}
|
||||
seconds={22}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Phone */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 60 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-10%" }}
|
||||
transition={{ duration: 0.9, delay: 0.15, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="absolute -right-2 bottom-0 w-[26%] min-w-[92px] overflow-hidden rounded-t-[1.25rem] border-[3px] border-b-0 border-slate-700 bg-slate-950 shadow-2xl shadow-black/60 md:-right-8 md:rounded-t-[2rem] md:border-[5px]"
|
||||
>
|
||||
<div className="aspect-[9/17] overflow-hidden">
|
||||
<ScrollingShot
|
||||
shot={item.mobile}
|
||||
frameAspect={9 / 17}
|
||||
alt={`${item.name} on a phone`}
|
||||
sizes="240px"
|
||||
active={active}
|
||||
seconds={26}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* WorkShowcase: each live site on a dark stage, as a desktop and a phone view
|
||||
* that scroll through the real pages.
|
||||
*/
|
||||
export const WorkShowcase: React.FC<{ items: readonly WorkItem[] }> = ({
|
||||
items,
|
||||
}) => (
|
||||
<div className="flex flex-col gap-20 md:gap-32">
|
||||
{items.map((item, index) => (
|
||||
<article
|
||||
key={item.name}
|
||||
className="grid grid-cols-1 gap-10 md:grid-cols-12 md:items-center md:gap-12"
|
||||
>
|
||||
<div className={`md:col-span-8 ${index % 2 === 1 ? "md:order-2" : ""}`}>
|
||||
<WorkStage item={item} />
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-4">
|
||||
<p className={`${MONO} m-0 text-slate-500`}>
|
||||
0{index + 1} · {item.client}
|
||||
</p>
|
||||
<h3 className="m-0 mt-4 font-sans text-3xl font-bold tracking-tighter text-slate-900 md:text-5xl">
|
||||
{item.name}
|
||||
</h3>
|
||||
<p className="m-0 mt-5 font-sans text-lg leading-relaxed text-slate-500 md:text-xl">
|
||||
{item.text}
|
||||
</p>
|
||||
|
||||
<ul className="m-0 mt-8 list-none p-0">
|
||||
{item.did.map((line) => (
|
||||
<li
|
||||
key={line}
|
||||
className="border-t border-slate-200 py-3 font-sans text-base text-slate-900 md:text-lg"
|
||||
>
|
||||
{line}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<ul className="m-0 mt-6 flex list-none flex-wrap gap-2 p-0">
|
||||
{item.tech.map((tech) => (
|
||||
<li
|
||||
key={tech}
|
||||
className="rounded-full border border-slate-200 px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-slate-600"
|
||||
>
|
||||
{tech}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<a
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-8 inline-flex items-center gap-3 rounded-full bg-slate-900 px-6 py-3 font-sans text-[11px] font-bold uppercase tracking-[0.15em] text-white no-underline transition-transform duration-300 hover:-translate-y-0.5"
|
||||
>
|
||||
Open the live site <span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
Reference in New Issue
Block a user