From 72895804ee8e0ffc77695316c6dc266216a96200 Mon Sep 17 00:00:00 2001 From: jnmclarty Date: Sun, 10 May 2026 19:32:22 -0400 Subject: [PATCH 1/2] Get local development working --- Dockerfile.dev | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 Dockerfile.dev diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..495ffab --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,22 @@ +# syntax=docker/dockerfile:1.7 +# Dev image for local docker-compose. Source is bind-mounted at runtime; +# node_modules lives in an anonymous volume so the container's deps win over +# whatever (if anything) is in the host tree. + +FROM node:22.22.2-bookworm-slim + +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN corepack enable && corepack prepare pnpm@9.5.0 --activate + +WORKDIR /app + +COPY package.json pnpm-lock.yaml ./ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile --prefer-offline + +EXPOSE 5050 + +CMD ["pnpm", "run", "dev"] From dd046c6d5f96fc837068e2f72b47be5a4c6f9fcb Mon Sep 17 00:00:00 2001 From: jnmclarty Date: Sun, 10 May 2026 21:29:59 -0400 Subject: [PATCH 2/2] Add memo engagement --- src/app/api/revalidate/route.ts | 32 ++ src/app/memos/[slug]/MemoEngagement.tsx | 514 ++++++++++++++++++++++++ src/app/memos/[slug]/page.tsx | 9 + src/lib/api/client.ts | 49 ++- src/lib/api/memos.ts | 22 + src/lib/api/types.ts | 16 + 6 files changed, 638 insertions(+), 4 deletions(-) create mode 100644 src/app/api/revalidate/route.ts create mode 100644 src/app/memos/[slug]/MemoEngagement.tsx diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts new file mode 100644 index 0000000..018c75f --- /dev/null +++ b/src/app/api/revalidate/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { revalidateTag } from "next/cache"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + const secret = process.env.REVALIDATE_SECRET; + if (!secret) { + return NextResponse.json({ error: "revalidation_disabled" }, { status: 503 }); + } + + const auth = req.headers.get("authorization"); + if (auth !== `Bearer ${secret}`) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + let body: { tag?: string; tags?: string[] }; + try { + body = (await req.json()) as { tag?: string; tags?: string[] }; + } catch { + return NextResponse.json({ error: "invalid_json" }, { status: 400 }); + } + + const tags = body.tags ?? (body.tag ? [body.tag] : []); + if (tags.length === 0) { + return NextResponse.json({ error: "no_tags" }, { status: 400 }); + } + + for (const tag of tags) revalidateTag(tag); + return NextResponse.json({ ok: true, revalidated: tags }); +} diff --git a/src/app/memos/[slug]/MemoEngagement.tsx b/src/app/memos/[slug]/MemoEngagement.tsx new file mode 100644 index 0000000..a6bd81f --- /dev/null +++ b/src/app/memos/[slug]/MemoEngagement.tsx @@ -0,0 +1,514 @@ +"use client"; + +import { useEffect, useRef, useState, type FormEvent } from "react"; +import { Dialog } from "@base-ui/react/dialog"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { API_ORIGIN, API_URL, apiPost, type ApiPostError } from "@/lib/api/client"; + +type Endorser = { name: string; created_at: string }; +type Critique = { id: number; name: string; body: string; created_at: string }; + +interface Props { + memoSlug: string; + endorsementsCount: number; + critiquesCount: number; + recentEndorsers: Endorser[]; + critiques: Critique[]; +} + +interface LinkedinPayload { + sub: string; + name: string; + given_name?: string; + family_name?: string; + email?: string; + email_verified?: boolean; + picture?: string; +} + +type Kind = "endorsement" | "critique"; + +const POSTAL_CODE_REGEX = /^[A-Za-z]\d[A-Za-z] ?\d[A-Za-z]\d$/; + +const TRUSTED_API_ORIGIN = + process.env.NEXT_PUBLIC_YORK_FACTORY_ORIGIN || API_ORIGIN; + +function formatDate(iso: string) { + try { + return new Date(iso).toLocaleDateString("en-CA", { + year: "numeric", + month: "long", + day: "numeric", + timeZone: "UTC", + }); + } catch { + return ""; + } +} + +function splitFirstSentence(text: string): { first: string; rest: string } { + const trimmed = text.trim(); + // Match characters up to and including the first sentence-ending punctuation + // followed by whitespace or end of string. + const match = trimmed.match(/^[\s\S]*?[.!?](?=\s|$)/); + if (!match) return { first: trimmed, rest: "" }; + const first = match[0]; + const rest = trimmed.slice(first.length).trim(); + return { first: first.trim(), rest }; +} + +export function MemoEngagement(props: Props) { + const router = useRouter(); + const [endorsementsCount, setEndorsementsCount] = useState(props.endorsementsCount); + const [critiquesCount, setCritiquesCount] = useState(props.critiquesCount); + const [recentEndorsers, setRecentEndorsers] = useState(props.recentEndorsers); + const [critiques, setCritiques] = useState(props.critiques); + const [pendingCritique, setPendingCritique] = useState(null); + const [openKind, setOpenKind] = useState(null); + + const handleEndorsed = (endorser: Endorser) => { + setEndorsementsCount((c) => c + 1); + setRecentEndorsers((list) => [endorser, ...list].slice(0, 5)); + setOpenKind(null); + toast.success("Thanks for endorsing this memo."); + router.refresh(); + }; + + const handleCritiqued = (critique: Critique) => { + setPendingCritique(critique); + setOpenKind(null); + toast.success("Critique submitted for review."); + router.refresh(); + }; + + const handleDuplicate = (kind: Kind) => { + toast.error( + kind === "endorsement" + ? "You've already endorsed this memo." + : "You've already submitted a critique for this memo.", + ); + setOpenKind(null); + }; + + return ( +
+
+ + + + {endorsementsCount} {endorsementsCount === 1 ? "endorsement" : "endorsements"} + {" · "} + {critiquesCount} {critiquesCount === 1 ? "critique" : "critiques"} + +
+ +
+
+

