Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions src/app/story/[storylineId]/og/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { ImageResponse } from "next/og";
import { type Address } from "viem";
import { createServerClient, type Storyline } from "../../../../../lib/supabase";
import { getTokenPrice } from "../../../../../lib/price";
import { IS_TESTNET } from "../../../../../lib/contracts/constants";
import { truncateAddress } from "../../../../../lib/utils";

export const runtime = "edge";

export async function GET(
_request: Request,
{ params }: { params: Promise<{ storylineId: string }> },
) {
const { storylineId } = await params;
const id = Number(storylineId);

if (isNaN(id) || id <= 0) {
return new Response("Invalid storyline ID", { status: 400 });
}

const supabase = createServerClient();
if (!supabase) {
return new Response("Database unavailable", { status: 503 });
}

const { data: storyline } = await supabase
.from("storylines")
.select("*")
.eq("storyline_id", id)
.eq("hidden", false)
.single();

if (!storyline) {
return new Response("Storyline not found", { status: 404 });
}

const sl = storyline as Storyline;

const priceInfo = sl.token_address
? await getTokenPrice(sl.token_address as Address)
: null;

const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT";
const priceDisplay = priceInfo
? `${priceInfo.pricePerToken} ${reserveLabel}`
: null;

return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: "60px",
backgroundColor: "#0a0a0a",
color: "#e0e0e0",
fontFamily: "monospace",
}}
>
{/* Top: branding */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "12px",
fontSize: "24px",
color: "#00ff88",
}}
>
PlotLink
</div>

{/* Center: title */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: "16px",
}}
>
<div
style={{
fontSize: "48px",
fontWeight: 700,
color: "#00ff88",
overflow: "hidden",
}}
>
{sl.title.length > 80 ? `${sl.title.slice(0, 77)}...` : sl.title}
</div>
</div>

