diff --git a/.env.example b/.env.example index b235786..a1cf2c6 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 3d07a4d..0cdb431 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ # production /build +# runtime data (SQLite counter) +/data + # misc .DS_Store *.pem diff --git a/Dockerfile b/Dockerfile index 122253a..763cc91 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/bun.lock b/bun.lock index 1ca2ab6..ad720a5 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ }, }, "trustedDependencies": [ + "better-sqlite3", "sharp", ], "packages": { diff --git a/docker-compose.yml b/docker-compose.yml index e1e4582..27da33e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/next.config.ts b/next.config.ts index 285375b..c00ef7d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -12,6 +12,19 @@ const nextConfig: NextConfig = { }, }, }, + async headers() { + return [ + { + source: "/:path*.pdf", + headers: [ + { + key: "Content-Disposition", + value: "inline", + }, + ], + }, + ]; + }, }; export default nextConfig; diff --git a/package.json b/package.json index 078001d..fd3729e 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "unrs-resolver" ], "trustedDependencies": [ + "better-sqlite3", "sharp", "unrs-resolver" ] diff --git a/public/luisdralves-cv.pdf b/public/luisdralves-cv.pdf new file mode 100644 index 0000000..7e61140 Binary files /dev/null and b/public/luisdralves-cv.pdf differ diff --git a/public/og-image.png b/public/og-image.png new file mode 100644 index 0000000..e1577cd Binary files /dev/null and b/public/og-image.png differ diff --git a/src/app/api/counter/route.ts b/src/app/api/counter/route.ts new file mode 100644 index 0000000..de27522 --- /dev/null +++ b/src/app/api/counter/route.ts @@ -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 }); + } +}; diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..dc74e5b --- /dev/null +++ b/src/app/error.tsx @@ -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 ( +
+

500

+

A wire came loose.

+

+ Something gave out on this page. Try again, it might catch. +

+ +
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 7385d92..e0e12f9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -28,3 +28,8 @@ body { color: var(--foreground); font-family: var(--font-body); } + +:focus-visible { + outline: 2px solid var(--accent-cyan); + outline-offset: 3px; +} diff --git a/src/app/icon.svg b/src/app/icon.svg new file mode 100644 index 0000000..e6591cd --- /dev/null +++ b/src/app/icon.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 25e1987..05855db 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ + + Skip to content + + +