mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-11 22:40:32 +01:00
Add PaywallBanner and ReplayGrid components; fetch and display replays with paywall handling in user stream page
This commit is contained in:
79
packages/web/src/components/PaywallBanner.tsx
Normal file
79
packages/web/src/components/PaywallBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
118
packages/web/src/components/ReplayGrid.tsx
Normal file
118
packages/web/src/components/ReplayGrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -54,43 +62,6 @@ const handleGet = async ({
|
||||
console.error("[stream-replays] Error fetching replays:", error)
|
||||
return json({ error: "Failed to fetch replays" }, 500)
|
||||
}
|
||||
}
|
||||
|
||||
// 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")({
|
||||
|
||||
Reference in New Issue
Block a user