2026-06-07 17:54:30 -04:00
|
|
|
import type { Metadata } from "next";
|
|
|
|
|
import { notFound } from "next/navigation";
|
|
|
|
|
import { Mic2 } from "lucide-react";
|
|
|
|
|
import { prisma } from "@/lib/db";
|
|
|
|
|
import { storage } from "@/lib/storage";
|
|
|
|
|
import { getActiveBranding, hexToHslTriplet } from "@/lib/branding";
|
|
|
|
|
import { WaveformPlayer } from "@/components/app/waveform-player";
|
|
|
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
2026-06-20 20:58:14 -04:00
|
|
|
import { Logo } from "@/components/ui/logo";
|
2026-06-07 17:54:30 -04:00
|
|
|
import type { StructuredScript } from "@/lib/ai/types";
|
|
|
|
|
|
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
|
|
|
|
|
|
async function loadShared(shareId: string) {
|
|
|
|
|
const episode = await prisma.episode.findUnique({
|
|
|
|
|
where: { shareId },
|
|
|
|
|
include: { audioAsset: true, coverArt: true, script: true, speakers: true },
|
|
|
|
|
});
|
|
|
|
|
// 404 when no episode, sharing disabled, or not finished.
|
|
|
|
|
if (!episode || !episode.shareId || episode.status !== "READY") return null;
|
|
|
|
|
return episode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function generateMetadata({
|
|
|
|
|
params,
|
|
|
|
|
}: {
|
|
|
|
|
params: Promise<{ shareId: string }>;
|
|
|
|
|
}): Promise<Metadata> {
|
|
|
|
|
const { shareId } = await params;
|
|
|
|
|
const episode = await loadShared(shareId);
|
|
|
|
|
if (!episode) return { title: "Episode not found" };
|
|
|
|
|
return {
|
|
|
|
|
title: episode.title,
|
|
|
|
|
description: episode.topic.slice(0, 160),
|
|
|
|
|
robots: { index: false },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default async function PublicSharePage({
|
|
|
|
|
params,
|
|
|
|
|
}: {
|
|
|
|
|
params: Promise<{ shareId: string }>;
|
|
|
|
|
}) {
|
|
|
|
|
const { shareId } = await params;
|
|
|
|
|
const episode = await loadShared(shareId);
|
|
|
|
|
if (!episode) notFound();
|
|
|
|
|
|
|
|
|
|
// Resolve the owning org's white-label branding (Agency custom_branding only).
|
|
|
|
|
const branding = await getActiveBranding(episode.userId, episode.organizationId);
|
|
|
|
|
const brandHsl = hexToHslTriplet(branding?.primaryColor);
|
|
|
|
|
const brandStyle = brandHsl
|
|
|
|
|
? ({ "--brand": brandHsl, "--ring": brandHsl } as React.CSSProperties)
|
|
|
|
|
: undefined;
|
2026-06-20 20:12:43 -04:00
|
|
|
const brandName = branding?.brandName ?? "Podcast Distribution AI";
|
2026-06-07 17:54:30 -04:00
|
|
|
const removePoweredBy = branding?.removePoweredBy ?? false;
|
|
|
|
|
|
|
|
|
|
// Prefer a directly-fetchable public URL (e.g. nginx /media); otherwise fall
|
|
|
|
|
// back to the share-authorized public cover route.
|
|
|
|
|
const coverUrl = episode.coverArt
|
|
|
|
|
? storage().publicUrl(episode.coverArt.storageKey) ??
|
|
|
|
|
`/api/public/episodes/${shareId}/cover`
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
const speakerNames: Record<string, string> = {};
|
|
|
|
|
for (const s of episode.speakers) speakerNames[s.speakerKey] = s.displayName;
|
|
|
|
|
const script = episode.script?.content as unknown as StructuredScript | undefined;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={brandStyle} className="mx-auto max-w-3xl px-6 py-10 sm:py-16">
|
|
|
|
|
{/* Header / brand wordmark */}
|
|
|
|
|
<header className="mb-8 flex items-center gap-2.5">
|
|
|
|
|
{branding?.logoUrl ? (
|
2026-06-20 20:58:14 -04:00
|
|
|
<>
|
|
|
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
|
|
|
<img src={branding.logoUrl} alt={brandName} className="h-7 w-auto" />
|
|
|
|
|
<span className="font-display text-base font-extrabold tracking-tight">{brandName}</span>
|
|
|
|
|
</>
|
2026-06-07 17:54:30 -04:00
|
|
|
) : (
|
2026-06-20 20:58:14 -04:00
|
|
|
<Logo className="h-7 w-auto" />
|
2026-06-07 17:54:30 -04:00
|
|
|
)}
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<article className="space-y-8">
|
|
|
|
|
<div className="grid gap-6 sm:grid-cols-[200px_1fr] sm:items-end">
|
|
|
|
|
<Card className="overflow-hidden">
|
|
|
|
|
<div className="aspect-square bg-muted">
|
|
|
|
|
{coverUrl ? (
|
|
|
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
|
|
|
<img src={coverUrl} alt={episode.title} className="h-full w-full object-cover" />
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
|
|
|
|
|
<Mic2 className="h-10 w-10" />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<Badge variant="brand" className="uppercase tracking-[0.04em]">
|
|
|
|
|
Podcast episode
|
|
|
|
|
</Badge>
|
|
|
|
|
<h1 className="font-display text-3xl font-extrabold leading-[1.1] tracking-tight sm:text-4xl">
|
|
|
|
|
{episode.title}
|
|
|
|
|
</h1>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
{episode.format.replace("_", "-").toLowerCase()} · {episode.language.toUpperCase()} ·{" "}
|
|
|
|
|
{episode.targetLengthMin} min
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{episode.audioAsset && (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardContent className="pt-6">
|
|
|
|
|
<WaveformPlayer
|
|
|
|
|
src={`/api/public/episodes/${shareId}/audio`}
|
|
|
|
|
durationSec={episode.audioAsset.durationSec}
|
|
|
|
|
hideDownloads
|
|
|
|
|
/>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<section className="space-y-3">
|
|
|
|
|
<h2 className="font-display text-xl font-extrabold tracking-tight">About this episode</h2>
|
|
|
|
|
<p className="whitespace-pre-wrap leading-relaxed text-foreground/90">{episode.topic}</p>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{script && script.sections?.length > 0 && (
|
|
|
|
|
<section className="space-y-3">
|
|
|
|
|
<h2 className="font-display text-xl font-extrabold tracking-tight">Show notes</h2>
|
|
|
|
|
<ul className="space-y-1.5 text-sm text-muted-foreground">
|
|
|
|
|
{script.sections.map((s) => (
|
|
|
|
|
<li key={s.id} className="flex gap-2">
|
|
|
|
|
<span className="mt-1 h-1.5 w-1.5 shrink-0 rounded-full bg-brand" />
|
|
|
|
|
{s.title}
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</section>
|
|
|
|
|
)}
|
|
|
|
|
</article>
|
|
|
|
|
|
|
|
|
|
{!removePoweredBy && (
|
|
|
|
|
<footer className="mt-16 border-t pt-6 text-center text-xs text-muted-foreground">
|
|
|
|
|
Made with{" "}
|
|
|
|
|
<a href="/" className="font-semibold text-brand hover:underline">
|
2026-06-20 20:12:43 -04:00
|
|
|
Podcast Distribution AI
|
2026-06-07 17:54:30 -04:00
|
|
|
</a>{" "}
|
|
|
|
|
— turn any topic into a podcast with AI.
|
|
|
|
|
</footer>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|