Recent endorsers

+ {recentEndorsers.length === 0 ? ( +

+ No endorsements yet. Be the first. +

+ ) : ( +
    + {recentEndorsers.map((e, i) => ( +
  • + {e.name} + + {formatDate(e.created_at)} + +
  • + ))} +
+ )} +
+ +
+

Critiques

+ {critiques.length === 0 && !pendingCritique ? ( +

+ No critiques yet. +

+ ) : ( +
    + {pendingCritique && ( + + )} + {critiques.map((c) => ( + + ))} +
+ )} +
+
+ + setOpenKind(null)} + onEndorsed={handleEndorsed} + onCritiqued={handleCritiqued} + onDuplicate={handleDuplicate} + /> +
+ ); +} + +function CritiqueItem({ critique, pending = false }: { critique: Critique; pending?: boolean }) { + const [open, setOpen] = useState(false); + const { first, rest } = splitFirstSentence(critique.body); + const hasMore = rest.length > 0; + + return ( +
  • +
    + {critique.name} + + {pending ? "Pending review" : formatDate(critique.created_at)} + +
    +

    + {first} + {hasMore && ( + <> + {" "} + + + )} +

    + + + + + + + {critique.name} + + + {pending ? "Pending review" : formatDate(critique.created_at)} + +

    {critique.body}

    + + + + + +
    +
    +
    +
  • + ); +} + +interface DialogProps { + kind: Kind | null; + memoSlug: string; + onClose: () => void; + onEndorsed: (endorser: Endorser) => void; + onCritiqued: (critique: Critique) => void; + onDuplicate: (kind: Kind) => void; +} + +function EngagementDialog({ + kind, + memoSlug, + onClose, + onEndorsed, + onCritiqued, + onDuplicate, +}: DialogProps) { + const [phase, setPhase] = useState<"connect" | "verifying" | "ready">("connect"); + const [payload, setPayload] = useState(null); + const [verifiedTicket, setVerifiedTicket] = useState(null); + const [postalCode, setPostalCode] = useState(""); + const [body, setBody] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const popupRef = useRef(null); + const popupTimerRef = useRef | null>(null); + + const open = kind !== null; + + // Reset state every time we open or change kind. + useEffect(() => { + if (!open) return; + setPhase("connect"); + setPayload(null); + setVerifiedTicket(null); + setPostalCode(""); + setBody(""); + setError(null); + setSubmitting(false); + }, [open, kind]); + + // Listen for the popup's postMessage. + useEffect(() => { + if (!open) return; + + const handler = (event: MessageEvent) => { + if (TRUSTED_API_ORIGIN && event.origin !== TRUSTED_API_ORIGIN) return; + const data = event.data as + | { type?: string; verifiedTicket?: string; payload?: LinkedinPayload; error?: string } + | null; + if (!data || data.type !== "linkedin-verified") return; + + if (data.error) { + setError(`LinkedIn verification failed: ${data.error}`); + setPhase("connect"); + return; + } + if (data.verifiedTicket && data.payload) { + setVerifiedTicket(data.verifiedTicket); + setPayload(data.payload); + setPhase("ready"); + } + }; + + window.addEventListener("message", handler); + return () => window.removeEventListener("message", handler); + }, [open]); + + // Clean up popup polling on close. + useEffect(() => { + if (open) return; + if (popupTimerRef.current) { + clearInterval(popupTimerRef.current); + popupTimerRef.current = null; + } + if (popupRef.current && !popupRef.current.closed) { + popupRef.current.close(); + } + popupRef.current = null; + }, [open]); + + const startLinkedin = () => { + if (!kind) return; + setError(null); + setPhase("verifying"); + + const startUrl = `${API_URL.replace(/\/api\/v1\/?$/, "")}/api/v1/auth/linkedin/start?kind=${kind}&memo_slug=${encodeURIComponent(memoSlug)}`; + + const w = 600; + const h = 720; + const left = window.screenX + (window.outerWidth - w) / 2; + const top = window.screenY + (window.outerHeight - h) / 2; + const popup = window.open( + startUrl, + "linkedin-verify", + `width=${w},height=${h},left=${left},top=${top}`, + ); + + if (!popup) { + setError("Popup blocked. Please allow popups for this site and try again."); + setPhase("connect"); + return; + } + popupRef.current = popup; + + popupTimerRef.current = setInterval(() => { + if (popup.closed) { + if (popupTimerRef.current) { + clearInterval(popupTimerRef.current); + popupTimerRef.current = null; + } + // Only revert to connect if we never received the ticket. + setPhase((p) => (p === "verifying" ? "connect" : p)); + } + }, 500); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (!kind || !verifiedTicket || !payload) return; + + setError(null); + + if (!POSTAL_CODE_REGEX.test(postalCode)) { + setError("Please enter a valid Canadian postal code (e.g. A1A 1A1)."); + return; + } + if (kind === "critique" && body.trim().length < 5) { + setError("Please write a critique of at least 5 characters."); + return; + } + + setSubmitting(true); + try { + const path = `/memos/${memoSlug}/${kind === "endorsement" ? "endorsements" : "critiques"}`; + const responseBody: Record = { + verified_ticket: verifiedTicket, + postal_code: postalCode, + }; + if (kind === "critique") responseBody.body = body.trim(); + + const data = await apiPost<{ id: number; name: string; body?: string; created_at: string }>( + path, + responseBody, + ); + + if (kind === "endorsement") { + onEndorsed({ name: data.name, created_at: data.created_at }); + } else { + onCritiqued({ + id: data.id, + name: data.name, + body: data.body ?? body.trim(), + created_at: data.created_at, + }); + } + } catch (err) { + const apiErr = err as ApiPostError; + if (apiErr?.status === 409) { + onDuplicate(kind); + return; + } + const errBody = apiErr?.body as { errors?: string[]; message?: string } | undefined; + const message = + errBody?.errors?.join(", ") || + errBody?.message || + "Something went wrong. Please try again."; + setError(message); + } finally { + setSubmitting(false); + } + }; + + const inputClass = + "border border-charcoal-300 bg-white px-3 py-2.5 type-body placeholder:text-charcoal-400 outline-none focus:border-charcoal-1000 transition-colors w-full"; + + return ( + { if (!isOpen) onClose(); }}> + + + + + {kind === "endorsement" ? "Endorse this memo" : "Critique this memo"} + + + {kind === "endorsement" + ? "Verify your identity through LinkedIn so your endorsement carries weight." + : "Verify your identity through LinkedIn and share your critique. Critiques are reviewed before they appear publicly."} + + + {phase === "connect" && ( +
    + + {error &&

    {error}

    } +
    + )} + + {phase === "verifying" && ( +
    +

    + Waiting for LinkedIn… Complete the sign-in in the popup window. +

    + + {error &&

    {error}

    } +
    + )} + + {phase === "ready" && payload && ( +
    +
    +

    Verified via LinkedIn

    +

    {payload.name}

    + {payload.email && ( +

    + {payload.email} + {payload.email_verified && " (verified)"} +

    + )} +
    + + setPostalCode(e.target.value)} + placeholder="Postal code (e.g. A1A 1A1)" + required + maxLength={7} + className={inputClass} + /> + + {kind === "critique" && ( +