Add PaywallBanner and ReplayGrid components; fetch and display replays with paywall handling in user stream page

This commit is contained in:
Nikita
2025-12-25 12:01:48 -08:00
parent 2be1e74e3b
commit 9a2e5c5a4a
4 changed files with 306 additions and 55 deletions

View File

@@ -0,0 +1,79 @@
import { Lock, Sparkles } from "lucide-react"
import { Link } from "@tanstack/react-router"
interface PaywallBannerProps {
creatorName: string
creatorUsername: string
isAuthenticated: boolean
}
export function PaywallBanner({ creatorName, creatorUsername, isAuthenticated }: PaywallBannerProps) {
return (
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
{/* Icon */}
<div className="relative mb-6">
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-purple-500/20 to-pink-500/20 flex items-center justify-center border border-white/10">
<Lock className="w-10 h-10 text-white/60" />
</div>
<div className="absolute -top-1 -right-1 w-8 h-8 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center">
<Sparkles className="w-4 h-4 text-white" />
</div>
</div>
{/* Heading */}
<h3 className="text-2xl font-bold text-white mb-3">
Premium Content
</h3>
{/* Description */}
<p className="text-white/60 text-base max-w-md mb-8">
Subscribe to <span className="text-white font-medium">{creatorName}</span> to access their past stream replays and exclusive content
</p>
{/* Benefits */}
<div className="flex flex-col gap-2 mb-8 text-left max-w-sm">
<div className="flex items-start gap-3 text-white/70 text-sm">
<div className="w-5 h-5 rounded-full bg-green-500/20 flex items-center justify-center flex-shrink-0 mt-0.5">
<div className="w-2 h-2 rounded-full bg-green-500" />
</div>
<span>Watch all past stream recordings</span>
</div>
<div className="flex items-start gap-3 text-white/70 text-sm">
<div className="w-5 h-5 rounded-full bg-green-500/20 flex items-center justify-center flex-shrink-0 mt-0.5">
<div className="w-2 h-2 rounded-full bg-green-500" />
</div>
<span>Access exclusive behind-the-scenes content</span>
</div>
<div className="flex items-start gap-3 text-white/70 text-sm">
<div className="w-5 h-5 rounded-full bg-green-500/20 flex items-center justify-center flex-shrink-0 mt-0.5">
<div className="w-2 h-2 rounded-full bg-green-500" />
</div>
<span>Support {creatorName}'s work</span>
</div>
</div>
{/* CTA Button */}
{isAuthenticated ? (
<Link
to={`/${creatorUsername}/subscribe`}
className="px-8 py-3 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white font-semibold rounded-full transition-all transform hover:scale-105"
>
Subscribe Now
</Link>
) : (
<div className="flex flex-col gap-3">
<Link
to="/auth"
search={{ redirect: `/${creatorUsername}` }}
className="px-8 py-3 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white font-semibold rounded-full transition-all transform hover:scale-105"
>
Sign In to Subscribe
</Link>
<p className="text-white/40 text-xs">
New to Linsa? <Link to="/auth" className="text-purple-400 hover:underline">Create an account</Link>
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,118 @@
import { Play, Clock, Calendar } from "lucide-react"
import { Link } from "@tanstack/react-router"
interface Replay {
id: string
title: string | null
playback_url: string | null
thumbnail_url: string | null
duration_seconds: number | null
started_at: string | null
status: string | null
}
interface ReplayGridProps {
replays: Replay[]
username: string
}
function formatDuration(seconds: number | null): string {
if (!seconds) return "0:00"
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`
}
return `${minutes}:${secs.toString().padStart(2, "0")}`
}
function formatDate(dateString: string | null): string {
if (!dateString) return "Unknown date"
const date = new Date(dateString)
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
}
export function ReplayGrid({ replays, username }: ReplayGridProps) {
if (replays.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="text-white/40 mb-3">
<Play className="w-12 h-12" />
</div>
<p className="text-white/60 text-sm">No past streams available yet</p>
</div>
)
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
{replays.map((replay) => (
<div
key={replay.id}
className="group relative bg-white/5 rounded-lg overflow-hidden hover:bg-white/10 transition-colors border border-white/10"
>
{/* Thumbnail */}
<div className="relative aspect-video bg-black">
{replay.thumbnail_url ? (
<img
src={replay.thumbnail_url}
alt={replay.title || "Stream replay"}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Play className="w-12 h-12 text-white/20" />
</div>
)}
{/* Play overlay */}
{replay.playback_url && replay.status === "ready" && (
<Link
to={`/${username}/replay/${replay.id}`}
className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/60 transition-colors"
>
<div className="w-16 h-16 rounded-full bg-white/90 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Play className="w-8 h-8 text-black ml-1" />
</div>
</Link>
)}
{/* Duration badge */}
{replay.duration_seconds && (
<div className="absolute bottom-2 right-2 px-2 py-1 bg-black/80 rounded text-xs text-white flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatDuration(replay.duration_seconds)}
</div>
)}
{/* Status badge */}
{replay.status === "processing" && (
<div className="absolute top-2 left-2 px-2 py-1 bg-yellow-500/90 rounded text-xs text-black font-medium">
Processing...
</div>
)}
</div>
{/* Info */}
<div className="p-3">
<h3 className="text-white font-medium text-sm line-clamp-2 mb-1">
{replay.title || "Untitled Stream"}
</h3>
{replay.started_at && (
<div className="flex items-center gap-1 text-white/50 text-xs">
<Calendar className="w-3 h-3" />
{formatDate(replay.started_at)}
</div>
)}
</div>
</div>
))}
</div>
)
}

View File

@@ -5,6 +5,8 @@ import { VideoPlayer } from "@/components/VideoPlayer"
import { JazzProvider } from "@/lib/jazz/provider"
import { CommentBox } from "@/components/CommentBox"
import { ProfileSidebar } from "@/components/ProfileSidebar"
import { ReplayGrid } from "@/components/ReplayGrid"
import { PaywallBanner } from "@/components/PaywallBanner"
import { authClient } from "@/lib/auth-client"
import { MessageCircle, LogIn, X, User } from "lucide-react"
@@ -33,6 +35,11 @@ function StreamPage() {
const [showMobileChat, setShowMobileChat] = useState(false)
const [showMobileProfile, setShowMobileProfile] = useState(false)
// Replays state
const [replays, setReplays] = useState<any[]>([])
const [replaysLoading, setReplaysLoading] = useState(false)
const [showPaywall, setShowPaywall] = useState(false)
const isAuthenticated = !!session?.user
// Fetch user and stream data from API
@@ -148,6 +155,55 @@ function StreamPage() {
// Determine if stream is actually live
const isActuallyLive = hlsLive === true || Boolean(stream?.is_live)
// Fetch past stream replays when offline
useEffect(() => {
if (!data?.user || isActuallyLive || hlsLive === null) {
return
}
let isActive = true
const fetchReplays = async () => {
setReplaysLoading(true)
setShowPaywall(false)
try {
const res = await fetch(`/api/streams/${username}/replays`)
if (!isActive) return
if (res.status === 403) {
const errorData = await res.json()
if (errorData.code === "SUBSCRIPTION_REQUIRED") {
setShowPaywall(true)
setReplays([])
}
return
}
if (!res.ok) {
console.error("Failed to fetch replays:", res.status)
return
}
const replayData = await res.json()
setReplays(replayData.replays || [])
} catch (err) {
console.error("Error fetching replays:", err)
} finally {
if (isActive) {
setReplaysLoading(false)
}
}
}
fetchReplays()
return () => {
isActive = false
}
}, [data?.user, username, isActuallyLive, hlsLive])
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-black text-white">
@@ -236,7 +292,9 @@ function StreamPage() {
)}
</div>
) : (
<div className="flex h-full w-full items-center justify-center text-white pb-16 md:pb-0">
<div className="flex h-full w-full flex-col overflow-y-auto text-white pb-16 md:pb-0">
{/* Offline Message */}
<div className="flex-shrink-0 flex items-center justify-center py-16 md:py-24">
<div className="mx-auto flex w-full max-w-2xl flex-col items-center px-6 text-center">
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.35em] text-neutral-400">
<span className="h-2 w-2 rounded-full bg-neutral-500" />
@@ -257,6 +315,31 @@ function StreamPage() {
)}
</div>
</div>
{/* Past Streams Section */}
<div className="flex-shrink-0 border-t border-white/10">
<div className="max-w-7xl mx-auto">
<div className="px-6 py-6 border-b border-white/10">
<h2 className="text-xl font-bold text-white">Past Streams</h2>
<p className="text-sm text-white/60 mt-1">Watch previous recordings</p>
</div>
{replaysLoading ? (
<div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-4 border-white/20 border-t-white rounded-full animate-spin" />
</div>
) : showPaywall ? (
<PaywallBanner
creatorName={profileUser.name || profileUser.username}
creatorUsername={profileUser.username}
isAuthenticated={isAuthenticated}
/>
) : (
<ReplayGrid replays={replays} username={username} />
)}
</div>
</div>
</div>
)}
</div>

View File

@@ -36,10 +36,18 @@ const handleGet = async ({
const auth = getAuth()
const session = await auth.api.getSession({ headers: request.headers })
const isOwner = session?.user?.id === user.id
// Owners can always see their own replays
if (isOwner) {
// ONLY nikita@nikiv.dev can view replays
const isNikita = session?.user?.email === "nikita@nikiv.dev"
if (!isNikita) {
return json(
{ error: "Subscription required", code: "SUBSCRIPTION_REQUIRED" },
403
)
}
// Nikita can see all replays
try {
const replays = await database
.select()
@@ -56,43 +64,6 @@ const handleGet = async ({
}
}
// Non-owners need subscription to this creator to view replays
if (!session?.user?.id) {
return json(
{ error: "Subscription required", code: "SUBSCRIPTION_REQUIRED" },
403
)
}
const hasSubscription = await hasCreatorSubscription(session.user.id, user.id)
if (!hasSubscription) {
return json(
{ error: "Subscription required", code: "SUBSCRIPTION_REQUIRED" },
403
)
}
// With subscription, can view public ready replays
try {
const replays = await database
.select()
.from(stream_replays)
.where(
and(
eq(stream_replays.user_id, user.id),
eq(stream_replays.is_public, true),
eq(stream_replays.status, "ready")
)
)
.orderBy(desc(stream_replays.started_at), desc(stream_replays.created_at))
return json({ replays })
} catch (error) {
console.error("[stream-replays] Error fetching replays:", error)
return json({ error: "Failed to fetch replays" }, 500)
}
}
export const Route = createFileRoute("/api/streams/$username/replays")({
server: {
handlers: {