feat: add url shortner
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function ShareNotFound() {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto flex min-h-[60vh] max-w-xl flex-col items-center justify-center px-4 py-16 text-center">
|
||||||
|
<h1 className="text-2xl font-semibold">Link not found</h1>
|
||||||
|
<p className="text-muted-foreground mt-3 text-sm">
|
||||||
|
This short link may have expired or never existed.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-primary mt-6 text-sm underline underline-offset-4"
|
||||||
|
>
|
||||||
|
Return to Prabhupāda Says →
|
||||||
|
</Link>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Metadata> {
|
||||||
|
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 (
|
||||||
|
<main className="mx-auto flex min-h-[60vh] max-w-xl flex-col items-center justify-center px-4 py-16 text-center">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Redirecting to your highlight…
|
||||||
|
</p>
|
||||||
|
<noscript>
|
||||||
|
<p className="mt-4 text-sm">
|
||||||
|
JavaScript is disabled.{" "}
|
||||||
|
<a className="underline" href={fullPath}>
|
||||||
|
Continue to the page
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</noscript>
|
||||||
|
<RedirectClient to={fullPath} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
// `<head>` 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;
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useCopyFeedback, type Position } from "./shared";
|
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 {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerClose,
|
DrawerClose,
|
||||||
@@ -33,6 +34,18 @@ export function MorePopover({
|
|||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const { copied, copy } = useCopyFeedback();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open || isMobile) return;
|
if (!open || isMobile) return;
|
||||||
@@ -67,6 +80,17 @@ export function MorePopover({
|
|||||||
icon: <FolderPlus className="size-5" />,
|
icon: <FolderPlus className="size-5" />,
|
||||||
onClick: onSaveToCollection,
|
onClick: onSaveToCollection,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "copy-link",
|
||||||
|
label: copied
|
||||||
|
? "Link Copied!"
|
||||||
|
: generatingLink
|
||||||
|
? "Generating link…"
|
||||||
|
: "Copy Link",
|
||||||
|
description: "Copy a shareable link to this quote",
|
||||||
|
icon: copied ? <Check className="size-5" /> : <Link2 className="size-5" />,
|
||||||
|
onClick: handleCopyLink,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "mention",
|
key: "mention",
|
||||||
label: "Mention",
|
label: "Mention",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { useCopyFeedback } from "./shared";
|
import { useCopyFeedback } from "./shared";
|
||||||
import { buildHighlightUrl } from "@/lib/text-highlight";
|
import { buildShortHighlightUrl } from "@/lib/text-highlight";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { X, MessageSquare, ExternalLink } from "lucide-react";
|
import { X, MessageSquare, ExternalLink } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +25,18 @@ export function ShareModal({
|
|||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { copied, copy } = useCopyFeedback();
|
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 = [
|
const shareOptions = [
|
||||||
{
|
{
|
||||||
@@ -41,12 +54,17 @@ export function ShareModal({
|
|||||||
label: "Share Link",
|
label: "Share Link",
|
||||||
icon: <ExternalLink className="size-4" />,
|
icon: <ExternalLink className="size-4" />,
|
||||||
description: "Copy a shareable link",
|
description: "Copy a shareable link",
|
||||||
onClick: () => {
|
onClick: handleCopyShareLink,
|
||||||
copy(buildHighlightUrl(selectedText));
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<Drawer open={open} onOpenChange={(o) => !o && onClose()}>
|
<Drawer open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
@@ -72,7 +90,7 @@ export function ShareModal({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-white/90">
|
<p className="text-sm font-medium text-white/90">
|
||||||
{opt.key === "link" && copied ? "Link Copied!" : opt.label}
|
{linkLabel(opt.key, opt.label)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-white/50">{opt.description}</p>
|
<p className="text-xs text-white/50">{opt.description}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,7 +148,7 @@ export function ShareModal({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-white/90">
|
<p className="text-sm font-medium text-white/90">
|
||||||
{opt.key === "link" && copied ? "Link Copied!" : opt.label}
|
{linkLabel(opt.key, opt.label)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-white/50">{opt.description}</p>
|
<p className="text-xs text-white/50">{opt.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+38
@@ -121,3 +121,41 @@ export async function bffList<T>(
|
|||||||
const result = await bff<T[]>(path, options);
|
const result = await bff<T[]>(path, options);
|
||||||
return result ?? [];
|
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<T>(
|
||||||
|
path: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<T> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+378
@@ -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<Metadata> {
|
||||||
|
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<Metadata> {
|
||||||
|
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<Metadata> {
|
||||||
|
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<Metadata> {
|
||||||
|
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<Metadata> {
|
||||||
|
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<Metadata> {
|
||||||
|
const doc = await bff<EssayDoc>(`/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<Metadata> {
|
||||||
|
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<Metadata> {
|
||||||
|
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<Metadata> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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<string, Promise<string | null>>();
|
||||||
|
|
||||||
|
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<string | null> {
|
||||||
|
const cached = createCache.get(longPath);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const promise = bffPost<CreateResponse>("/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<string | null> {
|
||||||
|
try {
|
||||||
|
const r = await bff<ResolveResponse>(`/share/${encodeURIComponent(code)}`, {
|
||||||
|
revalidate: 0,
|
||||||
|
});
|
||||||
|
return r?.url ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -249,6 +249,37 @@ export function buildHighlightUrl(selectedText: string): string {
|
|||||||
return url.toString();
|
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/<code>` 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<string> {
|
||||||
|
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.
|
* Find and highlight text in the DOM that matches a decoded text fragment.
|
||||||
* Returns true if found and highlighted.
|
* Returns true if found and highlighted.
|
||||||
|
|||||||
Reference in New Issue
Block a user