diff --git a/app/share/[code]/not-found.tsx b/app/share/[code]/not-found.tsx new file mode 100644 index 00000000..7ad174da --- /dev/null +++ b/app/share/[code]/not-found.tsx @@ -0,0 +1,18 @@ +import Link from "next/link"; + +export default function ShareNotFound() { + return ( +
+

Link not found

+

+ This short link may have expired or never existed. +

+ + Return to Prabhupāda Says → + +
+ ); +} diff --git a/app/share/[code]/page.tsx b/app/share/[code]/page.tsx new file mode 100644 index 00000000..f077492e --- /dev/null +++ b/app/share/[code]/page.tsx @@ -0,0 +1,47 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { resolveShortLink } from "@/lib/share"; +import { buildShareOgMeta } from "@/lib/share-og"; +import { ogDefault } from "@/lib/og"; +import { RedirectClient } from "./redirect-client"; + +type PageProps = { params: Promise<{ code: string }> }; + +export async function generateMetadata({ + params, +}: PageProps): Promise { + const { code } = await params; + const fullPath = await resolveShortLink(code); + if (!fullPath) { + return { + title: "Link expired — Prabhupāda Says", + robots: { index: false, follow: false }, + ...ogDefault(), + }; + } + return buildShareOgMeta(fullPath); +} + +export default async function SharePage({ params }: PageProps) { + const { code } = await params; + const fullPath = await resolveShortLink(code); + if (!fullPath) notFound(); + + return ( +
+

+ Redirecting to your highlight… +

+ + +
+ ); +} diff --git a/app/share/[code]/redirect-client.tsx b/app/share/[code]/redirect-client.tsx new file mode 100644 index 00000000..5c799a7a --- /dev/null +++ b/app/share/[code]/redirect-client.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +// Client-side redirect: we deliberately don't use `redirect()` from +// next/navigation on the server, because that returns a 307 before the OG +// `` is ever served. With `generateMetadata` + a 200 body, social-link +// scrapers get the full OG card while real users still get redirected. +export function RedirectClient({ to }: { to: string }) { + const router = useRouter(); + useEffect(() => { + router.replace(to); + }, [router, to]); + return null; +} diff --git a/components/selection-toolbar/more-popover.tsx b/components/selection-toolbar/more-popover.tsx index 3c3fbe8f..d3b91044 100644 --- a/components/selection-toolbar/more-popover.tsx +++ b/components/selection-toolbar/more-popover.tsx @@ -1,9 +1,10 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useCopyFeedback, type Position } from "./shared"; -import { Bookmark, FolderPlus, AtSign } from "lucide-react"; +import { buildShortHighlightUrl } from "@/lib/text-highlight"; +import { Bookmark, FolderPlus, AtSign, Link2, Check } from "lucide-react"; import { Drawer, DrawerClose, @@ -33,6 +34,18 @@ export function MorePopover({ }) { const ref = useRef(null); const { copied, copy } = useCopyFeedback(); + const [generatingLink, setGeneratingLink] = useState(false); + + async function handleCopyLink() { + if (generatingLink) return; + setGeneratingLink(true); + try { + const url = await buildShortHighlightUrl(selectedText); + await copy(url); + } finally { + setGeneratingLink(false); + } + } useEffect(() => { if (!open || isMobile) return; @@ -67,6 +80,17 @@ export function MorePopover({ icon: , onClick: onSaveToCollection, }, + { + key: "copy-link", + label: copied + ? "Link Copied!" + : generatingLink + ? "Generating link…" + : "Copy Link", + description: "Copy a shareable link to this quote", + icon: copied ? : , + onClick: handleCopyLink, + }, { key: "mention", label: "Mention", diff --git a/components/selection-toolbar/share-modal.tsx b/components/selection-toolbar/share-modal.tsx index 43195c56..9a314601 100644 --- a/components/selection-toolbar/share-modal.tsx +++ b/components/selection-toolbar/share-modal.tsx @@ -1,7 +1,8 @@ "use client"; +import { useState } from "react"; import { useCopyFeedback } from "./shared"; -import { buildHighlightUrl } from "@/lib/text-highlight"; +import { buildShortHighlightUrl } from "@/lib/text-highlight"; import { createPortal } from "react-dom"; import { X, MessageSquare, ExternalLink } from "lucide-react"; import { @@ -24,6 +25,18 @@ export function ShareModal({ isMobile: boolean; }) { const { copied, copy } = useCopyFeedback(); + const [generating, setGenerating] = useState(false); + + async function handleCopyShareLink() { + if (generating) return; + setGenerating(true); + try { + const url = await buildShortHighlightUrl(selectedText); + await copy(url); + } finally { + setGenerating(false); + } + } const shareOptions = [ { @@ -41,12 +54,17 @@ export function ShareModal({ label: "Share Link", icon: , description: "Copy a shareable link", - onClick: () => { - copy(buildHighlightUrl(selectedText)); - }, + onClick: handleCopyShareLink, }, ]; + function linkLabel(optKey: string, optLabel: string) { + if (optKey !== "link") return optLabel; + if (copied) return "Link Copied!"; + if (generating) return "Generating link…"; + return optLabel; + } + if (isMobile) { return ( !o && onClose()}> @@ -72,7 +90,7 @@ export function ShareModal({

