Comprehensive admin + user dashboards (production-ready)
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
// Public, unauthenticated route group (e.g. shared episode pages). No app shell,
|
||||
// no sidebar, no session requirement — just a clean centered canvas. The root
|
||||
// layout already provides <html>/<body>, fonts and the toaster.
|
||||
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="min-h-screen bg-background">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
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";
|
||||
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;
|
||||
const brandName = branding?.brandName ?? "PodcastYes";
|
||||
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 ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={branding.logoUrl} alt={brandName} className="h-7 w-auto" />
|
||||
) : (
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-2xl bg-brand text-brand-foreground">
|
||||
<Mic2 className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
<span className="font-display text-base font-extrabold tracking-tight">{brandName}</span>
|
||||
</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">
|
||||
PodcastYes
|
||||
</a>{" "}
|
||||
— turn any topic into a podcast with AI.
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user