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,449 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useNarrow } from "./useNarrow";
|
||||
import {
|
||||
KIND_LABEL,
|
||||
WORKFLOWS,
|
||||
type FlowNode,
|
||||
type NodeKind,
|
||||
type Workflow,
|
||||
} from "@/src/content/automation";
|
||||
|
||||
/**
|
||||
* AutomationFlow: a workflow drawn as connected systems, with the work visibly
|
||||
* travelling through it. Each run plays step by step and writes a log beside it.
|
||||
*/
|
||||
|
||||
const WIDE = { w: 920, h: 400 } as const;
|
||||
const TALL = { w: 400, h: 540 } as const;
|
||||
const NODE_W = 172;
|
||||
const NODE_H = 64;
|
||||
const TRAVEL_MS = 1050;
|
||||
const DWELL_MS = 520;
|
||||
const PAUSE_MS = 1400;
|
||||
const MARK = "#fde68a";
|
||||
|
||||
type Point = { readonly x: number; readonly y: number };
|
||||
|
||||
/** A node placed for the current canvas: wide runs left to right, tall runs top to bottom. */
|
||||
type Placed = FlowNode & { readonly cx: number; readonly cy: number };
|
||||
|
||||
const place = (node: FlowNode, tall: boolean): Placed => ({
|
||||
...node,
|
||||
cx: tall ? node.nx : node.x,
|
||||
cy: tall ? node.ny : node.y,
|
||||
});
|
||||
|
||||
/** Cubic curve between two nodes, leaving and entering along the flow direction. */
|
||||
const curve = (from: Placed, to: Placed, tall: boolean) => {
|
||||
if (tall) {
|
||||
const p0: Point = { x: from.cx, y: from.cy + NODE_H / 2 };
|
||||
const p3: Point = { x: to.cx, y: to.cy - NODE_H / 2 };
|
||||
const bend = Math.max(24, (p3.y - p0.y) / 2);
|
||||
return {
|
||||
p0,
|
||||
p1: { x: p0.x, y: p0.y + bend },
|
||||
p2: { x: p3.x, y: p3.y - bend },
|
||||
p3,
|
||||
};
|
||||
}
|
||||
const p0: Point = { x: from.cx + NODE_W / 2, y: from.cy };
|
||||
const p3: Point = { x: to.cx - NODE_W / 2, y: to.cy };
|
||||
const bend = Math.max(40, (p3.x - p0.x) / 2);
|
||||
return {
|
||||
p0,
|
||||
p1: { x: p0.x + bend, y: p0.y },
|
||||
p2: { x: p3.x - bend, y: p3.y },
|
||||
p3,
|
||||
};
|
||||
};
|
||||
|
||||
const pathOf = (from: Placed, to: Placed, tall: boolean): string => {
|
||||
const { p0, p1, p2, p3 } = curve(from, to, tall);
|
||||
return `M ${p0.x} ${p0.y} C ${p1.x} ${p1.y} ${p2.x} ${p2.y} ${p3.x} ${p3.y}`;
|
||||
};
|
||||
|
||||
const pointOn = (from: Placed, to: Placed, t: number, tall: boolean): Point => {
|
||||
const { p0, p1, p2, p3 } = curve(from, to, tall);
|
||||
const u = 1 - t;
|
||||
return {
|
||||
x:
|
||||
u * u * u * p0.x +
|
||||
3 * u * u * t * p1.x +
|
||||
3 * u * t * t * p2.x +
|
||||
t * t * t * p3.x,
|
||||
y:
|
||||
u * u * u * p0.y +
|
||||
3 * u * u * t * p1.y +
|
||||
3 * u * t * t * p2.y +
|
||||
t * t * t * p3.y,
|
||||
};
|
||||
};
|
||||
|
||||
const ease = (t: number): number =>
|
||||
t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
||||
|
||||
const KIND_STYLE: Readonly<
|
||||
Record<NodeKind, { stroke: string; dash?: string; tag: string }>
|
||||
> = {
|
||||
trigger: { stroke: "#e2e8f0", tag: "#e2e8f0" },
|
||||
rule: { stroke: "#64748b", tag: "#94a3b8" },
|
||||
ai: { stroke: "#fde68a", dash: "5 4", tag: "#fde68a" },
|
||||
action: { stroke: "#64748b", tag: "#94a3b8" },
|
||||
human: { stroke: "#f8fafc", dash: "2 4", tag: "#f8fafc" },
|
||||
};
|
||||
|
||||
type Clock = {
|
||||
/** Which run of the workflow is playing. */
|
||||
readonly run: number;
|
||||
/** Step within the run; equal to steps.length while pausing at the end. */
|
||||
readonly step: number;
|
||||
/** 0..1 progress of the travelling packets within the step. */
|
||||
readonly t: number;
|
||||
};
|
||||
|
||||
const MONO = "font-mono text-[11px] uppercase tracking-[0.2em]";
|
||||
|
||||
export const AutomationFlow: React.FC = () => {
|
||||
const [flowId, setFlowId] = React.useState(WORKFLOWS[0]?.id ?? "");
|
||||
const [clock, setClock] = React.useState<Clock>({ run: 0, step: 0, t: 0 });
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const [inView, setInView] = React.useState(false);
|
||||
const [still, setStill] = React.useState(false);
|
||||
|
||||
const tall = useNarrow();
|
||||
const canvas = tall ? TALL : WIDE;
|
||||
|
||||
const flow = WORKFLOWS.find((item) => item.id === flowId) ?? WORKFLOWS[0];
|
||||
|
||||
React.useEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setInView(entry?.isIntersecting ?? false),
|
||||
{ threshold: 0.2 },
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!flow) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
setStill(true);
|
||||
return;
|
||||
}
|
||||
if (!inView) return;
|
||||
|
||||
let raf = 0;
|
||||
let run = 0;
|
||||
let step = 0;
|
||||
let stepStart = performance.now();
|
||||
setClock({ run: 0, step: 0, t: 0 });
|
||||
|
||||
const tick = (now: number) => {
|
||||
const steps = flow.runs[run % flow.runs.length] ?? [];
|
||||
const elapsed = now - stepStart;
|
||||
if (step >= steps.length) {
|
||||
if (elapsed > PAUSE_MS) {
|
||||
run += 1;
|
||||
step = 0;
|
||||
stepStart = now;
|
||||
}
|
||||
} else if (elapsed > TRAVEL_MS + DWELL_MS) {
|
||||
step += 1;
|
||||
stepStart = now;
|
||||
}
|
||||
const t =
|
||||
step >= steps.length ? 1 : Math.min(1, (now - stepStart) / TRAVEL_MS);
|
||||
setClock({ run, step, t });
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [flow, inView]);
|
||||
|
||||
if (!flow) return null;
|
||||
|
||||
const placed = flow.nodes.map((node) => place(node, tall));
|
||||
const nodeOf = (id: string): Placed | undefined =>
|
||||
placed.find((node) => node.id === id);
|
||||
|
||||
const steps = flow.runs[clock.run % flow.runs.length] ?? [];
|
||||
const firstNode = steps[0]?.[0]?.[0];
|
||||
|
||||
// Nodes a packet has already arrived at in this run.
|
||||
const reached = new Set<string>();
|
||||
if (still) {
|
||||
flow.nodes.forEach((node) => reached.add(node.id));
|
||||
} else {
|
||||
if (firstNode) reached.add(firstNode);
|
||||
steps.forEach((edges, index) => {
|
||||
const arrived =
|
||||
index < clock.step || (index === clock.step && clock.t >= 1);
|
||||
if (arrived) edges.forEach(([, to]) => reached.add(to));
|
||||
});
|
||||
}
|
||||
|
||||
const activeEdges = still ? [] : (steps[clock.step] ?? []);
|
||||
const travelled = new Set<string>();
|
||||
steps.forEach((edges, index) => {
|
||||
if (index < clock.step || (index === clock.step && clock.t >= 1)) {
|
||||
edges.forEach(([from, to]) => travelled.add(`${from}>${to}`));
|
||||
}
|
||||
});
|
||||
|
||||
const logLines: string[] = [];
|
||||
if (firstNode) {
|
||||
const first = nodeOf(firstNode);
|
||||
if (first) logLines.push(first.log);
|
||||
}
|
||||
steps.forEach((edges, index) => {
|
||||
const arrived =
|
||||
index < clock.step || (index === clock.step && clock.t >= 1);
|
||||
if (!arrived) return;
|
||||
edges.forEach(([, to]) => {
|
||||
const node = nodeOf(to);
|
||||
if (node && !logLines.includes(node.log)) logLines.push(node.log);
|
||||
});
|
||||
});
|
||||
const finished = clock.step >= steps.length;
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Example workflows"
|
||||
className="flex flex-wrap gap-2"
|
||||
>
|
||||
{WORKFLOWS.map((item) => {
|
||||
const selected = item.id === flow.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
onClick={() => setFlowId(item.id)}
|
||||
className={`rounded-full border px-5 py-2.5 font-mono text-[11px] uppercase tracking-[0.15em] transition-colors duration-200 ${
|
||||
selected
|
||||
? "border-slate-900 bg-slate-900 text-white"
|
||||
: "border-slate-200 bg-white text-slate-600 hover:border-slate-400 hover:text-slate-900"
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="m-0 mt-6 max-w-3xl font-sans text-lg leading-relaxed text-slate-600 md:text-xl">
|
||||
{flow.summary}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 overflow-hidden rounded-3xl bg-slate-900">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4 md:px-7">
|
||||
<span className={`${MONO} text-slate-400`}>{flow.name}</span>
|
||||
<span className={`${MONO} flex items-center gap-2 text-slate-500`}>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${finished ? "bg-emerald-400" : "bg-amber-300 animate-pulse"}`}
|
||||
/>
|
||||
{finished ? "run complete" : "running"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<svg
|
||||
viewBox={`0 0 ${canvas.w} ${canvas.h}`}
|
||||
role="img"
|
||||
aria-label={`${flow.name}: ${flow.summary}`}
|
||||
className="block h-auto w-full"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="flow-grid"
|
||||
width="20"
|
||||
height="20"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<circle cx="1" cy="1" r="1" fill="#1e293b" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width={canvas.w} height={canvas.h} fill="url(#flow-grid)" />
|
||||
|
||||
{flow.edges.map(([from, to]) => {
|
||||
const a = nodeOf(from);
|
||||
const b = nodeOf(to);
|
||||
if (!a || !b) return null;
|
||||
const done = still || travelled.has(`${from}>${to}`);
|
||||
return (
|
||||
<path
|
||||
key={`${from}>${to}`}
|
||||
d={pathOf(a, b, tall)}
|
||||
fill="none"
|
||||
stroke={done ? "#e2e8f0" : "#334155"}
|
||||
strokeWidth={done ? 2 : 1.5}
|
||||
style={{ transition: "stroke 300ms" }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{activeEdges.map(([from, to]) => {
|
||||
const a = nodeOf(from);
|
||||
const b = nodeOf(to);
|
||||
if (!a || !b || clock.t >= 1) return null;
|
||||
const eased = ease(clock.t);
|
||||
return (
|
||||
<g key={`packet-${from}>${to}`}>
|
||||
{[0.16, 0.1, 0.05].map((lag, i) => {
|
||||
const p = pointOn(a, b, Math.max(0, eased - lag), tall);
|
||||
return (
|
||||
<circle
|
||||
key={lag}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={5 - i}
|
||||
fill={MARK}
|
||||
opacity={0.12 + i * 0.12}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<circle
|
||||
cx={pointOn(a, b, eased, tall).x}
|
||||
cy={pointOn(a, b, eased, tall).y}
|
||||
r={7}
|
||||
fill={MARK}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{placed.map((node) => {
|
||||
const style = KIND_STYLE[node.kind];
|
||||
const on = reached.has(node.id);
|
||||
return (
|
||||
<g key={node.id}>
|
||||
<rect
|
||||
x={node.cx - NODE_W / 2}
|
||||
y={node.cy - NODE_H / 2}
|
||||
width={NODE_W}
|
||||
height={NODE_H}
|
||||
rx={14}
|
||||
fill={on ? "#1e293b" : "#0f172a"}
|
||||
stroke={on ? style.stroke : "#334155"}
|
||||
strokeWidth={on ? 1.75 : 1.25}
|
||||
strokeDasharray={style.dash}
|
||||
style={{ transition: "fill 300ms, stroke 300ms" }}
|
||||
/>
|
||||
<text
|
||||
x={node.cx - NODE_W / 2 + 16}
|
||||
y={node.cy - 8}
|
||||
fill={on ? style.tag : "#64748b"}
|
||||
style={{
|
||||
fontFamily:
|
||||
"var(--font-jetbrains-mono), ui-monospace, monospace",
|
||||
fontSize: 10,
|
||||
letterSpacing: "0.14em",
|
||||
textTransform: "uppercase",
|
||||
transition: "fill 300ms",
|
||||
}}
|
||||
>
|
||||
{node.system}
|
||||
</text>
|
||||
<text
|
||||
x={node.cx - NODE_W / 2 + 16}
|
||||
y={node.cy + 14}
|
||||
fill={on ? "#f8fafc" : "#94a3b8"}
|
||||
style={{
|
||||
fontFamily:
|
||||
"var(--font-inter), Inter, system-ui, sans-serif",
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
transition: "fill 300ms",
|
||||
}}
|
||||
>
|
||||
{node.label}
|
||||
</text>
|
||||
{on && (
|
||||
<circle
|
||||
cx={node.cx + NODE_W / 2 - 16}
|
||||
cy={node.cy - NODE_H / 2 + 16}
|
||||
r={4}
|
||||
fill={node.kind === "human" ? "#f8fafc" : "#34d399"}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 border-t border-slate-800 md:grid-cols-12">
|
||||
<div className="border-b border-slate-800 px-5 py-5 md:col-span-7 md:border-b-0 md:border-r md:px-7">
|
||||
<p className={`${MONO} m-0 text-slate-500`}>Log</p>
|
||||
<ol
|
||||
className="m-0 mt-3 min-h-[7.5rem] list-none p-0 font-mono text-[12px] leading-relaxed text-slate-300 md:text-[13px]"
|
||||
aria-live="off"
|
||||
>
|
||||
{logLines.map((line, index) => (
|
||||
<li key={line} className="flex gap-3">
|
||||
<span className="text-slate-600">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
line.startsWith("unclear") ? "text-amber-200" : undefined
|
||||
}
|
||||
>
|
||||
{line}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<ul className="m-0 flex list-none flex-col justify-center gap-2 px-5 py-5 md:col-span-5 md:px-7">
|
||||
{(Object.keys(KIND_LABEL) as NodeKind[])
|
||||
.filter((kind) => flow.nodes.some((node) => node.kind === kind))
|
||||
.map((kind) => (
|
||||
<li
|
||||
key={kind}
|
||||
className="flex items-center gap-3 font-mono text-[11px] text-slate-400"
|
||||
>
|
||||
<svg width="22" height="12" aria-hidden="true">
|
||||
<rect
|
||||
x="1"
|
||||
y="1"
|
||||
width="20"
|
||||
height="10"
|
||||
rx="3"
|
||||
fill="none"
|
||||
stroke={KIND_STYLE[kind].stroke}
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray={KIND_STYLE[kind].dash}
|
||||
/>
|
||||
</svg>
|
||||
{KIND_LABEL[kind]}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div className="rounded-3xl border border-slate-200 p-6 md:p-8">
|
||||
<p className={`${MONO} m-0 text-slate-400`}>By hand</p>
|
||||
<p className="m-0 mt-3 font-sans text-lg leading-relaxed text-slate-500 md:text-xl">
|
||||
{flow.byHand}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-slate-900 p-6 md:p-8">
|
||||
<p className={`${MONO} m-0 text-slate-900`}>Automated</p>
|
||||
<p className="m-0 mt-3 font-sans text-lg leading-relaxed text-slate-900 md:text-xl">
|
||||
{flow.automated}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user