- {opt.key === "link" && copied ? "Link Copied!" : opt.label} + {linkLabel(opt.key, opt.label)}

{opt.description}

@@ -130,7 +148,7 @@ export function ShareModal({

- {opt.key === "link" && copied ? "Link Copied!" : opt.label} + {linkLabel(opt.key, opt.label)}

{opt.description}

diff --git a/lib/bff.ts b/lib/bff.ts index 71cd5b59..22614d08 100644 --- a/lib/bff.ts +++ b/lib/bff.ts @@ -121,3 +121,41 @@ export async function bffList( const result = await bff(path, options); return result ?? []; } + +/** + * POST JSON to the BFF API. Used for mutations like the URL shortener; does + * not participate in Next.js caching (each POST is a unique side-effect). + * + * Same envelope-unwrapping behavior as bff(): returns the inner `data` + * value, throws BffError on non-2xx. + */ +export async function bffPost( + path: string, + body: Record, +): Promise { + const url = apiUrl(path); + + const res = await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + cache: "no-store", + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new BffError( + res.status, + `BFF ${res.status} POST ${path}: ${text.slice(0, 200)}`, + ); + } + + const json = await res.json(); + if (json && typeof json === "object" && "data" in json) { + return json.data as T; + } + return json as T; +} diff --git a/lib/share-og.ts b/lib/share-og.ts new file mode 100644 index 00000000..69c10a4b --- /dev/null +++ b/lib/share-og.ts @@ -0,0 +1,378 @@ +/** + * OG metadata for the /share/[code] page. + * + * Given a resolved long path (`/books/bg/18/66?hl=…`), this dispatches to the + * correct page-specific OG builder so social-link previews on shortened URLs + * look identical to previews on the canonical destination URL. + * + * The destination page's own `generateMetadata` is not invoked from `/share`, + * so we replicate the equivalent fetch + `extractQuoteFromHl` + `ogFor*` + * pipeline here. On any failure we fall back to a site-level default so the + * link is still scrape-safe. + */ + +import type { Metadata } from "next"; +import { + buildOgMeta, + extractQuoteFromHl, + ogDefault, + ogForBook, + ogForChapter, + ogForEssay, + ogForJournal, + ogForLetter, + ogForMagazine, + ogForOtherDocument, + ogForTranscription, + ogForVerse, +} from "@/lib/og"; +import { BOOK_NAMES, CC_LILAS } from "@/lib/constants"; +import { bff } from "@/lib/bff"; +import { getSingleVerse, getProseChapter } from "@/lib/api"; +import { getLetter } from "@/lib/letters"; +import { getTranscription } from "@/lib/transcriptions"; +import { + getJournalChapterBySlug, + slugToBookCode as journalSlugToBookCode, +} from "@/lib/journals"; +import { + getMagazineIssue, + slugToBookCode as magazineSlugToBookCode, +} from "@/lib/magazines"; +import { + getOtherDocumentChapter, + slugToBookCode as otherDocSlugToBookCode, +} from "@/lib/other-documents"; + +// --------------------------------------------------------------------------- +// Path parsing +// --------------------------------------------------------------------------- + +// Verse-bearing books with a `/{chapter}/{verse}` leaf (no division segment). +const VERSE_BOOKS_NO_DIVISION = new Set([ + "bg", + "sb1962", + "bs", + "mm", + "nbs", +]); + +// Books whose verse routes have an extra division segment (canto for SB, līlā for CC). +const VERSE_BOOKS_WITH_DIVISION = new Set(["sb", "cc"]); + +// Books where the leaf is a single segment (mantra / text slug). +const VERSE_BOOKS_SINGLE_SEGMENT = new Set(["iso", "noi"]); + +function splitPath(pathname: string): string[] { + return pathname + .split("/") + .filter(Boolean) + .map((s) => { + try { + return decodeURIComponent(s); + } catch { + return s; + } + }); +} + +// --------------------------------------------------------------------------- +// Main entrypoint +// --------------------------------------------------------------------------- + +/** + * Build OG metadata for a /share/[code] page given the resolved long path. + * Never throws — any error falls back to the site default. + */ +export async function buildShareOgMeta(fullPath: string): Promise { + try { + const u = new URL("http://x" + fullPath); + const parts = splitPath(u.pathname); + const sp = u.searchParams; + + if (parts.length === 0) return ogDefault(); + + switch (parts[0]) { + case "books": + return await ogForBookPath(parts, sp); + case "letters": + return parts[1] ? await ogForLetterPath(parts[1], sp) : ogDefault(); + case "transcriptions": + return parts[1] + ? await ogForTranscriptionPath(parts[1], sp) + : ogDefault(); + case "essays": + return parts[1] ? await ogForEssayPath(parts[1], sp) : ogDefault(); + case "journals": + return parts.length >= 3 + ? await ogForJournalPath(parts[1], parts[2], sp) + : ogDefault(); + case "magazines": + return parts.length >= 3 + ? await ogForMagazinePath(parts[1], parts[2], sp) + : ogDefault(); + case "other-documents": + return parts.length >= 3 + ? await ogForOtherDocPath(parts[1], parts[2], sp) + : ogDefault(); + default: + return ogWithFallbackQuote("Prabhupāda Says", sp); + } + } catch { + return ogDefault(); + } +} + +// --------------------------------------------------------------------------- +// Per-type dispatchers +// --------------------------------------------------------------------------- + +async function ogForBookPath( + parts: string[], + sp: URLSearchParams, +): Promise { + if (parts.length < 2) return ogDefault(); + const code = parts[1]; + + // Verse with division (SB canto, CC līlā): /books/sb/{canto}/{ch}/{v} + if (VERSE_BOOKS_WITH_DIVISION.has(code) && parts.length >= 5) { + return await ogForVersePath(code, parts[2], parts[3], parts[4], sp); + } + + // Verse without division: /books/{code}/{ch}/{v} + if (VERSE_BOOKS_NO_DIVISION.has(code) && parts.length >= 4) { + return await ogForVersePath(code, null, parts[2], parts[3], sp); + } + + // ISO / NOI: /books/{code}/{slug-or-mantra} + if (VERSE_BOOKS_SINGLE_SEGMENT.has(code) && parts.length >= 3) { + // Treat the leaf as a "verse"-like slug. extractQuoteFromHl falls back to + // prefix-suffix if we can't fetch paragraphs cheaply. + return ogWithFallbackQuote(BOOK_NAMES[code] || code.toUpperCase(), sp); + } + + // KB: /books/kb/{volume}/{slug} + if (code === "kb" && parts.length >= 4) { + const volume = Number.parseInt(parts[2], 10); + if (Number.isFinite(volume)) { + const chapter = await getProseChapter("kb", volume, parts[3]); + const paragraphs = chapter?.paragraphs; + const quote = extractQuoteFromHl(sp, paragraphs); + if (quote) { + return buildOgMeta({ + title: BOOK_NAMES.kb, + detail: chapter?.title, + quote, + }); + } + return ogForChapter("kb", chapter?.title ?? ""); + } + } + + // Generic prose book: /books/{code}/{slug} + if (parts.length >= 3) { + const chapter = await getProseChapter(code, 0, parts[2]); + const paragraphs = chapter?.paragraphs; + const quote = extractQuoteFromHl(sp, paragraphs); + if (quote) { + return buildOgMeta({ + title: BOOK_NAMES[code] || code.toUpperCase(), + detail: chapter?.title, + quote, + }); + } + return ogForChapter(code, chapter?.title ?? ""); + } + + return ogForBook(code); +} + +async function ogForVersePath( + code: string, + division: string | null, + chapter: string, + verse: string, + sp: URLSearchParams, +): Promise { + const chapterNum = Number.parseInt(chapter, 10); + const verseNum = Number.parseInt(verse, 10); + if (!Number.isFinite(chapterNum) || !Number.isFinite(verseNum)) { + return ogForBook(code); + } + + const v = await getSingleVerse(code, division, chapterNum, verseNum); + const paragraphs = v ? [v.translation, ...v.purport] : undefined; + const quote = extractQuoteFromHl(sp, paragraphs); + + if (quote) { + const title = BOOK_NAMES[code] || code.toUpperCase(); + let detail: string; + if (code === "sb" && division) { + detail = `Canto ${division}, Chapter ${chapter}, Verse ${verse}`; + } else if (code === "cc" && division) { + const lila = CC_LILAS[division] || division; + detail = `${lila}, Chapter ${chapter}, Verse ${verse}`; + } else { + detail = `Chapter ${chapter}, Verse ${verse}`; + } + return buildOgMeta({ title, detail, quote }); + } + + return ogForVerse(code, { + division: division ?? undefined, + chapter: chapterNum, + verse: verseNum, + }); +} + +async function ogForLetterPath( + slug: string, + sp: URLSearchParams, +): Promise { + const letter = await getLetter(slug); + const paragraphs = letter?.paragraphs; + const quote = extractQuoteFromHl(sp, paragraphs); + const recipient = letter?.recipientDisplay ?? "(Unknown)"; + const meta = + letter?.date && letter?.location + ? `${letter.date} • ${letter.location}` + : letter?.date || letter?.location || undefined; + + if (quote) { + return buildOgMeta({ + title: `Letter to ${recipient}`, + detail: meta, + quote, + }); + } + return ogForLetter(recipient, meta); +} + +async function ogForTranscriptionPath( + slug: string, + sp: URLSearchParams, +): Promise { + const t = await getTranscription(slug); + const paragraphs = t?.content + ?.filter((b) => b.format === "paragraph" || b.format === "quote") + .map((b) => b.text); + const quote = extractQuoteFromHl(sp, paragraphs); + const name = t?.name ?? "Transcription"; + const meta = + t?.date && t?.place + ? `${t.date} • ${t.place}` + : t?.date || t?.place || undefined; + + if (quote) { + return buildOgMeta({ title: name, detail: meta, quote }); + } + return ogForTranscription(name, meta); +} + +type EssayDoc = { + slug: string; + title: string; + author: string; + year: number | null; + group: string | null; + paragraphs: string[]; +}; + +async function ogForEssayPath( + slug: string, + sp: URLSearchParams, +): Promise { + const doc = await bff(`/essays/${slug}`); + const paragraphs = doc?.paragraphs; + const quote = extractQuoteFromHl(sp, paragraphs); + if (quote) { + return buildOgMeta({ + title: doc?.title ?? "Essay", + detail: "Essay by Śrīla Prabhupāda", + quote, + }); + } + return ogForEssay(doc?.title ?? "Essay"); +} + +async function ogForJournalPath( + bookSlug: string, + chapterSlug: string, + sp: URLSearchParams, +): Promise { + const bookCode = journalSlugToBookCode(bookSlug); + const ch = await getJournalChapterBySlug(bookCode, chapterSlug); + const paragraphs = ch?.paragraphs; + const quote = extractQuoteFromHl(sp, paragraphs); + // The fetched chapter type doesn't carry the parent journal title; use the + // shared BOOK_NAMES map as the canonical display name. + const journalTitle = BOOK_NAMES[bookCode] ?? bookSlug; + if (quote) { + return buildOgMeta({ + title: journalTitle, + detail: ch?.title, + quote, + }); + } + return ogForJournal(journalTitle, ch?.title); +} + +async function ogForMagazinePath( + bookSlug: string, + issueSlug: string, + sp: URLSearchParams, +): Promise { + const bookCode = magazineSlugToBookCode(bookSlug); + const issue = await getMagazineIssue(bookCode, issueSlug); + const paragraphs = issue?.paragraphs; + const quote = extractQuoteFromHl(sp, paragraphs); + const magazineTitle = BOOK_NAMES[bookCode] ?? bookSlug; + if (quote) { + return buildOgMeta({ + title: magazineTitle, + detail: issue?.title, + quote, + }); + } + return ogForMagazine(magazineTitle, issue?.title); +} + +async function ogForOtherDocPath( + bookSlug: string, + chapterPart: string, + sp: URLSearchParams, +): Promise { + const bookCode = otherDocSlugToBookCode(bookSlug); + const chapterNum = Number.parseInt(chapterPart, 10); + const docTitle = BOOK_NAMES[bookCode] ?? bookSlug; + if (!Number.isFinite(chapterNum)) { + return ogForOtherDocument(docTitle); + } + const ch = await getOtherDocumentChapter(bookCode, chapterNum); + const paragraphs = ch?.paragraphs; + const quote = extractQuoteFromHl(sp, paragraphs); + if (quote) { + return buildOgMeta({ + title: docTitle, + detail: ch?.title, + quote, + }); + } + return ogForOtherDocument(docTitle, ch?.title); +} + +// --------------------------------------------------------------------------- +// Fallback +// --------------------------------------------------------------------------- + +function ogWithFallbackQuote( + title: string, + sp: URLSearchParams, +): Metadata { + // No paragraphs available — fall back to prefix … suffix reconstruction. + const quote = extractQuoteFromHl(sp); + if (quote) { + return buildOgMeta({ title, quote }); + } + return ogDefault(); +} diff --git a/lib/share.ts b/lib/share.ts new file mode 100644 index 00000000..c9a359b1 --- /dev/null +++ b/lib/share.ts @@ -0,0 +1,53 @@ +/** + * Short link client — talks to the BFF /share endpoints. + * + * Used by the text-selection toolbar (`buildShortHighlightUrl`) and the + * `/share/[code]` page (`resolveShortLink`). + */ + +import { bff, bffPost } from "@/lib/bff"; + +// Module-scoped cache: a single user shouldn't generate a fresh code every +// time they re-click Share on the same selection. Keyed by the relative path +// (including ?hl=…), this returns the same promise for repeat calls. +const createCache = new Map>(); + +type CreateResponse = { code: string }; +type ResolveResponse = { url: string }; + +/** + * Create a short code for a same-site relative path (`/books/bg/18/66?hl=…`). + * Returns `null` on any failure — caller should fall back to the long URL. + */ +export function createShortLink(longPath: string): Promise { + const cached = createCache.get(longPath); + if (cached) return cached; + + const promise = bffPost("/share", { url: longPath }) + .then((r) => r.code) + .catch(() => null); + + createCache.set(longPath, promise); + // If the request fails, evict so the next click can retry instead of + // returning the cached null forever. + promise.then((code) => { + if (code === null) createCache.delete(longPath); + }); + + return promise; +} + +/** + * Resolve a short code to its full long path. Returns `null` when the code is + * not found, expired, disabled, or the BFF is unreachable. + */ +export async function resolveShortLink(code: string): Promise { + try { + const r = await bff(`/share/${encodeURIComponent(code)}`, { + revalidate: 0, + }); + return r?.url ?? null; + } catch { + return null; + } +} diff --git a/lib/text-highlight.ts b/lib/text-highlight.ts index 21f528b1..c159a602 100644 --- a/lib/text-highlight.ts +++ b/lib/text-highlight.ts @@ -249,6 +249,37 @@ export function buildHighlightUrl(selectedText: string): string { return url.toString(); } +/** + * Build a shortened, shareable URL for the current selection. POSTs the + * `?hl=…` URL to the BFF, which stores the mapping in Postgres (Redis-queued) + * and returns a short code. The destination `/share/` page resolves + * back to the same `?hl=…` URL the existing TextHighlighter already knows + * how to process — no change to highlighting behavior. + * + * Falls back to the long URL on any failure so sharing never silently breaks. + * + * `buildHighlightUrl` (sync) is intentionally left in place so other callers + * (e.g. search-result navigation) keep working unchanged. + */ +export async function buildShortHighlightUrl( + selectedText: string, +): Promise { + const longUrl = buildHighlightUrl(selectedText); + let u: URL; + try { + u = new URL(longUrl); + } catch { + return longUrl; + } + const relativePath = u.pathname + u.search + u.hash; + + const { createShortLink } = await import("@/lib/share"); + const code = await createShortLink(relativePath); + if (!code) return longUrl; + + return new URL(`/share/${code}`, u.origin).toString(); +} + /** * Find and highlight text in the DOM that matches a decoded text fragment. * Returns true if found and highlighted.