feat: share post to other apps
This commit is contained in:
@@ -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<Post>(`/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(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
backgroundColor: hasImage ? "#1a1410" : "#faf5f0",
|
||||||
|
fontFamily: "Noto Sans, sans-serif",
|
||||||
|
position: "relative",
|
||||||
|
padding: 80,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Background image + dimming overlay */}
|
||||||
|
{hasImage ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={bgImageUrl}
|
||||||
|
width={1200}
|
||||||
|
height={630}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
objectFit: "cover",
|
||||||
|
filter: "brightness(0.45)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Bottom-up gradient anchors the caption against the image */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
background:
|
||||||
|
"linear-gradient(to top, rgba(0,0,0,0.75), rgba(0,0,0,0.1))",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Caption — the centrepiece */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
fontSize: displayCaption.length > 160 ? 36 : 44,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: textPrimary,
|
||||||
|
lineHeight: 1.35,
|
||||||
|
marginTop: 20,
|
||||||
|
maxHeight: 380,
|
||||||
|
overflow: "hidden",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
“{displayCaption}”
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reference (source citation) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: textSecondary,
|
||||||
|
letterSpacing: 4,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
marginTop: 28,
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{reference}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spacer pushes the byline + branding to the bottom */}
|
||||||
|
<div style={{ flex: 1, display: "flex" }} />
|
||||||
|
|
||||||
|
{/* Bottom row: author + branding */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{post.author_avatar_url ? (
|
||||||
|
<img
|
||||||
|
src={post.author_avatar_url}
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
style={{
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 32,
|
||||||
|
objectFit: "cover",
|
||||||
|
border: `3px solid ${textPrimary}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 32,
|
||||||
|
backgroundColor: accent,
|
||||||
|
color: hasImage ? "#1a1410" : "#faf5f0",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{initialsFor(authorName)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 2 }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: textPrimary,
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{authorName}
|
||||||
|
</div>
|
||||||
|
{authorHandle ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
color: textSecondary,
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{authorHandle}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 18, color: accent, fontWeight: 700 }}>
|
||||||
|
prabhupadasays.org
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
/**
|
||||||
|
* OG card for a user profile (`/u/<handle>`).
|
||||||
|
*
|
||||||
|
* 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<PublicProfile>(
|
||||||
|
`/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(
|
||||||
|
(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
backgroundColor: "#faf5f0",
|
||||||
|
fontFamily: "Noto Sans, sans-serif",
|
||||||
|
padding: 80,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Top row: avatar + identity block */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 48,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{profile.avatar_url ? (
|
||||||
|
<img
|
||||||
|
src={profile.avatar_url}
|
||||||
|
width={220}
|
||||||
|
height={220}
|
||||||
|
style={{
|
||||||
|
width: 220,
|
||||||
|
height: 220,
|
||||||
|
borderRadius: 110,
|
||||||
|
objectFit: "cover",
|
||||||
|
border: "6px solid #2d2418",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 220,
|
||||||
|
height: 220,
|
||||||
|
borderRadius: 110,
|
||||||
|
backgroundColor: "#c47a5a",
|
||||||
|
color: "#faf5f0",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: 96,
|
||||||
|
fontWeight: 700,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{initialsFor(profile.name)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 6,
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 60,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#2d2418",
|
||||||
|
lineHeight: 1.05,
|
||||||
|
display: "flex",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 30, color: "#5a4a3a", display: "flex" }}>
|
||||||
|
@{profile.handle}
|
||||||
|
</div>
|
||||||
|
{stats ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
color: "#5a4a3a",
|
||||||
|
marginTop: 12,
|
||||||
|
display: "flex",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stats}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer: branding */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "auto",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 22, color: "#5a4a3a" }}>
|
||||||
|
Read quotes shared on
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 22, color: "#c47a5a", fontWeight: 700 }}>
|
||||||
|
prabhupadasays.org
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
{
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-5
@@ -14,7 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Suspense, useEffect } from "react";
|
import { Suspense, useEffect } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
AtSign,
|
AtSign,
|
||||||
Bookmark,
|
Bookmark,
|
||||||
@@ -102,9 +102,13 @@ export default function MeLayout({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
function ConditionalTabs({ tabs }: { tabs: ProfileTab[] }) {
|
function ConditionalTabs({ tabs }: { tabs: ProfileTab[] }) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
// /u/me?edit=1 swaps the Posts grid for the inline edit form (see
|
const pathname = usePathname();
|
||||||
// app/u/me/page.tsx). The tab strip would just be in the way while the
|
// Hide tabs when the user is in a focused subview:
|
||||||
// user is editing — hide it for that one URL state.
|
// • /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";
|
const editMode = searchParams.get("edit") === "1";
|
||||||
return editMode ? null : <ProfileTabs tabs={tabs} />;
|
const settingsMode = pathname.startsWith("/u/me/settings");
|
||||||
|
return editMode || settingsMode ? null : <ProfileTabs tabs={tabs} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link
|
||||||
|
href="/u/me"
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
Back to profile
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="font-[family-name:var(--font-title)] text-2xl font-semibold text-foreground">
|
||||||
|
Reading settings
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Theme, palette, font, and size — synced across your devices.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ReadingSettings variant="inline" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,18 +8,21 @@
|
|||||||
* The action row hosts:
|
* The action row hosts:
|
||||||
* • "Edit profile" link — only on the owner's own profile (via the
|
* • "Edit profile" link — only on the owner's own profile (via the
|
||||||
* `editHref` prop set by /u/me layout).
|
* `editHref` prop set by /u/me layout).
|
||||||
* • ReadingSettings gear — for every signed-in viewer, regardless of
|
* • Reading-settings gear — for every signed-in viewer. It links to
|
||||||
* whose profile they're on. SiteHeader drops its own mobile gear
|
* /u/me/settings (a focused page that mounts ReadingSettings in
|
||||||
* for auth users (see comment there) so this is the single source
|
* inline mode) rather than opening a popover here. A popover
|
||||||
* of truth for that affordance once you're signed in.
|
* 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
|
* Anonymous visitors don't see this row at all — they read SiteHeader's
|
||||||
* gear instead.
|
* popover gear instead.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
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";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
|
||||||
export function ProfileHeader({
|
export function ProfileHeader({
|
||||||
@@ -77,7 +80,21 @@ export function ProfileHeader({
|
|||||||
Edit profile
|
Edit profile
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
{showReadingSettings ? <ReadingSettings /> : null}
|
{showReadingSettings ? (
|
||||||
|
<Link
|
||||||
|
href="/u/me/settings"
|
||||||
|
aria-label="Reading settings"
|
||||||
|
title="Reading settings"
|
||||||
|
className={cn(
|
||||||
|
"size-9 inline-flex items-center justify-center rounded-lg",
|
||||||
|
"border border-border text-foreground hover:bg-muted",
|
||||||
|
"transition-colors outline-none",
|
||||||
|
"focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Settings className="size-4" />
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -235,11 +235,23 @@ type TriggerRenderer = (state: {
|
|||||||
* the popover off the bottom of the viewport. */
|
* the popover off the bottom of the viewport. */
|
||||||
type Placement = "below-right" | "above-left";
|
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({
|
export function ReadingSettings({
|
||||||
|
variant = "popover",
|
||||||
renderTrigger,
|
renderTrigger,
|
||||||
placement = "below-right",
|
placement = "below-right",
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: {
|
}: {
|
||||||
|
variant?: Variant;
|
||||||
renderTrigger?: TriggerRenderer;
|
renderTrigger?: TriggerRenderer;
|
||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
/** Fires every time the popover opens or closes. Used by the sidebar
|
/** 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 ?? "";
|
visiblePalettes.find((p) => p.id === activePaletteId)?.name ?? "";
|
||||||
|
|
||||||
if (!mounted) {
|
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 (
|
return (
|
||||||
<Button variant="ghost" size="icon-sm" aria-label="Reading settings">
|
<Button variant="ghost" size="icon-sm" aria-label="Reading settings">
|
||||||
<Settings className="size-4" />
|
<Settings className="size-4" />
|
||||||
@@ -463,53 +478,9 @@ export function ReadingSettings({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggle = () => setOpen((v) => !v);
|
const panelInner = (
|
||||||
|
<>
|
||||||
return (
|
{/* Theme selector */}
|
||||||
<div className="relative">
|
|
||||||
{renderTrigger ? (
|
|
||||||
renderTrigger({ open, toggle, buttonRef })
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
ref={buttonRef as React.Ref<HTMLButtonElement>}
|
|
||||||
aria-label="Reading settings"
|
|
||||||
aria-expanded={open}
|
|
||||||
onClick={toggle}
|
|
||||||
className={cn(
|
|
||||||
"size-9 inline-flex items-center justify-center rounded-lg",
|
|
||||||
"text-muted-foreground hover:text-foreground hover:bg-muted",
|
|
||||||
"transition-colors outline-none",
|
|
||||||
"focus-visible:ring-2 focus-visible:ring-ring",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Settings className="size-5 md:size-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<MaybePortal portal={placement === "above-left" && portalPos !== null}>
|
|
||||||
<div
|
|
||||||
ref={panelRef}
|
|
||||||
style={
|
|
||||||
placement === "above-left" && portalPos
|
|
||||||
? {
|
|
||||||
position: "fixed",
|
|
||||||
left: portalPos.left,
|
|
||||||
bottom: portalPos.bottom,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"w-72 rounded-lg border border-border bg-card shadow-lg p-4",
|
|
||||||
"max-h-[80vh] overflow-y-auto",
|
|
||||||
placement === "above-left" && portalPos
|
|
||||||
? "z-[100]"
|
|
||||||
: placement === "above-left"
|
|
||||||
? "absolute bottom-full left-0 mb-2 z-50"
|
|
||||||
: "absolute top-full right-0 mt-2 z-50",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Theme selector */}
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Theme
|
Theme
|
||||||
@@ -635,7 +606,70 @@ export function ReadingSettings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<div className="w-full max-w-xl rounded-lg border border-border bg-card p-4 sm:p-5">
|
||||||
|
{panelInner}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Popover variant (default) ───────────────────────────────────────
|
||||||
|
const toggle = () => setOpen((v) => !v);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{renderTrigger ? (
|
||||||
|
renderTrigger({ open, toggle, buttonRef })
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
ref={buttonRef as React.Ref<HTMLButtonElement>}
|
||||||
|
aria-label="Reading settings"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={toggle}
|
||||||
|
className={cn(
|
||||||
|
"size-9 inline-flex items-center justify-center rounded-lg",
|
||||||
|
"text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||||
|
"transition-colors outline-none",
|
||||||
|
"focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Settings className="size-5 md:size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<MaybePortal portal={placement === "above-left" && portalPos !== null}>
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
style={
|
||||||
|
placement === "above-left" && portalPos
|
||||||
|
? {
|
||||||
|
position: "fixed",
|
||||||
|
left: portalPos.left,
|
||||||
|
bottom: portalPos.bottom,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"w-72 rounded-lg border border-border bg-card shadow-lg p-4",
|
||||||
|
"max-h-[80vh] overflow-y-auto",
|
||||||
|
placement === "above-left" && portalPos
|
||||||
|
? "z-[100]"
|
||||||
|
: placement === "above-left"
|
||||||
|
? "absolute bottom-full left-0 mb-2 z-50"
|
||||||
|
: "absolute top-full right-0 mt-2 z-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{panelInner}
|
||||||
|
</div>
|
||||||
</MaybePortal>
|
</MaybePortal>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,13 +3,19 @@
|
|||||||
/**
|
/**
|
||||||
* PostActionsFooter — the icon-button row at the bottom of a post card.
|
* PostActionsFooter — the icon-button row at the bottom of a post card.
|
||||||
*
|
*
|
||||||
* Left cluster: Like · Comment · Reshare
|
* Left cluster (non-owner): Like · Comment · Reshare
|
||||||
* Right cluster: Save to Collection (folder-plus)
|
* Left cluster (owner): Like · Comment · Send
|
||||||
|
* Right cluster: Save to Collection (folder-plus)
|
||||||
*
|
*
|
||||||
* Like / Comment / Reshare are placeholders for now — they fire a
|
* Reshare and Send are *mutually exclusive* — once the post is on your
|
||||||
* "coming soon" toast on click. Save to Collection mounts the existing
|
* account there's nothing left to reshare onto it, so the Reshare slot
|
||||||
* CollectionsDrawer in `existingPostId` mode so the post is attached
|
* is replaced by Send (external share via SharePostSheet). Ownership is
|
||||||
* directly without creating a duplicate.
|
* 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
|
* Auth gating is handled per-action via `useAuthGate` — anonymous users
|
||||||
* see the in-place auth modal before the underlying action runs.
|
* see the in-place auth modal before the underlying action runs.
|
||||||
@@ -21,29 +27,59 @@ import {
|
|||||||
HiOutlineChatBubbleOvalLeft,
|
HiOutlineChatBubbleOvalLeft,
|
||||||
HiOutlineFolderPlus,
|
HiOutlineFolderPlus,
|
||||||
HiOutlineHeart,
|
HiOutlineHeart,
|
||||||
|
HiOutlinePaperAirplane,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
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 { useAuthGate } from "@/components/auth/auth-gate";
|
||||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||||
import { CollectionsDrawer } from "@/components/selection-toolbar/collections-drawer";
|
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
|
// `hi2` namespace), so they share one design language — uniform stroke
|
||||||
// weight, matching corner radius, matching optical sizing. No more
|
// weight, matching corner radius, matching optical sizing.
|
||||||
// Lucide cross-icon visual drift.
|
|
||||||
const ICON_CLASS = "size-[1.375rem]";
|
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 { requireAuth } = useAuthGate();
|
||||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||||
const [collectionsOpen, setCollectionsOpen] = useState(false);
|
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<boolean>(
|
||||||
|
!!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<void>) {
|
async function withAuth(fn: () => void | Promise<void>) {
|
||||||
if (!(await requireAuth())) return;
|
if (!(await requireAuth())) return;
|
||||||
await fn();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between px-2">
|
<div className="flex items-center justify-between px-2">
|
||||||
@@ -66,15 +102,23 @@ export function PostActionsFooter({ postID }: { postID: string }) {
|
|||||||
}
|
}
|
||||||
icon={<HiOutlineChatBubbleOvalLeft className={ICON_CLASS} />}
|
icon={<HiOutlineChatBubbleOvalLeft className={ICON_CLASS} />}
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
{/* Reshare for posts not yet on your account, Send for posts
|
||||||
label="Reshare"
|
that are — authored OR previously reshared. Mutually
|
||||||
onClick={() =>
|
exclusive: the row stays three icons regardless of viewer
|
||||||
withAuth(() => {
|
state. */}
|
||||||
toast("Resharing is coming soon", { duration: 2000 });
|
{showSend ? (
|
||||||
})
|
<ActionButton
|
||||||
}
|
label="Send"
|
||||||
icon={<HiOutlineArrowPath className={ICON_CLASS} />}
|
onClick={() => setShareOpen(true)}
|
||||||
/>
|
icon={<HiOutlinePaperAirplane className={ICON_CLASS} />}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ActionButton
|
||||||
|
label="Reshare"
|
||||||
|
onClick={() => withAuth(onReshare)}
|
||||||
|
icon={<HiOutlineArrowPath className={ICON_CLASS} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
label="Save to collection"
|
label="Save to collection"
|
||||||
@@ -87,8 +131,17 @@ export function PostActionsFooter({ postID }: { postID: string }) {
|
|||||||
open={collectionsOpen}
|
open={collectionsOpen}
|
||||||
onClose={() => setCollectionsOpen(false)}
|
onClose={() => setCollectionsOpen(false)}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
existingPostId={postID}
|
existingPostId={post.id}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{showSend ? (
|
||||||
|
<SharePostSheet
|
||||||
|
open={shareOpen}
|
||||||
|
onClose={() => setShareOpen(false)}
|
||||||
|
post={post}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export function PostCard({
|
|||||||
{/* Action footer — like / comment / reshare on the left, add to
|
{/* Action footer — like / comment / reshare on the left, add to
|
||||||
collection on the right. */}
|
collection on the right. */}
|
||||||
<div className="mt-3 pt-2 border-t border-border">
|
<div className="mt-3 pt-2 border-t border-border">
|
||||||
<PostActionsFooter postID={post.id} />
|
<PostActionsFooter post={post} />
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export function PostScrollCard({
|
|||||||
{/* Action footer — like / comment / reshare on the left, add to
|
{/* Action footer — like / comment / reshare on the left, add to
|
||||||
collection on the right. */}
|
collection on the right. */}
|
||||||
<div className="pt-1 pb-2 border-t border-border">
|
<div className="pt-1 pb-2 border-t border-border">
|
||||||
<PostActionsFooter postID={post.id} />
|
<PostActionsFooter post={post} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{onUpdated ? (
|
{onUpdated ? (
|
||||||
|
|||||||
@@ -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:
|
||||||
|
*
|
||||||
|
* "<full caption>"
|
||||||
|
*
|
||||||
|
* — <reference, e.g. Bhagavad-gītā 2.13>
|
||||||
|
*
|
||||||
|
* <shortened URL pointing to the POST on your profile feed>
|
||||||
|
*
|
||||||
|
* The link target is `/u/<handle>/posts#post-<id>` — 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/<code> 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<string | null>(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/<handle>/posts#post-<id>`; 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: <FaWhatsapp className="size-6" />,
|
||||||
|
onClick: () =>
|
||||||
|
openIntent(`https://wa.me/?text=${encodeURIComponent(shareText)}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "telegram",
|
||||||
|
label: "Telegram",
|
||||||
|
brandClass: "text-[#229ED9]",
|
||||||
|
icon: <FaTelegram className="size-6" />,
|
||||||
|
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: <FaXTwitter className="size-6" />,
|
||||||
|
onClick: () =>
|
||||||
|
openIntent(
|
||||||
|
`https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}`,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "facebook",
|
||||||
|
label: "Facebook",
|
||||||
|
brandClass: "text-[#1877F2]",
|
||||||
|
icon: <FaFacebook className="size-6" />,
|
||||||
|
// 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: <FaInstagram className="size-6" />,
|
||||||
|
// 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: <FaEnvelope className="size-6" />,
|
||||||
|
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: <Copy className="size-6" />,
|
||||||
|
onClick: () => copyText(shareText),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const direction = isMobile ? "bottom" : "right";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
direction={direction}
|
||||||
|
>
|
||||||
|
<DrawerContent direction={direction} className="bg-card">
|
||||||
|
<DrawerHeader className="text-left border-b border-border pb-4">
|
||||||
|
<DrawerTitle>Share post</DrawerTitle>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||||
|
“{post.caption.slice(0, 140)}
|
||||||
|
{post.caption.length > 140 ? "…" : ""}”
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground/80 mt-1">
|
||||||
|
{reference}
|
||||||
|
</p>
|
||||||
|
</DrawerHeader>
|
||||||
|
|
||||||
|
<div className="p-4 sm:p-6 overflow-y-auto">
|
||||||
|
{shortening ? (
|
||||||
|
<div className="flex items-center justify-center py-10 text-muted-foreground">
|
||||||
|
<Loader2 className="size-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-4 gap-3 sm:grid-cols-5">
|
||||||
|
{targets.map((t) => (
|
||||||
|
<ShareTile key={t.key} target={t} />
|
||||||
|
))}
|
||||||
|
{isMobile && nativeShareSupported ? (
|
||||||
|
<ShareTile
|
||||||
|
target={{
|
||||||
|
key: "native",
|
||||||
|
label: "More",
|
||||||
|
brandClass: "text-foreground",
|
||||||
|
icon: <Share2 className="size-6" />,
|
||||||
|
onClick: shareNative,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={target.onClick}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-1.5 rounded-lg p-2 sm:p-3",
|
||||||
|
"hover:bg-muted transition-colors",
|
||||||
|
"outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center size-12 rounded-full",
|
||||||
|
"bg-muted",
|
||||||
|
target.brandClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{target.icon}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-foreground text-center leading-tight">
|
||||||
|
{target.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -385,3 +385,88 @@ export function ogDefault() {
|
|||||||
quote: SITE_DESCRIPTION,
|
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/<handle>`). */
|
||||||
|
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/<handle>/posts#post-<id>` 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],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
ogForLetter,
|
ogForLetter,
|
||||||
ogForMagazine,
|
ogForMagazine,
|
||||||
ogForOtherDocument,
|
ogForOtherDocument,
|
||||||
|
ogForPost,
|
||||||
|
ogForProfile,
|
||||||
ogForTranscription,
|
ogForTranscription,
|
||||||
ogForVerse,
|
ogForVerse,
|
||||||
} from "@/lib/og";
|
} from "@/lib/og";
|
||||||
@@ -115,6 +117,10 @@ export async function buildShareOgMeta(fullPath: string): Promise<Metadata> {
|
|||||||
return parts.length >= 3
|
return parts.length >= 3
|
||||||
? await ogForOtherDocPath(parts[1], parts[2], sp)
|
? await ogForOtherDocPath(parts[1], parts[2], sp)
|
||||||
: ogDefault();
|
: ogDefault();
|
||||||
|
case "u":
|
||||||
|
// /u/<handle> → profile share
|
||||||
|
// /u/<handle>/posts#post-<id> → post share (post id in u.hash)
|
||||||
|
return await ogForProfilePath(parts, u.hash);
|
||||||
default:
|
default:
|
||||||
return ogWithFallbackQuote("Prabhupāda Says", sp);
|
return ogWithFallbackQuote("Prabhupāda Says", sp);
|
||||||
}
|
}
|
||||||
@@ -365,6 +371,74 @@ async function ogForOtherDocPath(
|
|||||||
// Fallback
|
// 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/<handle>` and `/u/<handle>/posts#post-<id>` 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<Metadata> {
|
||||||
|
if (parts.length < 2) return ogDefault();
|
||||||
|
const handle = parts[1];
|
||||||
|
|
||||||
|
// Post share: /u/<handle>/posts with `#post-<id>` fragment.
|
||||||
|
if (parts.length >= 3 && parts[2] === "posts") {
|
||||||
|
const m = hash.match(/^#post-(.+)$/);
|
||||||
|
if (m) {
|
||||||
|
const postID = m[1];
|
||||||
|
try {
|
||||||
|
const post = await bff<PostForOg>(
|
||||||
|
`/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/<handle>
|
||||||
|
try {
|
||||||
|
const profile = await bff<PublicProfileForOg>(
|
||||||
|
`/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(
|
function ogWithFallbackQuote(
|
||||||
title: string,
|
title: string,
|
||||||
sp: URLSearchParams,
|
sp: URLSearchParams,
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ export type Post = {
|
|||||||
author_name?: string;
|
author_name?: string;
|
||||||
author_handle?: string;
|
author_handle?: string;
|
||||||
author_avatar_url?: string | null;
|
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 = {
|
export type Bookmark = {
|
||||||
@@ -130,6 +135,17 @@ export const posts = {
|
|||||||
mine() {
|
mine() {
|
||||||
return call<Post[]>("/posts/mine");
|
return call<Post[]>("/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<void>(`/posts/${id}/reshare`, { method: "POST" });
|
||||||
|
},
|
||||||
|
/** Remove a previously recorded reshare. Idempotent. */
|
||||||
|
unreshare(id: string) {
|
||||||
|
return call<void>(`/posts/${id}/reshare`, { method: "DELETE" });
|
||||||
|
},
|
||||||
/** R2 presigned PUT for a single post image. Returns the public URL the post should persist. */
|
/** R2 presigned PUT for a single post image. Returns the public URL the post should persist. */
|
||||||
async uploadImage(file: File): Promise<string> {
|
async uploadImage(file: File): Promise<string> {
|
||||||
const { upload_url, public_url } = await call<{
|
const { upload_url, public_url } = await call<{
|
||||||
|
|||||||
Reference in New Issue
Block a user