Add footer and SEO
Build and Deploy / build-deploy (push) Successful in 1m1s

This commit is contained in:
2026-06-18 03:03:25 +01:00
parent 2e7d451d39
commit 8c3bbdb3dd
41 changed files with 654 additions and 103 deletions
+8 -3
View File
@@ -1,3 +1,8 @@
IMMICH_API_URL=
IMMICH_API_KEY=
IMMICH_ALBUM_ID=
# Build-time: URL of the rate-my-shots API consumed by the photography section
RATE_MY_SHOTS_URL=
# Host port the app is published on
HOST_PORT=3000
# Host directory bind-mounted to /app/data (where the SQLite DB lives)
DATA_DIR=./data
+3
View File
@@ -20,6 +20,9 @@
# production
/build
# runtime data (SQLite counter)
/data
# misc
.DS_Store
*.pem
+6 -3
View File
@@ -1,9 +1,11 @@
FROM oven/bun:1 AS deps
FROM oven/bun:1-alpine AS deps
WORKDIR /app
RUN apk add --no-cache python3 make g++ nodejs npm
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
RUN npm rebuild better-sqlite3
FROM oven/bun:1 AS builder
FROM oven/bun:1-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
@@ -12,8 +14,9 @@ ENV RATE_MY_SHOTS_URL=$RATE_MY_SHOTS_URL
ENV NEXT_TELEMETRY_DISABLED=1
RUN bun run build
FROM node:24-slim AS runner
FROM oven/bun:1-alpine AS runner
WORKDIR /app
RUN apk add --no-cache nodejs
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
+1
View File
@@ -31,6 +31,7 @@
},
},
"trustedDependencies": [
"better-sqlite3",
"sharp",
],
"packages": {
+1 -1
View File
@@ -10,6 +10,6 @@ services:
ports:
- "${HOST_PORT}:3000"
environment:
DATA_DIR: /app/data
DB_PATH: /app/data/db.sqlite
volumes:
- "${DATA_DIR:-./data}:/app/data"
+13
View File
@@ -12,6 +12,19 @@ const nextConfig: NextConfig = {
},
},
},
async headers() {
return [
{
source: "/:path*.pdf",
headers: [
{
key: "Content-Disposition",
value: "inline",
},
],
},
];
},
};
export default nextConfig;
+1
View File
@@ -38,6 +38,7 @@
"unrs-resolver"
],
"trustedDependencies": [
"better-sqlite3",
"sharp",
"unrs-resolver"
]
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+43
View File
@@ -0,0 +1,43 @@
import { type NextRequest, NextResponse } from "next/server";
import { footer } from "@/content/footer";
import { getCounter, incrementCounter } from "@/lib/db";
import { checkRateLimit, getClientIp } from "@/lib/rate-limit";
const LIMIT = 10;
const WINDOW_MS = 60_000;
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export const GET = () => {
try {
return NextResponse.json({ count: getCounter() });
} catch (err) {
console.error("[counter] GET failed:", err);
return NextResponse.json({ error: footer.errorMessage }, { status: 500 });
}
};
export const POST = (request: NextRequest) => {
const ip = getClientIp(request.headers);
const result = checkRateLimit(`counter:${ip}`, LIMIT, WINDOW_MS);
if (!result.allowed) {
return NextResponse.json(
{ error: footer.rateLimitMessage },
{
status: 429,
headers: {
"Retry-After": Math.ceil(result.retryAfterMs / 1000).toString(),
},
},
);
}
try {
return NextResponse.json({ count: incrementCounter() });
} catch (err) {
console.error("[counter] POST failed:", err);
return NextResponse.json({ error: footer.errorMessage }, { status: 500 });
}
};
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { useEffect } from "react";
type ErrorPageProps = {
error: Error & { digest?: string };
reset: () => void;
};
export default function ErrorPage({ error, reset }: ErrorPageProps) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-6 px-8 py-16 text-center">
<p className="font-mono text-accent-cyan/70 text-xs uppercase tracking-[0.3em]">500</p>
<h1 className="font-bold font-heading text-5xl md:text-7xl">A wire came loose.</h1>
<p className="max-w-md font-body text-foreground/70 text-lg leading-relaxed">
Something gave out on this page. Try again, it might catch.
</p>
<button
type="button"
onClick={() => reset()}
className="mt-4 inline-flex cursor-pointer items-center gap-2 rounded-full border border-foreground/15 bg-foreground/[0.02] px-6 py-3 font-body text-foreground/80 transition-colors hover:border-accent-cyan/40 hover:text-accent-cyan"
>
Try again
</button>
</main>
);
}
+5
View File
@@ -28,3 +28,8 @@ body {
color: var(--foreground);
font-family: var(--font-body);
}
:focus-visible {
outline: 2px solid var(--accent-cyan);
outline-offset: 3px;
}
+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.5 -0.5 7.5 11">
<style>
path {
stroke: #1d1e2c;
fill: none;
stroke-width: 0.7;
stroke-linecap: square;
stroke-linejoin: miter;
}
@media (prefers-color-scheme: dark) {
path { stroke: #f1ecdf; }
}
</style>
<path d="M0 0 L2 0 L2 10 L0 10 Z" />
<path d="M2.5 3 L2.5 10 L6.5 10 L6.5 3 Z M4.5 3 L4.5 8" />
</svg>

After

Width:  |  Height:  |  Size: 419 B

+87 -1
View File
@@ -23,8 +23,53 @@ const courierPrime = Courier_Prime({
display: "swap",
});
const SITE_URL = "https://luisdralves.dev";
const SITE_TITLE = "luisdralves";
const SITE_DESCRIPTION =
"Personal site of Luís Alves. Building systems that connect, things to preserve, new ways to see familiar things. Easier to show than tell.";
export const metadata: Metadata = {
title: "luisdralves",
metadataBase: new URL(SITE_URL),
title: SITE_TITLE,
description: SITE_DESCRIPTION,
openGraph: {
title: SITE_TITLE,
description: SITE_DESCRIPTION,
url: SITE_URL,
siteName: SITE_TITLE,
type: "website",
locale: "en",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: SITE_TITLE,
},
],
},
twitter: {
card: "summary_large_image",
title: SITE_TITLE,
description: SITE_DESCRIPTION,
images: ["/og-image.png"],
},
robots: { index: true, follow: true },
};
const personSchema = {
"@context": "https://schema.org",
"@type": "Person",
name: "Luís Alves",
alternateName: "luisdralves",
url: SITE_URL,
sameAs: [
"https://github.com/luisdralves",
"https://gitea.luisdralves.dev/luis",
"https://linkedin.com/in/luisdralves",
],
jobTitle: "Software Engineer",
description: SITE_DESCRIPTION,
};
export default function RootLayout({
@@ -37,6 +82,47 @@ export default function RootLayout({
<body
className={`${spaceGrotesk.variable} ${outfit.variable} ${courierPrime.variable} font-body antialiased`}
>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-200 focus:rounded focus:bg-accent-cyan focus:px-4 focus:py-2 focus:font-mono focus:text-background focus:text-sm focus:no-underline"
>
Skip to content
</a>
<noscript>
<style>{`
[style*="opacity: 0"], [style*="opacity:0"] { opacity: 1 !important; }
[style*="translate"], [style*="scale("], [style*="rotate("] { transform: none !important; }
[style*="inset("] { clip-path: none !important; }
[style*="blur("] { filter: none !important; }
[style*="stroke-dashoffset"] { stroke-dashoffset: 0 !important; }
[data-scroll-animated] {
opacity: 1 !important;
stroke-dashoffset: 0 !important;
transform: none !important;
clip-path: none !important;
filter: none !important;
}
[data-noscript-hide] { display: none !important; }
[data-projects-stage] { height: auto !important; }
[data-projects-stage] > div {
position: static !important;
height: auto !important;
overflow: visible !important;
}
[data-project-layer] {
position: relative !important;
inset: auto !important;
pointer-events: auto !important;
padding: 4rem 0;
margin-bottom: 4rem;
}
`}</style>
</noscript>
<script
type="application/ld+json"
// biome-ignore lint/security/noDangerouslySetInnerHtml: required for JSON-LD schema markup
dangerouslySetInnerHTML={{ __html: JSON.stringify(personSchema) }}
/>
<MotionProvider>{children}</MotionProvider>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
import Link from "next/link";
import ArrowLeft from "@/components/icons/arrow-left.svg";
export default function NotFound() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-6 px-8 py-16 text-center">
<p className="font-mono text-accent-cyan/70 text-xs uppercase tracking-[0.3em]">404</p>
<h1 className="font-bold font-heading text-5xl md:text-7xl">Wrong corridor.</h1>
<p className="max-w-md font-body text-foreground/70 text-lg leading-relaxed">
Nothing on this wall. The gallery's that way.
</p>
<Link
href="/"
className="mt-4 inline-flex items-center gap-2 rounded-full border border-foreground/15 bg-foreground/[0.02] px-6 py-3 font-body text-foreground/80 transition-colors hover:border-accent-cyan/40 hover:text-accent-cyan"
>
<ArrowLeft className="size-4" />
Back to the entrance
</Link>
</main>
);
}
+4 -19
View File
@@ -1,4 +1,5 @@
import { About } from "@/app/sections/about";
import { Footer } from "@/app/sections/footer";
import { Landing } from "@/app/sections/landing";
import { Photography } from "@/app/sections/photography";
import { Projects } from "@/app/sections/projects";
@@ -9,7 +10,7 @@ export default function Home() {
<>
<ScrollProgressIndicator />
<main>
<main id="main-content">
<Landing />
<About />
@@ -17,25 +18,9 @@ export default function Home() {
<Projects />
<Photography />
<section
id="footer"
className="flex min-h-[50vh] items-center px-8 py-16 md:px-16 lg:px-24"
>
<div className="mx-auto w-full max-w-4xl space-y-8 text-center">
<h2 className="font-bold font-heading text-4xl md:text-6xl">End of Journey</h2>
<p className="font-body text-lg opacity-80 md:text-xl">
The footer section is intentionally short to demonstrate that the scroll indicator
handles varying section heights correctly.
</p>
<div className="flex justify-center gap-6">
<span className="text-accent-cyan">GitHub</span>
<span className="text-accent-violet">LinkedIn</span>
<span className="text-foreground/60">Email</span>
</div>
</div>
</section>
</main>
<Footer />
</>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type { MetadataRoute } from "next";
const SITE_URL = "https://luisdralves.dev";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: `${SITE_URL}/sitemap.xml`,
};
}
@@ -91,5 +91,11 @@ export const HandwrittenStatement = ({
}
});
return <div ref={containerRef} className="flex justify-center text-accent-cyan" />;
return (
<div ref={containerRef} className="flex justify-center text-accent-cyan">
<noscript>
<p className="text-center font-body text-2xl italic">{text}</p>
</noscript>
</div>
);
};
+4 -1
View File
@@ -10,7 +10,10 @@ export const About = () => {
const sequence = generateKeySequence(prefix, suffixes);
return (
<section id="about" className="px-8 py-16 md:px-16 lg:px-24">
<section id="about" aria-labelledby="about-heading" className="px-8 py-16 md:px-16 lg:px-24">
<h2 id="about-heading" className="sr-only">
About
</h2>
<Threshold>
<div className="mx-auto max-w-4xl space-y-16">
<div className="space-y-6">
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { animate, useMotionValue, useMotionValueEvent, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { footer } from "@/content/footer";
type CounterProps = {
initialCount: number;
};
const BURST_WINDOW_MS = 16000;
const BURST_THRESHOLD = 3;
export const Counter = ({ initialCount }: CounterProps) => {
const motionCount = useMotionValue(initialCount);
const [display, setDisplay] = useState(initialCount);
const [message, setMessage] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const shouldReduce = useReducedMotion();
const messageTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clickTimestampsRef = useRef<number[]>([]);
useMotionValueEvent(motionCount, "change", (v) => {
setDisplay(Math.round(v));
});
useEffect(
() => () => {
if (messageTimerRef.current) clearTimeout(messageTimerRef.current);
},
[],
);
const showMessage = (text: string) => {
if (messageTimerRef.current) clearTimeout(messageTimerRef.current);
setMessage(text);
messageTimerRef.current = setTimeout(() => setMessage(null), 3000);
};
const handleClick = async () => {
if (busy) return;
const now = Date.now();
const recent = clickTimestampsRef.current.filter((t) => now - t < BURST_WINDOW_MS);
recent.push(now);
clickTimestampsRef.current = recent;
setBusy(true);
try {
const res = await fetch("/api/counter", { method: "POST" });
const data = (await res.json()) as { count?: number; error?: string };
if (res.ok && typeof data.count === "number") {
if (shouldReduce) {
motionCount.set(data.count);
} else {
animate(motionCount, data.count, { duration: 0.4, ease: "easeOut" });
}
if (recent.length >= BURST_THRESHOLD) {
showMessage(footer.excessClickMessage);
}
} else {
showMessage(data.error ?? footer.errorMessage);
}
} catch {
showMessage(footer.errorMessage);
} finally {
setBusy(false);
}
};
return (
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex items-center gap-3">
<span className="font-body text-foreground/70">{footer.totalVisitsLabel}</span>
<span className="font-mono text-3xl text-foreground tabular-nums">
{display.toLocaleString()}
</span>
<button
type="button"
onClick={handleClick}
disabled={busy}
aria-label="Increment visitor counter"
className="inline-flex size-8 cursor-pointer items-center justify-center rounded-full border border-foreground/20 bg-foreground/[0.02] font-mono text-foreground transition-colors hover:border-accent-cyan/60 hover:text-accent-cyan disabled:cursor-wait"
>
+
</button>
</div>
<p className="max-w-sm font-body text-foreground/60 text-sm">{footer.counterPrompt}</p>
<p aria-live="polite" className="h-5 font-body text-accent-cyan text-sm">
{message}
</p>
</div>
);
};
+32
View File
@@ -0,0 +1,32 @@
import { GeometricElement } from "@/app/sections/landing/geometric-element";
import { footer } from "@/content/footer";
import { getCounter } from "@/lib/db";
import { Counter } from "./counter";
import { SocialLinks } from "./social-links";
export const dynamic = "force-dynamic";
const safeGetCounter = (): number => {
try {
return getCounter();
} catch {
return 0;
}
};
export const Footer = () => {
const initialCount = safeGetCounter();
return (
<footer
id="footer"
className="relative flex flex-col items-center gap-16 px-8 pt-24 pb-12 md:px-16 lg:px-24"
>
<Counter initialCount={initialCount} />
<SocialLinks links={footer.socialLinks} />
<div className="pointer-events-none w-full opacity-30">
<GeometricElement wrapperHeight="80px" canvasHeight="35vh" />
</div>
</footer>
);
};
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { m } from "motion/react";
import { useMagneticSpringHover } from "@/hooks/use-magnetic-spring-hover";
import { resolveHost } from "@/lib/host-icons";
type Link = {
platform: string;
url: string;
};
type SocialLinksProps = {
links: readonly Link[];
};
const SocialLink = ({ link }: { link: Link }) => {
const hover = useMagneticSpringHover<HTMLAnchorElement>({
magnetStrength: 0.22,
scaleAmount: 1.05,
shadowElevation: 0,
});
const { Icon } = resolveHost(link.url);
return (
<m.a
ref={hover.ref}
style={{ ...hover.style, boxShadow: "none" }}
{...hover.handlers}
href={link.url}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-2 rounded-full border border-foreground/15 bg-foreground/[0.02] px-5 py-2.5 font-body text-foreground/80 text-sm transition-colors hover:border-accent-cyan/40 hover:text-accent-cyan"
>
<Icon className="size-4" />
<span>{link.platform}</span>
</m.a>
);
};
export const SocialLinks = ({ links }: SocialLinksProps) => {
return (
<div className="flex flex-wrap items-center justify-center gap-4">
{links.map((link) => (
<SocialLink key={link.url} link={link} />
))}
</div>
);
};
@@ -1,9 +1,10 @@
import {
BLUE,
CYAN,
DRIFT_AMOUNT,
DRIFT_RATIO,
EDGE_APPEAR_SPEED,
EDGE_DISAPPEAR_SPEED,
EDGE_FADE_RATIO,
NEIGHBORS,
PROXIMITY_THRESHOLD,
REPEL_STRENGTH,
@@ -22,20 +23,23 @@ export const updatePoints = (
deltaTime: number,
cursor: Cursor,
rect: DOMRect,
scaleX: number,
scaleY: number,
isTouchDevice: boolean,
) => {
const minDim = Math.min(rect.width, rect.height);
const driftAmount = DRIFT_RATIO * minDim;
for (const point of points) {
const driftX = Math.sin(elapsed * point.driftSpeedX + point.driftPhaseX) * DRIFT_AMOUNT;
const driftY = Math.sin(elapsed * point.driftSpeedY + point.driftPhaseY) * DRIFT_AMOUNT;
const baseX = point.nx * rect.width;
const baseY = point.ny * rect.height;
const driftX = Math.sin(elapsed * point.driftSpeedX + point.driftPhaseX) * driftAmount;
const driftY = Math.sin(elapsed * point.driftSpeedY + point.driftPhaseY) * driftAmount;
point.targetOffsetX = 0;
point.targetOffsetY = 0;
if (!isTouchDevice && cursor.x !== 0) {
const pointScreenX = rect.left + (point.baseX + driftX) * scaleX;
const pointScreenY = rect.top + (point.baseY + driftY) * scaleY;
const pointScreenX = rect.left + baseX + driftX;
const pointScreenY = rect.top + baseY + driftY;
const dx = cursor.x - pointScreenX;
const dy = cursor.y - pointScreenY;
const distance = Math.sqrt(dx * dx + dy * dy);
@@ -57,8 +61,8 @@ export const updatePoints = (
point.currentOffsetX += point.velocityX * deltaTime;
point.currentOffsetY += point.velocityY * deltaTime;
point.x = point.baseX + driftX + point.currentOffsetX;
point.y = point.baseY + driftY + point.currentOffsetY;
point.x = baseX + driftX + point.currentOffsetX;
point.y = baseY + driftY + point.currentOffsetY;
}
};
@@ -89,7 +93,6 @@ export const updateEdges = (
currentEdges: Set<string>,
deltaTime: number,
) => {
// Mark edges that should disappear
for (const [key, edge] of edges) {
if (!currentEdges.has(key) && edge.phase !== EdgePhase.Disappearing) {
edge.phase = EdgePhase.Disappearing;
@@ -97,7 +100,6 @@ export const updateEdges = (
}
}
// Add new edges or resurrect disappearing ones
for (const key of currentEdges) {
const existing = edges.get(key);
if (!existing) {
@@ -109,7 +111,6 @@ export const updateEdges = (
}
}
// Animate edges and remove fully disappeared ones
const toRemove: string[] = [];
for (const [key, edge] of edges) {
if (edge.phase === EdgePhase.Appearing) {
@@ -155,36 +156,33 @@ export const drawEdges = (
ctx: CanvasRenderingContext2D,
edges: Map<string, EdgeState>,
points: Point[],
scaleX: number,
scaleY: number,
minDim: number,
) => {
ctx.lineWidth = 1;
const maxDist = EDGE_FADE_RATIO * minDim;
for (const [, edge] of edges) {
const p1 = points[edge.id1];
const p2 = points[edge.id2];
const x1 = p1.x * scaleX;
const y1 = p1.y * scaleY;
const x2 = p2.x * scaleX;
const y2 = p2.y * scaleY;
const x1 = p1.x;
const y1 = p1.y;
const x2 = p2.x;
const y2 = p2.y;
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
const dist = Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2);
const maxDist = 200;
const baseOpacity = Math.max(0, 1 - dist / maxDist) * 0.6;
const color = getEdgeColor(edge.id1, edge.id2);
if (edge.phase === EdgePhase.Appearing) {
const t = edge.progress;
// First half: draw from p1 towards center
const seg1End = Math.min(t * 2, 1);
drawLine(ctx, x1, y1, x1 + (cx - x1) * seg1End, y1 + (cy - y1) * seg1End, color, baseOpacity);
// Second half: draw from p2 towards center
if (t > 0.5) {
const seg2End = (t - 0.5) * 2;
drawLine(
@@ -204,7 +202,6 @@ export const drawEdges = (
const dirY = len > 0 ? (y2 - y1) / len : 0;
if (t < 0.5) {
// First half: gap grows from center
const gapSize = t * 2;
const halfLen = Math.sqrt((cx - x1) ** 2 + (cy - y1) ** 2);
const gapHalf = halfLen * gapSize;
@@ -212,7 +209,6 @@ export const drawEdges = (
drawLine(ctx, x1, y1, cx - dirX * gapHalf, cy - dirY * gapHalf, color, baseOpacity);
drawLine(ctx, cx + dirX * gapHalf, cy + dirY * gapHalf, x2, y2, color, baseOpacity);
} else {
// Second half: segments shrink and fade
const shrink = (t - 0.5) * 2;
const fadeOpacity = baseOpacity * (1 - shrink);
@@ -236,25 +232,17 @@ export const drawEdges = (
);
}
} else {
// Visible - draw full line
drawLine(ctx, x1, y1, x2, y2, color, baseOpacity);
}
}
};
export const drawPoints = (
ctx: CanvasRenderingContext2D,
points: Point[],
scaleX: number,
scaleY: number,
) => {
export const drawPoints = (ctx: CanvasRenderingContext2D, points: Point[]) => {
for (const point of points) {
const x = point.x * scaleX;
const y = point.y * scaleY;
const color = point.id % 2 === 0 ? CYAN : VIOLET;
ctx.fillStyle = `rgb(${color.r}, ${color.g}, ${color.b})`;
ctx.beginPath();
ctx.arc(x, y, 2, 0, Math.PI * 2);
ctx.arc(point.x, point.y, 2, 0, Math.PI * 2);
ctx.fill();
}
};
@@ -1,14 +1,19 @@
import type { Color } from "./types";
export const DRIFT_AMOUNT = 25;
/** Drift radius as a fraction of the canvas's smaller dimension. */
export const DRIFT_RATIO = 0.05;
export const NEIGHBORS = 3;
/** Cursor reach, in screen pixels. */
export const PROXIMITY_THRESHOLD = 250;
export const REPEL_STRENGTH = 80;
/** Maximum cursor-repulsion offset, in canvas pixels. */
export const REPEL_STRENGTH = 60;
export const SPRING_STIFFNESS = 180;
export const SPRING_DAMPING = 12;
export const SPRING_MASS = 1;
export const EDGE_APPEAR_SPEED = 4;
export const EDGE_DISAPPEAR_SPEED = 5;
/** Edge opacity fade distance, as a fraction of the canvas's smaller dimension. */
export const EDGE_FADE_RATIO = 0.4;
export const CYAN: Color = { r: 68, g: 211, b: 220 };
export const VIOLET: Color = { r: 145, g: 82, b: 211 };
export const BLUE: Color = { r: 107, g: 147, b: 216 };
@@ -61,26 +61,16 @@ export const DynamicCanvas = () => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
const scaleX = rect.width / 1000;
const scaleY = rect.height / 500;
const minDim = Math.min(rect.width, rect.height);
updatePoints(
points,
elapsed,
deltaTime,
cursorRef.current,
rect,
scaleX,
scaleY,
isTouchDevice,
);
updatePoints(points, elapsed, deltaTime, cursorRef.current, rect, isTouchDevice);
const currentEdges = findCurrentEdges(points);
updateEdges(edges, currentEdges, deltaTime);
ctx.clearRect(0, 0, rect.width, rect.height);
drawEdges(ctx, edges, points, scaleX, scaleY);
drawPoints(ctx, points, scaleX, scaleY);
drawEdges(ctx, edges, points, minDim);
drawPoints(ctx, points);
animationId = requestAnimationFrame(animate);
};
@@ -19,6 +19,11 @@ export const GeometricElement = ({ wrapperHeight = "0px", canvasHeight = "100vh"
style={{ height: canvasHeight }}
>
{shouldReduceMotion ? <StaticFallback /> : <DynamicCanvas />}
<noscript>
<div className="absolute inset-0">
<StaticFallback />
</div>
</noscript>
</div>
</div>
);
@@ -5,25 +5,27 @@ const seededRandom = (seed: number) => {
return x - Math.floor(x);
};
/** Inset from each edge in normalized space — keeps drifting points off the rim. */
const EDGE_PADDING = 0.05;
export const generatePoints = (): Point[] => {
const points: Point[] = [];
const numPoints = 40;
for (let i = 0; i < numPoints; i++) {
const rand = (offset: number) => seededRandom(i * 100 + offset);
const baseX = 50 + rand(1) * 900;
const baseY = 30 + rand(2) * 440;
const span = 1 - 2 * EDGE_PADDING;
points.push({
id: i,
baseX,
baseY,
nx: EDGE_PADDING + rand(1) * span,
ny: EDGE_PADDING + rand(2) * span,
driftPhaseX: rand(3) * Math.PI * 2,
driftPhaseY: rand(4) * Math.PI * 2,
driftSpeedX: 0.00015 + rand(5) * 0.00025,
driftSpeedY: 0.00015 + rand(6) * 0.00025,
x: baseX,
y: baseY,
x: 0,
y: 0,
velocityX: 0,
velocityY: 0,
targetOffsetX: 0,
@@ -13,7 +13,7 @@ export const StaticFallback = () => {
.filter((p) => p.id !== point.id)
.map((other) => ({
other,
dist: Math.sqrt((point.baseX - other.baseX) ** 2 + (point.baseY - other.baseY) ** 2),
dist: Math.sqrt((point.nx - other.nx) ** 2 + (point.ny - other.ny) ** 2),
}))
.sort((a, b) => a.dist - b.dist);
@@ -27,7 +27,7 @@ export const StaticFallback = () => {
return (
<svg
viewBox="0 0 1000 500"
viewBox="0 0 100 100"
className="h-full w-full"
preserveAspectRatio="xMidYMid meet"
aria-hidden="true"
@@ -35,21 +35,22 @@ export const StaticFallback = () => {
{edges.map(({ p1, p2 }) => (
<line
key={`${p1.id}-${p2.id}`}
x1={p1.baseX}
y1={p1.baseY}
x2={p2.baseX}
y2={p2.baseY}
x1={p1.nx * 100}
y1={p1.ny * 100}
x2={p2.nx * 100}
y2={p2.ny * 100}
stroke={(p1.id + p2.id) % 2 === 0 ? "var(--accent-cyan)" : "var(--accent-violet)"}
strokeWidth={1}
vectorEffect="non-scaling-stroke"
opacity={0.4}
/>
))}
{points.map((point) => (
<circle
key={point.id}
cx={point.baseX}
cy={point.baseY}
r={2}
cx={point.nx * 100}
cy={point.ny * 100}
r={0.4}
fill={point.id % 2 === 0 ? "var(--accent-cyan)" : "var(--accent-violet)"}
/>
))}
@@ -1,13 +1,16 @@
export type Point = {
id: number;
baseX: number;
baseY: number;
/** Normalized base position [0, 1] — multiplied by canvas dimensions at frame time. */
nx: number;
ny: number;
driftPhaseX: number;
driftPhaseY: number;
driftSpeedX: number;
driftSpeedY: number;
/** Current position in canvas-relative pixels. */
x: number;
y: number;
/** Physics state, all in canvas pixels / pixels-per-second. */
velocityX: number;
velocityY: number;
targetOffsetX: number;
+5 -5
View File
@@ -34,7 +34,7 @@ type Props = {
};
export const Logotype = ({ paths }: Props) => {
const ref = useRef<HTMLDivElement>(null);
const ref = useRef<HTMLHeadingElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
@@ -42,10 +42,10 @@ export const Logotype = ({ paths }: Props) => {
});
return (
<div ref={ref} className="flex min-h-screen w-full items-center justify-center">
<h1 ref={ref} className="m-0 flex min-h-screen w-full items-center justify-center">
<span className="sr-only">luisdralves</span>
<m.svg
role="img"
aria-label="luisdralves"
aria-hidden="true"
viewBox="-0.5 -0.5 49 11"
className="w-[90vw] max-w-6xl text-foreground"
fill="none"
@@ -58,6 +58,6 @@ export const Logotype = ({ paths }: Props) => {
<LogotypePath key={key} d={d} scrollProgress={scrollYProgress} />
))}
</m.svg>
</div>
</h1>
);
};
+3
View File
@@ -18,6 +18,9 @@ export const Photography = () => {
</div>
<div className="mx-auto mt-16 max-w-480">
<PhotographyClient apiUrl={apiUrl} />
<noscript>
<p className="text-center font-body text-foreground/60">This one really needs JS.</p>
</noscript>
</div>
</section>
);
+4 -1
View File
@@ -4,7 +4,10 @@ import { ProjectsStage } from "./projects-stage";
export const Projects = () => {
return (
<section id="projects" className="relative">
<section id="projects" aria-labelledby="projects-heading" className="relative">
<h2 id="projects-heading" className="sr-only">
Projects
</h2>
<ProjectsStage projects={projects} />
<Outro profileUrls={[githubProfileUrl, giteaProfileUrl]} />
</section>
@@ -52,6 +52,7 @@ export const ProjectLayer = ({
return (
<m.div
style={layerStyle}
data-project-layer
className="absolute inset-0 flex items-center desktop:px-16 px-8 lg:px-24"
>
<div className="mx-auto grid w-full max-w-7xl desktop:grid-cols-2 grid-cols-1 items-center desktop:gap-12 gap-10 lg:gap-20">
@@ -71,6 +72,7 @@ export const ProjectLayer = ({
localProgress={localProgress}
priority={index === 0}
sideSign={visualSideSign}
active={active}
/>
</div>
</div>
+18 -2
View File
@@ -67,9 +67,10 @@ type VisualShowcaseProps = {
localProgress: MotionValue<number>;
priority?: boolean;
sideSign: -1 | 1;
active: boolean;
};
const renderMedia = (item: ProjectMedia, priority: boolean) => {
const renderMedia = (item: ProjectMedia, priority: boolean, active: boolean) => {
switch (item.type) {
case "image":
return (
@@ -96,6 +97,20 @@ const renderMedia = (item: ProjectMedia, priority: boolean) => {
/>
);
case "iframe":
if (!active) {
return (
<noscript>
<a
href={item.src}
target="_blank"
rel="noreferrer noopener"
className="flex size-full items-center justify-center bg-foreground/[0.04] font-mono text-foreground/60 text-sm"
>
Open live demo
</a>
</noscript>
);
}
if (item.simulatedWidth) {
return (
<ScaledIframe
@@ -131,6 +146,7 @@ export const VisualShowcase = ({
localProgress,
priority = false,
sideSign,
active,
}: VisualShowcaseProps) => {
const bodyProgress = useTransform(localProgress, [VISUAL_BODY.start, VISUAL_BODY.end], [0, 1], {
clamp: true,
@@ -188,7 +204,7 @@ export const VisualShowcase = ({
transition={{ duration: 0.55, ease: [0.22, 0.61, 0.36, 1] }}
className="absolute inset-0"
>
{renderMedia(item, priority)}
{renderMedia(item, priority, active)}
</m.div>
</AnimatePresence>
<div
+14
View File
@@ -0,0 +1,14 @@
import type { MetadataRoute } from "next";
const SITE_URL = "https://luisdralves.dev";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: SITE_URL,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 1,
},
];
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M19 12H5" />
<path d="M11 6 5 12l6 6" />
</svg>

After

Width:  |  Height:  |  Size: 242 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />
<path d="M14 3v5h5" />
<path d="M9 10h2" />
<path d="M9 13h6" />
<path d="M9 16h6" />
</svg>

After

Width:  |  Height:  |  Size: 356 B

+8
View File
@@ -3,7 +3,9 @@ export type FooterContent = {
platform: string;
url: string;
}[];
totalVisitsLabel: string;
counterPrompt: string;
excessClickMessage: string;
rateLimitMessage: string;
errorMessage: string;
};
@@ -22,8 +24,14 @@ export const footer = {
platform: "LinkedIn",
url: "https://linkedin.com/in/luisdralves",
},
{
platform: "CV",
url: "/luisdralves-cv.pdf",
},
],
totalVisitsLabel: "Total visits:",
counterPrompt: "Please kindly increase the counter before you leave.",
excessClickMessage: "Please don't increase it too much.",
rateLimitMessage: "Slow down there, speedster.",
errorMessage: "Counter is taking a nap.",
} as const satisfies FooterContent;
+37
View File
@@ -0,0 +1,37 @@
import { mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import Database from "better-sqlite3";
const DB_PATH = process.env.DB_PATH ?? resolve(process.cwd(), "data", "db.sqlite");
let cached: Database.Database | null = null;
const getDb = (): Database.Database => {
if (cached) return cached;
mkdirSync(dirname(DB_PATH), { recursive: true });
const db = new Database(DB_PATH);
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS counter (
id INTEGER PRIMARY KEY CHECK (id = 1),
value INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO counter (id, value) VALUES (1, 0);
`);
cached = db;
return db;
};
export const getCounter = (): number => {
const row = getDb().prepare("SELECT value FROM counter WHERE id = 1").get() as
| { value: number }
| undefined;
return row?.value ?? 0;
};
export const incrementCounter = (): number => {
const row = getDb()
.prepare("UPDATE counter SET value = value + 1 WHERE id = 1 RETURNING value")
.get() as { value: number };
return row.value;
};
+5
View File
@@ -1,4 +1,5 @@
import type { FC, SVGProps } from "react";
import DocumentIcon from "@/components/icons/document.svg";
import GiteaIcon from "@/components/icons/gitea.svg";
import GitHubIcon from "@/components/icons/github.svg";
import SourceIcon from "@/components/icons/source.svg";
@@ -20,6 +21,10 @@ export type HostInfo = {
};
export const resolveHost = (url: string): HostInfo => {
if (url.toLowerCase().endsWith(".pdf")) {
return { name: "Document", Icon: DocumentIcon };
}
let hostname: string;
try {
hostname = new URL(url).hostname.toLowerCase();
+49
View File
@@ -0,0 +1,49 @@
/**
* In-memory sliding-window rate limiter, keyed by IP.
* Sufficient for a single-instance Next.js server. If you scale horizontally,
* swap this out for a shared store (Redis, Upstash, etc.).
*/
const buckets = new Map<string, number[]>();
export type RateLimitResult = {
allowed: boolean;
remaining: number;
retryAfterMs: number;
};
export const checkRateLimit = (key: string, limit: number, windowMs: number): RateLimitResult => {
const now = Date.now();
const windowStart = now - windowMs;
const existing = buckets.get(key) ?? [];
const recent = existing.filter((t) => t > windowStart);
if (recent.length >= limit) {
const oldest = recent[0];
return {
allowed: false,
remaining: 0,
retryAfterMs: Math.max(0, oldest + windowMs - now),
};
}
recent.push(now);
buckets.set(key, recent);
return {
allowed: true,
remaining: limit - recent.length,
retryAfterMs: 0,
};
};
/** Best-effort IP extraction from common proxy headers, falling back to a sentinel. */
export const getClientIp = (headers: Headers): string => {
const forwarded = headers.get("x-forwarded-for");
if (forwarded) {
const first = forwarded.split(",")[0]?.trim();
if (first) return first;
}
const real = headers.get("x-real-ip");
if (real) return real.trim();
return "unknown";
};