diff --git a/app/api/og/post/[id]/route.tsx b/app/api/og/post/[id]/route.tsx new file mode 100644 index 00000000..96d9321e --- /dev/null +++ b/app/api/og/post/[id]/route.tsx @@ -0,0 +1,278 @@ +/** + * OG card for a single post. + * + * Renders a 1200×630 image with: + * + * • The post caption (the quote) as the visual centrepiece, in + * serif-toned weight so it reads as a passage rather than UI chrome. + * • The source reference below — citation for the underlying scripture. + * • Author byline (avatar + name + @handle) bottom-left. + * • The post's first attached image as a dimmed background when + * available — same content the author sees on the card itself. + * + * Falls back to a flat cream background when the post has no images. + * The light/dark text treatment swaps based on which surface is showing. + */ + +import { ImageResponse } from "next/og"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { bff } from "@/lib/bff"; + +export const runtime = "nodejs"; + +type PostImage = { id: string; url: string; position: number }; +type Post = { + id: string; + user_id: string; + caption: string; + source_url: string; + source_title: string; + images: PostImage[]; + author_name?: string; + author_handle?: string; + author_avatar_url?: string | null; +}; + +let notoRegularData: Buffer | null = null; +let notoBoldData: Buffer | null = null; + +async function loadFonts() { + const fontsDir = join(process.cwd(), "public/fonts/Noto_Sans/static"); + if (!notoRegularData) { + notoRegularData = await readFile(join(fontsDir, "NotoSans-Regular.ttf")); + } + if (!notoBoldData) { + notoBoldData = await readFile(join(fontsDir, "NotoSans-Bold.ttf")); + } + return { regular: notoRegularData, bold: notoBoldData }; +} + +function initialsFor(name: string): string { + return ( + name + .split(/\s+/) + .filter(Boolean) + .map((p) => p[0]) + .slice(0, 2) + .join("") + .toUpperCase() || "?" + ); +} + +export async function GET( + _request: Request, + context: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await context.params; + const post = await bff(`/posts/${encodeURIComponent(id)}`, { + revalidate: 60, + }).catch(() => null); + if (!post) { + return new Response("Post not found", { status: 404 }); + } + + const fonts = await loadFonts(); + const caption = post.caption || ""; + const displayCaption = + caption.length > 280 ? caption.slice(0, 277) + "…" : caption; + const reference = post.source_title || "Source"; + const authorName = post.author_name || "Unknown"; + const authorHandle = post.author_handle ? `@${post.author_handle}` : ""; + const bgImageUrl = post.images?.[0]?.url ?? null; + const hasImage = !!bgImageUrl; + + // Colour palette flips for image-backed cards so the caption stays + // legible over the dimmed photo. + const textPrimary = hasImage ? "#ffffff" : "#2d2418"; + const textSecondary = hasImage ? "rgba(255,255,255,0.85)" : "#5a4a3a"; + const accent = hasImage ? "#ffd9a8" : "#c47a5a"; + + return new ImageResponse( + ( +
+ {/* Background image + dimming overlay */} + {hasImage ? ( + <> + + {/* Bottom-up gradient anchors the caption against the image */} +
+ + ) : null} + + {/* Caption — the centrepiece */} +
160 ? 36 : 44, + fontWeight: 700, + color: textPrimary, + lineHeight: 1.35, + marginTop: 20, + maxHeight: 380, + overflow: "hidden", + zIndex: 1, + }} + > + “{displayCaption}” +
+ + {/* Reference (source citation) */} +
+ {reference} +
+ + {/* Spacer pushes the byline + branding to the bottom */} +
+ + {/* Bottom row: author + branding */} +
+
+ {post.author_avatar_url ? ( + + ) : ( +
+ {initialsFor(authorName)} +
+ )} +
+
+ {authorName} +
+ {authorHandle ? ( +
+ {authorHandle} +
+ ) : null} +
+
+
+ prabhupadasays.org +
+
+
+ ), + { + width: 1200, + height: 630, + fonts: [ + { + name: "Noto Sans", + data: fonts.regular, + style: "normal" as const, + weight: 400, + }, + { + name: "Noto Sans", + data: fonts.bold, + style: "normal" as const, + weight: 700, + }, + ], + }, + ); + } catch (e) { + console.error("Post OG image generation failed:", e); + return new Response("Failed to generate OG image", { status: 500 }); + } +} diff --git a/app/api/og/profile/[handle]/route.tsx b/app/api/og/profile/[handle]/route.tsx new file mode 100644 index 00000000..aad3c7a9 --- /dev/null +++ b/app/api/og/profile/[handle]/route.tsx @@ -0,0 +1,224 @@ +/** + * OG card for a user profile (`/u/`). + * + * Renders a 1200×630 image with avatar (or initials fallback), display + * name, @handle, and a stats line (post count + join year). Designed + * to match the chapter-card OG aesthetic so a feed of links from + * different parts of the site looks coherent in WhatsApp / Twitter + * previews. + * + * Data comes from the public BFF profile endpoint — no auth required. + * Failures (missing profile, BFF unreachable) return 404 / 500 instead + * of a generic image so social scrapers know to fall back to a + * higher-level card; the share/[code]'s `ogDefault()` shape handles + * that case. + */ + +import { ImageResponse } from "next/og"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { bff } from "@/lib/bff"; + +export const runtime = "nodejs"; + +type PublicProfile = { + handle: string; + name: string; + avatar_url: string | null; + joined_at: string; + post_count: number; +}; + +let notoRegularData: Buffer | null = null; +let notoBoldData: Buffer | null = null; + +async function loadFonts() { + const fontsDir = join(process.cwd(), "public/fonts/Noto_Sans/static"); + if (!notoRegularData) { + notoRegularData = await readFile(join(fontsDir, "NotoSans-Regular.ttf")); + } + if (!notoBoldData) { + notoBoldData = await readFile(join(fontsDir, "NotoSans-Bold.ttf")); + } + return { regular: notoRegularData, bold: notoBoldData }; +} + +function initialsFor(name: string): string { + return ( + name + .split(/\s+/) + .filter(Boolean) + .map((p) => p[0]) + .slice(0, 2) + .join("") + .toUpperCase() || "?" + ); +} + +function formatJoined(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + return `${d.toLocaleString("en-US", { month: "short" })} ${d.getFullYear()}`; +} + +export async function GET( + _request: Request, + context: { params: Promise<{ handle: string }> }, +) { + try { + const { handle } = await context.params; + const profile = await bff( + `/u/${encodeURIComponent(handle)}`, + { revalidate: 60 }, + ).catch(() => null); + if (!profile) { + return new Response("Profile not found", { status: 404 }); + } + + const fonts = await loadFonts(); + const displayName = + profile.name.length > 40 ? profile.name.slice(0, 37) + "…" : profile.name; + const stats = [ + `${profile.post_count} post${profile.post_count === 1 ? "" : "s"}`, + profile.joined_at ? `Joined ${formatJoined(profile.joined_at)}` : null, + ] + .filter(Boolean) + .join(" · "); + + return new ImageResponse( + ( +
+ {/* Top row: avatar + identity block */} +
+ {profile.avatar_url ? ( + + ) : ( +
+ {initialsFor(profile.name)} +
+ )} +
+
+ {displayName} +
+
+ @{profile.handle} +
+ {stats ? ( +
+ {stats} +
+ ) : null} +
+
+ + {/* Footer: branding */} +
+ + Read quotes shared on + + + prabhupadasays.org + +
+
+ ), + { + width: 1200, + height: 630, + fonts: [ + { + name: "Noto Sans", + data: fonts.regular, + style: "normal" as const, + weight: 400, + }, + { + name: "Noto Sans", + data: fonts.bold, + style: "normal" as const, + weight: 700, + }, + ], + }, + ); + } catch (e) { + console.error("Profile OG image generation failed:", e); + return new Response("Failed to generate OG image", { status: 500 }); + } +} diff --git a/app/u/me/layout.tsx b/app/u/me/layout.tsx index bb67afbc..204c45d2 100644 --- a/app/u/me/layout.tsx +++ b/app/u/me/layout.tsx @@ -14,7 +14,7 @@ */ import { Suspense, useEffect } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { AtSign, Bookmark, @@ -102,9 +102,13 @@ export default function MeLayout({ children }: { children: React.ReactNode }) { function ConditionalTabs({ tabs }: { tabs: ProfileTab[] }) { const searchParams = useSearchParams(); - // /u/me?edit=1 swaps the Posts grid for the inline edit form (see - // app/u/me/page.tsx). The tab strip would just be in the way while the - // user is editing — hide it for that one URL state. + const pathname = usePathname(); + // Hide tabs when the user is in a focused subview: + // • /u/me?edit=1 — the inline profile-edit form + // • /u/me/settings — the reading-prefs page + // In either case the strip is visual noise that distracts from the + // task they navigated in to do. const editMode = searchParams.get("edit") === "1"; - return editMode ? null : ; + const settingsMode = pathname.startsWith("/u/me/settings"); + return editMode || settingsMode ? null : ; } diff --git a/app/u/me/settings/page.tsx b/app/u/me/settings/page.tsx new file mode 100644 index 00000000..b3b83d80 --- /dev/null +++ b/app/u/me/settings/page.tsx @@ -0,0 +1,43 @@ +"use client"; + +/** + * /u/me/settings — dedicated reading-preferences screen for auth users. + * + * Mirrors the Edit Profile pattern: a focused subview of /u/me that + * trades the tab strip for a back link + the settings form. We send + * users here instead of opening the popover because the popover, when + * triggered from ProfileHeader's centred action row, can spill off the + * left edge of narrow viewports. Anonymous users still get the popover + * in SiteHeader — that trigger sits at right-2 so it doesn't overflow. + * + * The /u/me layout's ConditionalTabs detects this path and hides the + * Posts/Collections/Bookmarks/Mentions strip while the user is editing + * settings. + */ + +import Link from "next/link"; +import { ChevronLeft } from "lucide-react"; +import { ReadingSettings } from "@/components/reading-settings"; + +export default function ReadingSettingsPage() { + return ( +
+ + + Back to profile + +
+

+ Reading settings +

+

+ Theme, palette, font, and size — synced across your devices. +

+
+ +
+ ); +} diff --git a/components/profile/profile-header.tsx b/components/profile/profile-header.tsx index 3e60ed66..545bc571 100644 --- a/components/profile/profile-header.tsx +++ b/components/profile/profile-header.tsx @@ -8,18 +8,21 @@ * The action row hosts: * • "Edit profile" link — only on the owner's own profile (via the * `editHref` prop set by /u/me layout). - * • ReadingSettings gear — for every signed-in viewer, regardless of - * whose profile they're on. SiteHeader drops its own mobile gear - * for auth users (see comment there) so this is the single source - * of truth for that affordance once you're signed in. + * • Reading-settings gear — for every signed-in viewer. It links to + * /u/me/settings (a focused page that mounts ReadingSettings in + * inline mode) rather than opening a popover here. A popover + * triggered from this centred row tends to spill off the left edge + * on narrow viewports, so the page-navigation pattern matches Edit + * Profile and dodges the problem entirely. * * Anonymous visitors don't see this row at all — they read SiteHeader's - * gear instead. + * popover gear instead. */ import Image from "next/image"; import Link from "next/link"; -import { ReadingSettings } from "@/components/reading-settings"; +import { Settings } from "lucide-react"; +import { cn } from "@/lib/utils"; import { useAuth } from "@/components/auth/auth-provider"; export function ProfileHeader({ @@ -77,7 +80,21 @@ export function ProfileHeader({ Edit profile ) : null} - {showReadingSettings ? : null} + {showReadingSettings ? ( + + + + ) : null}
) : null} diff --git a/components/reading-settings.tsx b/components/reading-settings.tsx index 36593bf7..96541299 100644 --- a/components/reading-settings.tsx +++ b/components/reading-settings.tsx @@ -235,11 +235,23 @@ type TriggerRenderer = (state: { * the popover off the bottom of the viewport. */ type Placement = "below-right" | "above-left"; +/** How the controls are presented. + * + * - "popover" (default) — the icon trigger + floating panel pattern + * used in SiteHeader/sidebar. Centred triggers on narrow viewports + * can push the panel off-screen, which is why auth users get the + * dedicated settings page instead. + * - "inline" — render the controls flat as part of normal page flow, + * full-width up to a sensible cap. Used by /u/me/settings. */ +type Variant = "popover" | "inline"; + export function ReadingSettings({ + variant = "popover", renderTrigger, placement = "below-right", onOpenChange, }: { + variant?: Variant; renderTrigger?: TriggerRenderer; placement?: Placement; /** Fires every time the popover opens or closes. Used by the sidebar @@ -456,6 +468,9 @@ export function ReadingSettings({ visiblePalettes.find((p) => p.id === activePaletteId)?.name ?? ""; if (!mounted) { + // Inline variant: render nothing until mounted so we don't flash a + // misaligned skeleton on the dedicated settings page. + if (variant === "inline") return null; return ( - )} - - {open && ( - -
- {/* Theme selector */} + const panelInner = ( + <> + {/* Theme selector */}
Theme @@ -635,7 +606,70 @@ export function ReadingSettings({
- + + ); + + // ── Inline variant ────────────────────────────────────────────────── + // Renders the same controls as a flush, full-width card. No popover + // positioning, so it can't drift off-viewport on a narrow screen. Used + // by /u/me/settings. + if (variant === "inline") { + return ( +
+ {panelInner} +
+ ); + } + + // ── Popover variant (default) ─────────────────────────────────────── + const toggle = () => setOpen((v) => !v); + + return ( +
+ {renderTrigger ? ( + renderTrigger({ open, toggle, buttonRef }) + ) : ( + + )} + + {open && ( + +
+ {panelInner} +
)}
diff --git a/components/social/post-actions-footer.tsx b/components/social/post-actions-footer.tsx index f5ddbb55..9d1e9679 100644 --- a/components/social/post-actions-footer.tsx +++ b/components/social/post-actions-footer.tsx @@ -3,13 +3,19 @@ /** * PostActionsFooter — the icon-button row at the bottom of a post card. * - * Left cluster: Like · Comment · Reshare - * Right cluster: Save to Collection (folder-plus) + * Left cluster (non-owner): Like · Comment · Reshare + * Left cluster (owner): Like · Comment · Send + * Right cluster: Save to Collection (folder-plus) * - * Like / Comment / Reshare are placeholders for now — they fire a - * "coming soon" toast on click. Save to Collection mounts the existing - * CollectionsDrawer in `existingPostId` mode so the post is attached - * directly without creating a duplicate. + * Reshare and Send are *mutually exclusive* — once the post is on your + * account there's nothing left to reshare onto it, so the Reshare slot + * is replaced by Send (external share via SharePostSheet). Ownership is + * decided by `user.id === post.user_id`, which the BFF already includes + * on every post fetch — no extra round-trip needed. + * + * Like / Comment / Reshare are placeholders today (toast on click). + * Save to Collection mounts the existing CollectionsDrawer in + * `existingPostId` mode so the post attaches without a duplicate. * * Auth gating is handled per-action via `useAuthGate` — anonymous users * see the in-place auth modal before the underlying action runs. @@ -21,29 +27,59 @@ import { HiOutlineChatBubbleOvalLeft, HiOutlineFolderPlus, HiOutlineHeart, + HiOutlinePaperAirplane, } from "react-icons/hi2"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; +import { posts as postsApi, SocialError, type Post } from "@/lib/social"; +import { useAuth } from "@/components/auth/auth-provider"; import { useAuthGate } from "@/components/auth/auth-gate"; import { useMediaQuery } from "@/components/selection-toolbar/shared"; import { CollectionsDrawer } from "@/components/selection-toolbar/collections-drawer"; +import { SharePostSheet } from "./share-post-sheet"; -// All four icons come from the Heroicons v2 outline family (react-icons +// All icons come from the Heroicons v2 outline family (react-icons // `hi2` namespace), so they share one design language — uniform stroke -// weight, matching corner radius, matching optical sizing. No more -// Lucide cross-icon visual drift. +// weight, matching corner radius, matching optical sizing. const ICON_CLASS = "size-[1.375rem]"; -export function PostActionsFooter({ postID }: { postID: string }) { +export function PostActionsFooter({ post }: { post: Post }) { + const { user } = useAuth(); const { requireAuth } = useAuthGate(); const isMobile = useMediaQuery("(max-width: 767px)"); const [collectionsOpen, setCollectionsOpen] = useState(false); + const [shareOpen, setShareOpen] = useState(false); + // Optimistic local mirror of post.viewer_has_reshared so the swap + // happens the instant the user taps Reshare. Reconciles to the + // server's value via rollback if the API call fails. + const [reshared, setReshared] = useState( + !!post.viewer_has_reshared, + ); + + const isOwner = !!user && user.id === post.user_id; + // Owners and resharers both see Send — the post is "on their account" + // either way, so the action they need is external share, not reshare. + const showSend = isOwner || reshared; async function withAuth(fn: () => void | Promise) { if (!(await requireAuth())) return; await fn(); } + async function onReshare() { + // Optimistic flip first so the UI feels instant; revert on error. + setReshared(true); + try { + await postsApi.reshare(post.id); + toast.success("Reshared to your account", { duration: 2000 }); + } catch (err) { + setReshared(false); + const msg = + err instanceof SocialError ? err.message : "Could not reshare"; + toast.error(msg, { duration: 3000 }); + } + } + return ( <>
@@ -66,15 +102,23 @@ export function PostActionsFooter({ postID }: { postID: string }) { } icon={} /> - - withAuth(() => { - toast("Resharing is coming soon", { duration: 2000 }); - }) - } - icon={} - /> + {/* Reshare for posts not yet on your account, Send for posts + that are — authored OR previously reshared. Mutually + exclusive: the row stays three icons regardless of viewer + state. */} + {showSend ? ( + setShareOpen(true)} + icon={} + /> + ) : ( + withAuth(onReshare)} + icon={} + /> + )}
setCollectionsOpen(false)} isMobile={isMobile} - existingPostId={postID} + existingPostId={post.id} /> + + {showSend ? ( + setShareOpen(false)} + post={post} + isMobile={isMobile} + /> + ) : null} ); } diff --git a/components/social/post-card.tsx b/components/social/post-card.tsx index 3d262460..eee899bd 100644 --- a/components/social/post-card.tsx +++ b/components/social/post-card.tsx @@ -94,7 +94,7 @@ export function PostCard({ {/* Action footer — like / comment / reshare on the left, add to collection on the right. */}
- +
); diff --git a/components/social/post-scroll-card.tsx b/components/social/post-scroll-card.tsx index e0b36533..686147ca 100644 --- a/components/social/post-scroll-card.tsx +++ b/components/social/post-scroll-card.tsx @@ -134,7 +134,7 @@ export function PostScrollCard({ {/* Action footer — like / comment / reshare on the left, add to collection on the right. */}
- +
{onUpdated ? ( diff --git a/components/social/share-post-sheet.tsx b/components/social/share-post-sheet.tsx new file mode 100644 index 00000000..6b390aec --- /dev/null +++ b/components/social/share-post-sheet.tsx @@ -0,0 +1,380 @@ +"use client"; + +/** + * SharePostSheet — share a quote you've posted to external apps. + * + * Modal on desktop (Drawer direction="right"), drawer on mobile + * (direction="bottom"). Same UI primitive CollectionsDrawer uses. + * + * What we share: + * + * "" + * + * — + * + * + * + * The link target is `/u//posts#post-` — the Instagram-style + * scroll view anchored to this specific post — rather than the raw + * source URL. Recipients land on the card (image, your caption, your + * byline) and can click through to the original passage from there. + * This matches how every major social product handles "share a post": + * the share URL is the post, not the underlying resource. + * + * URL is run through createShortLink (same /share/ short-code + * service the selection-toolbar uses) so the payload stays compact and + * the click-through is trackable. The shortener's ValidatePath tolerates + * fragments, and the destination pages' existing hash-anchor effect + * scrolls to the post on mount — no other code needs to know. + * + * Sharing channels: + * + * • WhatsApp / Telegram / X / Facebook — official web share intents. + * We open them in a new window; the user finishes in that app. + * • Instagram — has no web share intent. We copy the payload + toast + * "paste in Instagram" so the user can switch and paste. + * • Email — mailto: with prefilled subject + body. + * • Copy — drops the full payload on the clipboard. + * • More — only when navigator.share is supported AND we're on mobile. + * Tries to attach the first image as a File via navigator.canShare; + * falls back to text-only if R2 CORS blocks the blob fetch. + * + * Images travel only via the native-share path. None of the open web + * share intents accept an image URL directly — they pull OpenGraph + * metadata from the linked page instead, which we don't currently + * generate per-post. That's the "bonus" the brief allows for. + */ + +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { + FaWhatsapp, + FaTelegram, + FaXTwitter, + FaFacebook, + FaInstagram, + FaEnvelope, +} from "react-icons/fa6"; +import { Copy, Loader2, Share2 } from "lucide-react"; +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; +import { cn } from "@/lib/utils"; +import type { Post } from "@/lib/social"; +import { createShortLink } from "@/lib/share"; +import { displaySourceLabel } from "@/lib/source-reference"; +import { useAuth } from "@/components/auth/auth-provider"; + +export function SharePostSheet({ + open, + onClose, + post, + isMobile, +}: { + open: boolean; + onClose: () => void; + post: Post; + isMobile: boolean; +}) { + const reference = displaySourceLabel(post.source_url, post.source_title); + const firstImage = post.images?.[0]?.url ?? null; + const { user } = useAuth(); + // The Send button is only rendered for owners, so user is guaranteed + // non-null here; we still defensively fall back via `?? ""` rather than + // crashing if the auth context blips during a re-render. + const authorHandle = post.author_handle ?? user?.handle ?? ""; + + const [shortUrl, setShortUrl] = useState(null); + const [shortening, setShortening] = useState(false); + // navigator.share only exists on a subset of browsers (mainly mobile + // Safari/Chrome and some desktop Safari builds). Capability-check once + // on mount; combined with `isMobile` to gate the "More" tile so the + // affordance only appears where it can do something useful. + const [nativeShareSupported, setNativeShareSupported] = useState(false); + + useEffect(() => { + setNativeShareSupported( + typeof navigator !== "undefined" && + typeof navigator.share === "function", + ); + }, []); + + // Shorten the post's anchor URL on the author's feed when the sheet + // opens. The path takes the form `/u//posts#post-`; the + // BFF's ValidatePath tolerates fragments, and the destination page + // already has a hash-anchor effect that scrolls to the post once the + // feed loads, so the recipient lands on the right card automatically. + useEffect(() => { + if (!open) return; + if (!authorHandle) return; + let cancelled = false; + setShortening(true); + (async () => { + try { + const origin = window.location.origin; + const longPath = `/u/${encodeURIComponent(authorHandle)}/posts#post-${post.id}`; + const code = await createShortLink(longPath); + if (cancelled) return; + if (!code) { + // Shorten failed — use the long URL so the share payload at + // least carries SOMETHING clickable. + setShortUrl(new URL(longPath, origin).toString()); + return; + } + setShortUrl(new URL(`/share/${code}`, origin).toString()); + } catch { + // Should be unreachable — createShortLink swallows its own + // failures. Fall back to a non-shortened long URL. + if (!cancelled) { + setShortUrl( + new URL( + `/u/${encodeURIComponent(authorHandle)}/posts#post-${post.id}`, + window.location.origin, + ).toString(), + ); + } + } finally { + if (!cancelled) setShortening(false); + } + })(); + return () => { + cancelled = true; + }; + }, [open, authorHandle, post.id]); + + // Canonical share payload — quote, reference, link. Built lazily because + // shortUrl arrives async; while loading we render a spinner instead. + const shareText = useMemo(() => { + if (!shortUrl) return ""; + return `“${post.caption}”\n\n— ${reference}\n\n${shortUrl}`; + }, [post.caption, reference, shortUrl]); + + function openIntent(url: string) { + window.open(url, "_blank", "noopener,noreferrer"); + onClose(); + } + + async function copyText(text: string, successMsg = "Copied") { + try { + await navigator.clipboard.writeText(text); + toast.success(successMsg); + } catch { + toast.error("Could not copy"); + } + onClose(); + } + + async function shareNative() { + if (!nativeShareSupported) return; + const payload: ShareData = { + title: reference, + text: shareText, + }; + // Bonus: attach the first image as a File so the recipient gets + // the visual. Requires (a) R2 to allow CORS GET on the public URL + // and (b) navigator.canShare to accept the File. If either fails + // we silently degrade to text-only — the quote + URL still go + // through, which is the contract. + if (firstImage) { + try { + const res = await fetch(firstImage, { mode: "cors" }); + if (res.ok) { + const blob = await res.blob(); + const ext = (blob.type.split("/")[1] || "jpg").toLowerCase(); + const file = new File([blob], `share.${ext}`, { + type: blob.type || "image/jpeg", + }); + if ( + typeof navigator.canShare === "function" && + navigator.canShare({ files: [file] }) + ) { + payload.files = [file]; + } + } + } catch { + // CORS-blocked or network error — proceed without files. + } + } + try { + await navigator.share(payload); + onClose(); + } catch { + // User cancelled the share sheet; leave the drawer open so they + // can pick another target. + } + } + + // Channel tiles — kept in a single declarative list so the JSX is just + // a map. Each tile's onClick closes the sheet to give the user a clean + // "done" signal. + const targets: ShareTarget[] = [ + { + key: "whatsapp", + label: "WhatsApp", + brandClass: "text-[#25D366]", + icon: , + onClick: () => + openIntent(`https://wa.me/?text=${encodeURIComponent(shareText)}`), + }, + { + key: "telegram", + label: "Telegram", + brandClass: "text-[#229ED9]", + icon: , + onClick: () => { + // Telegram's share URL splits text + url so the link is + // attached as a separate entity (better preview rendering). + const params = new URLSearchParams({ + url: shortUrl ?? "", + text: `“${post.caption}” — ${reference}`, + }); + openIntent(`https://t.me/share/url?${params.toString()}`); + }, + }, + { + key: "x", + label: "X", + brandClass: "text-foreground", + icon: , + onClick: () => + openIntent( + `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}`, + ), + }, + { + key: "facebook", + label: "Facebook", + brandClass: "text-[#1877F2]", + icon: , + // Facebook's web sharer ignores prefilled text for non-FB-app + // shares, so we send only the URL — FB scrapes OG tags from the + // linked page. The user can paste the rest before posting. + onClick: () => + openIntent( + `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shortUrl ?? "")}`, + ), + }, + { + key: "instagram", + label: "Instagram", + brandClass: "text-[#E1306C]", + icon: , + // Instagram has no public web share intent. Copy the payload and + // tell the user where it'll land. + onClick: () => + copyText(shareText, "Copied — open Instagram and paste"), + }, + { + key: "email", + label: "Email", + brandClass: "text-muted-foreground", + icon: , + onClick: () => { + // mailto: drives the user's default mail client. Some clients + // crop very long bodies; the user can edit before sending. + const href = + `mailto:?subject=${encodeURIComponent(reference)}` + + `&body=${encodeURIComponent(shareText)}`; + window.location.href = href; + onClose(); + }, + }, + { + key: "copy", + label: "Copy", + brandClass: "text-muted-foreground", + icon: , + onClick: () => copyText(shareText), + }, + ]; + + const direction = isMobile ? "bottom" : "right"; + + return ( + !o && onClose()} + direction={direction} + > + + + Share post +

+ “{post.caption.slice(0, 140)} + {post.caption.length > 140 ? "…" : ""}” +

+

+ {reference} +

+
+ +
+ {shortening ? ( +
+ +
+ ) : ( +
+ {targets.map((t) => ( + + ))} + {isMobile && nativeShareSupported ? ( + , + onClick: shareNative, + }} + /> + ) : null} +
+ )} +
+
+
+ ); +} + +// ─── Pieces ──────────────────────────────────────────────────────────── + +type ShareTarget = { + key: string; + label: string; + /** Tailwind text color class applied to the icon — usually the brand's + * hex, falling back to text-foreground / text-muted-foreground. */ + brandClass: string; + icon: React.ReactNode; + onClick: () => void; +}; + +function ShareTile({ target }: { target: ShareTarget }) { + return ( + + ); +} diff --git a/lib/og.ts b/lib/og.ts index 25e3a65c..81dcb5bb 100644 --- a/lib/og.ts +++ b/lib/og.ts @@ -385,3 +385,88 @@ export function ogDefault() { quote: SITE_DESCRIPTION, }); } + +// --------------------------------------------------------------------------- +// Social: profile + post OG cards +// --------------------------------------------------------------------------- +// +// Profiles and posts get their own image-rendering routes (see +// app/api/og/profile/[handle] and app/api/og/post/[id]) so the card +// can carry avatars, author bylines, and (for posts) the attached +// image. The helpers below just produce the Metadata object pointing +// at those routes. They live here rather than inline in share-og.ts +// so non-share entrypoints (e.g. a profile/post page that adds its +// own `generateMetadata`) can reuse the same builder. + +/** OG metadata for a public user profile (`/u/`). */ +export function ogForProfile(p: { + handle: string; + name: string; + postCount?: number; +}) { + const imageUrl = new URL( + `/api/og/profile/${encodeURIComponent(p.handle)}`, + SITE_URL, + ).toString(); + const title = `${p.name} (@${p.handle})`; + const stats = + typeof p.postCount === "number" + ? `${p.postCount} post${p.postCount === 1 ? "" : "s"} on Prabhupāda Says` + : "Reading shared on Prabhupāda Says"; + return { + title, + description: stats, + openGraph: { + title, + description: stats, + images: [{ url: imageUrl, width: 1200, height: 630, alt: title }], + siteName: "Prabhupāda Says", + }, + twitter: { + card: "summary_large_image" as const, + title, + description: stats, + images: [imageUrl], + }, + }; +} + +/** OG metadata for a single post (`/u//posts#post-` shares). */ +export function ogForPost(p: { + id: string; + caption: string; + source_title?: string | null; + author_name?: string | null; + author_handle?: string | null; +}) { + const imageUrl = new URL( + `/api/og/post/${encodeURIComponent(p.id)}`, + SITE_URL, + ).toString(); + const byline = p.author_handle + ? `${p.author_name ?? p.author_handle} (@${p.author_handle})` + : p.author_name ?? "Prabhupāda Says"; + const reference = p.source_title?.trim() || "Prabhupāda Says"; + // Description is shown in some unfurlers (Slack, Discord) when the + // image fails to load — keep the caption visible there too. + const truncatedCaption = + p.caption.length > 200 ? p.caption.slice(0, 197) + "…" : p.caption; + const description = `“${truncatedCaption}” — ${reference}`; + const title = `${reference} · shared by ${byline}`; + return { + title, + description, + openGraph: { + title, + description, + images: [{ url: imageUrl, width: 1200, height: 630, alt: reference }], + siteName: "Prabhupāda Says", + }, + twitter: { + card: "summary_large_image" as const, + title, + description, + images: [imageUrl], + }, + }; +} diff --git a/lib/share-og.ts b/lib/share-og.ts index 69c10a4b..d930c2dd 100644 --- a/lib/share-og.ts +++ b/lib/share-og.ts @@ -23,6 +23,8 @@ import { ogForLetter, ogForMagazine, ogForOtherDocument, + ogForPost, + ogForProfile, ogForTranscription, ogForVerse, } from "@/lib/og"; @@ -115,6 +117,10 @@ export async function buildShareOgMeta(fullPath: string): Promise { return parts.length >= 3 ? await ogForOtherDocPath(parts[1], parts[2], sp) : ogDefault(); + case "u": + // /u/ → profile share + // /u//posts#post- → post share (post id in u.hash) + return await ogForProfilePath(parts, u.hash); default: return ogWithFallbackQuote("Prabhupāda Says", sp); } @@ -365,6 +371,74 @@ async function ogForOtherDocPath( // Fallback // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Profile + post share dispatch +// --------------------------------------------------------------------------- + +type PublicProfileForOg = { + handle: string; + name: string; + post_count?: number; +}; + +type PostForOg = { + id: string; + caption: string; + source_title?: string | null; + author_name?: string | null; + author_handle?: string | null; +}; + +/** + * Resolve `/u/` and `/u//posts#post-` share targets + * to the correct OG card. The hash carries the post id when the share + * is a single-post link (built by SharePostSheet); without a hash we + * fall back to a profile card for the handle. + */ +async function ogForProfilePath( + parts: string[], + hash: string, +): Promise { + if (parts.length < 2) return ogDefault(); + const handle = parts[1]; + + // Post share: /u//posts with `#post-` fragment. + if (parts.length >= 3 && parts[2] === "posts") { + const m = hash.match(/^#post-(.+)$/); + if (m) { + const postID = m[1]; + try { + const post = await bff( + `/posts/${encodeURIComponent(postID)}`, + { revalidate: 60 }, + ); + if (post) return ogForPost(post); + } catch { + // fall through to profile-level card + } + } + // No post id → treat as a feed share for the profile. + } + + // Profile share: /u/ + try { + const profile = await bff( + `/u/${encodeURIComponent(handle)}`, + { revalidate: 60 }, + ); + if (profile) { + return ogForProfile({ + handle: profile.handle, + name: profile.name, + postCount: profile.post_count, + }); + } + } catch { + // fall through + } + return ogDefault(); +} + function ogWithFallbackQuote( title: string, sp: URLSearchParams, diff --git a/lib/social.ts b/lib/social.ts index 4d4021c6..96e04a1d 100644 --- a/lib/social.ts +++ b/lib/social.ts @@ -62,6 +62,11 @@ export type Post = { author_name?: string; author_handle?: string; author_avatar_url?: string | null; + /** True when the calling viewer has reshared this post. Populated by + * endpoints that go through OptionalAuth (single post, profile feed, + * collection contents). Always false / absent for anonymous viewers. + * Drives the post-card "Reshare ↔ Send" swap. */ + viewer_has_reshared?: boolean; }; export type Bookmark = { @@ -130,6 +135,17 @@ export const posts = { mine() { return call("/posts/mine"); }, + /** Record a reshare. Idempotent — calling twice is a no-op server + * side. Resharing your own post is rejected with 400 (not currently + * surfaced as a distinct error type because the UI only ever offers + * Reshare on non-owned posts). */ + reshare(id: string) { + return call(`/posts/${id}/reshare`, { method: "POST" }); + }, + /** Remove a previously recorded reshare. Idempotent. */ + unreshare(id: string) { + return call(`/posts/${id}/reshare`, { method: "DELETE" }); + }, /** R2 presigned PUT for a single post image. Returns the public URL the post should persist. */ async uploadImage(file: File): Promise { const { upload_url, public_url } = await call<{