diff --git a/app/api/brand/shape/route.ts b/app/api/brand/shape/route.ts new file mode 100644 index 0000000..d3c65b4 --- /dev/null +++ b/app/api/brand/shape/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server' + +import { tryLoadShape } from '@/lib/brand/shape-svg' +import type { ShapeKind } from '@/lib/brand/types' + +export const runtime = 'nodejs' + +function isShapeKind(value: string | null): value is ShapeKind { + return value === 'state' || value === 'country' +} + +export async function GET(request: NextRequest) { + const kind = request.nextUrl.searchParams.get('kind') + const code = request.nextUrl.searchParams.get('code') + + if (!isShapeKind(kind) || !code) { + return NextResponse.json({ error: 'kind and code are required' }, { status: 400 }) + } + + try { + const shape = tryLoadShape(kind, code) + if (!shape) { + return NextResponse.json({ error: 'Shape not found' }, { status: 404 }) + } + return NextResponse.json(shape, { + headers: { + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }) + } catch (error) { + console.error('Failed to load brand shape', error) + return NextResponse.json({ error: 'Shape not found' }, { status: 404 }) + } +} diff --git a/app/apple-icon.tsx b/app/apple-icon.tsx new file mode 100644 index 0000000..fa65411 --- /dev/null +++ b/app/apple-icon.tsx @@ -0,0 +1,40 @@ +import { ImageResponse } from 'next/og' +import { headers } from 'next/headers' + +import { resolveRequestGeo } from '@/lib/brand/geo' +import { logoDataUri } from '@/lib/brand/logo-markup' +import { loadShape } from '@/lib/brand/shape-svg' +import { BRAND_INK, BRAND_PAPER } from '@/lib/brand/types' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const size = { width: 180, height: 180 } +export const contentType = 'image/png' + +export default function AppleIcon() { + const geo = resolveRequestGeo(headers()) + const shape = loadShape(geo.kind, geo.code) + const src = logoDataUri(shape, BRAND_INK) + + return new ImageResponse( + ( +
+ +
+ ), + { + ...size, + headers: { + 'Cache-Control': 'private, max-age=3600', + }, + } + ) +} diff --git a/app/icon.tsx b/app/icon.tsx new file mode 100644 index 0000000..7b723e7 --- /dev/null +++ b/app/icon.tsx @@ -0,0 +1,40 @@ +import { ImageResponse } from 'next/og' +import { headers } from 'next/headers' + +import { resolveRequestGeo } from '@/lib/brand/geo' +import { logoDataUri } from '@/lib/brand/logo-markup' +import { loadShape } from '@/lib/brand/shape-svg' +import { BRAND_INK, BRAND_PAPER } from '@/lib/brand/types' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const size = { width: 32, height: 32 } +export const contentType = 'image/png' + +export default function Icon() { + const geo = resolveRequestGeo(headers()) + const shape = loadShape(geo.kind, geo.code) + const src = logoDataUri(shape, BRAND_INK) + + return new ImageResponse( + ( +
+ +
+ ), + { + ...size, + headers: { + 'Cache-Control': 'private, max-age=3600', + }, + } + ) +} diff --git a/app/layout.tsx b/app/layout.tsx index 252de2f..a971f92 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import type React from "react" import type { Metadata } from "next" +import { headers } from "next/headers" import { GeistSans } from "geist/font/sans" import { GeistMono } from "geist/font/mono" import { @@ -15,6 +16,9 @@ import { Poppins, Source_Sans_3 as Source_Sans_Pro, } from "next/font/google" +import { GeoProvider } from "@/components/brand/geo-provider" +import { resolveRequestGeo } from "@/lib/brand/geo" +import { loadShape } from "@/lib/brand/shape-svg" import "./globals.css" // Load Geist fonts for UI (already configured font objects) @@ -45,12 +49,15 @@ export default function RootLayout({ }: Readonly<{ children: React.ReactNode }>) { + const geo = resolveRequestGeo(headers()) + const initialShape = loadShape(geo.kind, geo.code) + return ( - {children} + {children} ) diff --git a/app/opengraph-image.tsx b/app/opengraph-image.tsx new file mode 100644 index 0000000..58ea960 --- /dev/null +++ b/app/opengraph-image.tsx @@ -0,0 +1,94 @@ +import { readFile } from 'fs/promises' +import { join } from 'path' + +import { ImageResponse } from 'next/og' + +import { logoDataUri } from '@/lib/brand/logo-markup' +import { loadShape } from '@/lib/brand/shape-svg' +import { BRAND_INK, BRAND_MUTED, BRAND_PAPER, FALLBACK_SHAPE_ID } from '@/lib/brand/types' + +export const runtime = 'nodejs' +export const alt = 'Map Studio — Create data-rich maps without leaving your browser. By DFLabs.' +export const size = { width: 1200, height: 630 } +export const contentType = 'image/png' + +async function loadFont(relativePath: string) { + return readFile(join(process.cwd(), 'node_modules/geist/dist/fonts', relativePath)) +} + +export default async function OpenGraphImage() { + const shape = loadShape(FALLBACK_SHAPE_ID.kind, FALLBACK_SHAPE_ID.code) + const src = logoDataUri(shape, BRAND_INK) + const [geistMonoBold, geistSansRegular, geistSansMedium] = await Promise.all([ + loadFont('geist-mono/GeistMono-Bold.ttf'), + loadFont('geist-sans/Geist-Regular.ttf'), + loadFont('geist-sans/Geist-Medium.ttf'), + ]) + + return new ImageResponse( + ( +
+
+ +
+
+
+ Map Studio +
+
+
Create data-rich maps
+
without leaving your browser.
+
+
+ By DFLabs. +
+
+
+ ), + { + ...size, + fonts: [ + { name: 'Geist Mono', data: geistMonoBold, weight: 700, style: 'normal' }, + { name: 'Geist Sans', data: geistSansRegular, weight: 400, style: 'normal' }, + { name: 'Geist Sans', data: geistSansMedium, weight: 500, style: 'normal' }, + ], + } + ) +} diff --git a/app/twitter-image.tsx b/app/twitter-image.tsx new file mode 100644 index 0000000..7e3f8b3 --- /dev/null +++ b/app/twitter-image.tsx @@ -0,0 +1 @@ +export { default, alt, size, contentType, runtime } from './opengraph-image' diff --git a/components/brand/animated-logo.tsx b/components/brand/animated-logo.tsx new file mode 100644 index 0000000..c02493f --- /dev/null +++ b/components/brand/animated-logo.tsx @@ -0,0 +1,144 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' + +import { useGeoShape } from '@/components/brand/geo-provider' +import { pickRandomShape } from '@/lib/brand/cycle-pool' +import { LogoFrame, LogoShapeGroup } from '@/lib/brand/logo-svg' +import { LOGO_FRAME, LOGO_PAD } from '@/lib/brand/types' +import type { ParsedShape, ShapeId } from '@/lib/brand/types' + +const shapeCache = new Map() + +function cacheKey(id: ShapeId): string { + return `${id.kind}:${id.code}` +} + +async function fetchShape(id: ShapeId): Promise { + const key = cacheKey(id) + const cached = shapeCache.get(key) + if (cached) return cached + + const response = await fetch(`/api/brand/shape?kind=${id.kind}&code=${encodeURIComponent(id.code)}`) + if (!response.ok) { + throw new Error(`Failed to load shape ${key}`) + } + const shape = (await response.json()) as ParsedShape + shapeCache.set(key, shape) + return shape +} + +function usePrefersReducedMotion(): boolean { + const [reduced, setReduced] = useState(false) + + useEffect(() => { + const media = window.matchMedia('(prefers-reduced-motion: reduce)') + setReduced(media.matches) + const onChange = () => setReduced(media.matches) + media.addEventListener('change', onChange) + return () => media.removeEventListener('change', onChange) + }, []) + + return reduced +} + +export function AnimatedLogo({ intervalMs, size = 32 }: { intervalMs: number; size?: number }) { + const initialShape = useGeoShape() + const reducedMotion = usePrefersReducedMotion() + const [front, setFront] = useState(initialShape) + const [back, setBack] = useState(null) + const [frontVisible, setFrontVisible] = useState(true) + const frontRef = useRef(front) + const backRef = useRef(back) + const frontVisibleRef = useRef(frontVisible) + const nextRef = useRef(null) + const inflight = useRef(false) + + frontRef.current = front + backRef.current = back + frontVisibleRef.current = frontVisible + + useEffect(() => { + if (!initialShape) return + shapeCache.set(cacheKey(initialShape), initialShape) + setFront((current) => current ?? initialShape) + }, [initialShape]) + + const prepareNext = useCallback(async (current: ParsedShape) => { + if (nextRef.current) return nextRef.current + const loaded = await fetchShape(pickRandomShape(current)) + nextRef.current = loaded + return loaded + }, []) + + useEffect(() => { + if (reducedMotion || !front) return + void prepareNext(front).catch((error) => console.error(error)) + }, [front, prepareNext, reducedMotion]) + + const cycle = useCallback(async () => { + if (inflight.current) return + const current = frontVisibleRef.current ? frontRef.current : backRef.current + if (!current) return + + inflight.current = true + try { + const nextShape = await prepareNext(current) + nextRef.current = null + if (frontVisibleRef.current) { + setBack(nextShape) + requestAnimationFrame(() => { + requestAnimationFrame(() => setFrontVisible(false)) + }) + } else { + setFront(nextShape) + requestAnimationFrame(() => { + requestAnimationFrame(() => setFrontVisible(true)) + }) + } + void prepareNext(nextShape).catch((error) => console.error(error)) + } catch (error) { + console.error(error) + } finally { + inflight.current = false + } + }, [prepareNext]) + + useEffect(() => { + if (reducedMotion || intervalMs <= 0) return + const timer = window.setInterval(() => { + void cycle() + }, intervalMs) + return () => window.clearInterval(timer) + }, [cycle, intervalMs, reducedMotion]) + + return ( + + ) +} diff --git a/components/brand/brand-lockup.tsx b/components/brand/brand-lockup.tsx new file mode 100644 index 0000000..c8e8e8a --- /dev/null +++ b/components/brand/brand-lockup.tsx @@ -0,0 +1,50 @@ +'use client' + +import Link from 'next/link' +import type { ReactNode } from 'react' + +import { AnimatedLogo } from '@/components/brand/animated-logo' + +const wordmarkClass = + 'font-mono text-[10px] font-bold uppercase tracking-[0.22em] text-foreground' + +export function BrandDivider() { + return ( +