This commit is contained in:
+8
-3
@@ -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
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
# production
|
||||
/build
|
||||
|
||||
# runtime data (SQLite counter)
|
||||
/data
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
+6
-3
@@ -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
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"better-sqlite3",
|
||||
"sharp",
|
||||
],
|
||||
"packages": {
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -12,6 +12,19 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
},
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/:path*.pdf",
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Disposition",
|
||||
value: "inline",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -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 |
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -28,3 +28,8 @@ body {
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent-cyan);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
Reference in New Issue
Block a user