{/* Bottom: metadata */}
<div
style={{
display: "flex",
gap: "32px",
fontSize: "22px",
color: "#737373",
}}
>
<span>by {truncateAddress(sl.writer_address)}</span>
<span>
{sl.plot_count} {sl.plot_count === 1 ? "plot" : "plots"}
</span>
{priceDisplay && <span>Price: {priceDisplay}</span>}
</div>
</div>
),
{
width: 1200,
height: 800,
},
);
}
68 changes: 68 additions & 0 deletions src/app/story/[storylineId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { type Metadata } from "next";
import { createServerClient, type Storyline, type Plot } from "../../../../lib/supabase";
import { DeadlineCountdown } from "../../../components/DeadlineCountdown";
import { TradingWidget } from "../../../components/TradingWidget";
import { PriceChart } from "../../../components/PriceChart";
import { DonateWidget } from "../../../components/DonateWidget";
import { RatingWidget } from "../../../components/RatingWidget";
import { RatingSummary } from "../../../components/RatingSummary";
import { ShareToFarcaster } from "../../../components/ShareToFarcaster";
import { getTokenPrice, type TokenPriceInfo } from "../../../../lib/price";
import { IS_TESTNET } from "../../../../lib/contracts/constants";
import { type Address } from "viem";
Expand All @@ -13,6 +15,71 @@ import { AgentBadge } from "../../../components/AgentBadge";

type Params = Promise<{ storylineId: string }>;

const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";

export async function generateMetadata({
params,
}: {
params: Params;
}): Promise<Metadata> {
const { storylineId } = await params;
const id = Number(storylineId);

if (isNaN(id) || id <= 0) return {};

const supabase = createServerClient();
if (!supabase) return {};

const { data: storyline } = await supabase
.from("storylines")
.select("*")
.eq("storyline_id", id)
.eq("hidden", false)
.single();

if (!storyline) return {};

const sl = storyline as Storyline;
const ogImageUrl = `${appUrl}/story/${id}/og`;
const storyUrl = `${appUrl}/story/${id}`;

const priceInfo = sl.token_address
? await getTokenPrice(sl.token_address as Address)
: null;
const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT";
const priceSuffix = priceInfo
? ` — Price: ${priceInfo.pricePerToken} ${reserveLabel}`
: "";
const description = `A collaborative on-chain story by ${truncateAddress(sl.writer_address)} — ${sl.plot_count} ${sl.plot_count === 1 ? "plot" : "plots"}${priceSuffix}`;

const fcEmbed = JSON.stringify({
version: "1",
imageUrl: ogImageUrl,
button: {
title: "Read Story",
action: {
type: "launch_miniapp",
url: storyUrl,
name: "PlotLink",
splashBackgroundColor: "#0a0a0a",
},
},
});

return {
title: `${sl.title} — PlotLink`,
description,
openGraph: {
title: sl.title,
description,
images: [{ url: ogImageUrl, width: 1200, height: 800 }],
},
other: {
"fc:miniapp": fcEmbed,
},
};
}

export default async function StoryPage({ params }: { params: Params }) {
const { storylineId } = await params;
const id = Number(storylineId);
Expand Down Expand Up @@ -86,6 +153,7 @@ export default async function StoryPage({ params }: { params: Params }) {
{sl.token_address && (
<RatingWidget storylineId={id} tokenAddress={sl.token_address} />
)}
<ShareToFarcaster storylineId={id} title={sl.title} />
</aside>
</div>
</div>
Expand Down
79 changes: 79 additions & 0 deletions src/components/ShareToFarcaster.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"use client";

import { useEffect, useState, useCallback } from "react";

/**
* "Share to Farcaster" button — only renders inside a Farcaster Mini App context.
* Calls sdk.actions.composeCast() with pre-filled text and story URL as embed.
*/
export function ShareToFarcaster({
storylineId,
title,
}: {
storylineId: number;
title: string;
}) {
const [visible, setVisible] = useState(false);

useEffect(() => {
if (typeof window === "undefined") return;

let cancelled = false;

import("@farcaster/miniapp-sdk")
.then(async ({ sdk }) => {
if (cancelled) return;
const ctx = await sdk.context;
if (ctx && !cancelled) setVisible(true);
})
.catch(() => {
// Not in a Farcaster context
});

return () => {
cancelled = true;
};
}, []);

const handleShare = useCallback(async () => {
const { sdk } = await import("@farcaster/miniapp-sdk");

const appUrl =
process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
const storyUrl = `${appUrl}/story/${storylineId}`;

await sdk.actions.composeCast({
text: `Check out "${title}" on PlotLink`,
embeds: [storyUrl],
});
}, [storylineId, title]);

if (!visible) return null;

return (
<button
type="button"
onClick={handleShare}
className="border-border bg-surface text-foreground hover:border-accent hover:text-accent flex w-full cursor-pointer items-center justify-center gap-2 rounded border px-3 py-2 text-xs transition-colors"
>
<FarcasterIcon />
Share to Farcaster
</button>
);
}

function FarcasterIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 1000 1000"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M257.778 155.556H742.222V844.444H671.111V528.889H670.414C662.554 441.677 589.258 373.333 500 373.333C410.742 373.333 337.446 441.677 329.586 528.889H328.889V844.444H257.778V155.556Z" />
<path d="M128.889 253.333L157.778 351.111H182.222V746.667C169.949 746.667 160 756.616 160 768.889V795.556H155.556C143.283 795.556 133.333 805.505 133.333 817.778V844.444H382.222V817.778C382.222 805.505 372.273 795.556 360 795.556H355.556V768.889C355.556 756.616 345.606 746.667 333.333 746.667H306.667V253.333H128.889Z" />
<path d="M693.333 746.667C681.06 746.667 671.111 756.616 671.111 768.889V795.556H666.667C654.394 795.556 644.444 805.505 644.444 817.778V844.444H893.333V817.778C893.333 805.505 883.384 795.556 871.111 795.556H866.667V768.889C866.667 756.616 856.717 746.667 844.444 746.667V351.111H868.889L897.778 253.333H720V746.667H693.333Z" />
</svg>
);
}
Loading