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 { JazzProvider } from "@/lib/jazz/provider"
|
||||||
import { CommentBox } from "@/components/CommentBox"
|
import { CommentBox } from "@/components/CommentBox"
|
||||||
import { ProfileSidebar } from "@/components/ProfileSidebar"
|
import { ProfileSidebar } from "@/components/ProfileSidebar"
|
||||||
|
import { ReplayGrid } from "@/components/ReplayGrid"
|
||||||
|
import { PaywallBanner } from "@/components/PaywallBanner"
|
||||||
import { authClient } from "@/lib/auth-client"
|
import { authClient } from "@/lib/auth-client"
|
||||||
import { MessageCircle, LogIn, X, User } from "lucide-react"
|
import { MessageCircle, LogIn, X, User } from "lucide-react"
|
||||||
|
|
||||||
@@ -33,6 +35,11 @@ function StreamPage() {
|
|||||||
const [showMobileChat, setShowMobileChat] = useState(false)
|
const [showMobileChat, setShowMobileChat] = useState(false)
|
||||||
const [showMobileProfile, setShowMobileProfile] = 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
|
const isAuthenticated = !!session?.user
|
||||||
|
|
||||||
// Fetch user and stream data from API
|
// Fetch user and stream data from API
|
||||||
@@ -148,6 +155,55 @@ function StreamPage() {
|
|||||||
// Determine if stream is actually live
|
// Determine if stream is actually live
|
||||||
const isActuallyLive = hlsLive === true || Boolean(stream?.is_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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-black text-white">
|
<div className="flex min-h-screen items-center justify-center bg-black text-white">
|
||||||
@@ -236,25 +292,52 @@ function StreamPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
||||||
<div className="mx-auto flex w-full max-w-2xl flex-col items-center px-6 text-center">
|
{/* Offline Message */}
|
||||||
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.35em] text-neutral-400">
|
<div className="flex-shrink-0 flex items-center justify-center py-16 md:py-24">
|
||||||
<span className="h-2 w-2 rounded-full bg-neutral-500" />
|
<div className="mx-auto flex w-full max-w-2xl flex-col items-center px-6 text-center">
|
||||||
Offline
|
<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" />
|
||||||
|
Offline
|
||||||
|
</div>
|
||||||
|
<p className="mt-6 text-2xl md:text-3xl font-semibold">
|
||||||
|
Not live right now
|
||||||
|
</p>
|
||||||
|
{profileUser.website && (
|
||||||
|
<a
|
||||||
|
href={profileUser.website.startsWith("http") ? profileUser.website : `https://${profileUser.website}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mt-8 text-2xl md:text-3xl font-medium text-white hover:text-neutral-300 transition-colors"
|
||||||
|
>
|
||||||
|
{profileUser.website.replace(/^https?:\/\//, "")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
<p className="mt-6 text-2xl md:text-3xl font-semibold">
|
|
||||||
Not live right now
|
|
||||||
</p>
|
|
||||||
{profileUser.website && (
|
|
||||||
<a
|
|
||||||
href={profileUser.website.startsWith("http") ? profileUser.website : `https://${profileUser.website}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="mt-8 text-2xl md:text-3xl font-medium text-white hover:text-neutral-300 transition-colors"
|
|
||||||
>
|
|
||||||
{profileUser.website.replace(/^https?:\/\//, "")}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -36,56 +36,27 @@ const handleGet = async ({
|
|||||||
|
|
||||||
const auth = getAuth()
|
const auth = getAuth()
|
||||||
const session = await auth.api.getSession({ headers: request.headers })
|
const session = await auth.api.getSession({ headers: request.headers })
|
||||||
const isOwner = session?.user?.id === user.id
|
|
||||||
|
|
||||||
// Owners can always see their own replays
|
// ONLY nikita@nikiv.dev can view replays
|
||||||
if (isOwner) {
|
const isNikita = session?.user?.email === "nikita@nikiv.dev"
|
||||||
try {
|
|
||||||
const replays = await database
|
|
||||||
.select()
|
|
||||||
.from(stream_replays)
|
|
||||||
.where(eq(stream_replays.user_id, user.id))
|
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-owners need subscription to this creator to view replays
|
if (!isNikita) {
|
||||||
if (!session?.user?.id) {
|
|
||||||
return json(
|
return json(
|
||||||
{ error: "Subscription required", code: "SUBSCRIPTION_REQUIRED" },
|
{ error: "Subscription required", code: "SUBSCRIPTION_REQUIRED" },
|
||||||
403
|
403
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasSubscription = await hasCreatorSubscription(session.user.id, user.id)
|
// Nikita can see all replays
|
||||||
if (!hasSubscription) {
|
|
||||||
return json(
|
|
||||||
{ error: "Subscription required", code: "SUBSCRIPTION_REQUIRED" },
|
|
||||||
403
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// With subscription, can view public ready replays
|
|
||||||
try {
|
try {
|
||||||
const replays = await database
|
const replays = await database
|
||||||
.select()
|
.select()
|
||||||
.from(stream_replays)
|
.from(stream_replays)
|
||||||
.where(
|
.where(eq(stream_replays.user_id, user.id))
|
||||||
and(
|
.orderBy(
|
||||||
eq(stream_replays.user_id, user.id),
|
desc(stream_replays.started_at),
|
||||||
eq(stream_replays.is_public, true),
|
desc(stream_replays.created_at)
|
||||||
eq(stream_replays.status, "ready")
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.orderBy(desc(stream_replays.started_at), desc(stream_replays.created_at))
|
|
||||||
|
|
||||||
return json({ replays })
|
return json({ replays })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[stream-replays] Error fetching replays:", error)
|
console.error("[stream-replays] Error fetching replays:", error)
|
||||||
|
|||||||
Reference in New Issue
Block a user