Add database schema updates for user profile fields and streams, seed initial data, and extend components with streaming settings and profile fields.

This commit is contained in:
Nikita
2025-12-25 00:41:00 -08:00
parent 3509b91c08
commit 205c38d0ee
13 changed files with 1030 additions and 352 deletions

View File

@@ -1,4 +1,3 @@
import { Link } from "@tanstack/react-router"
import { ExternalLink, MapPin, Calendar, Users } from "lucide-react"
interface ProfileSidebarProps {

View File

@@ -5,9 +5,10 @@ import {
UserRound,
type LucideIcon,
CreditCard,
Video,
} from "lucide-react"
type SettingsSection = "preferences" | "profile" | "billing"
type SettingsSection = "preferences" | "profile" | "streaming" | "billing"
interface UserProfile {
name?: string | null
@@ -30,6 +31,7 @@ type NavItem = {
const navItems: NavItem[] = [
{ id: "preferences", label: "Preferences", icon: SlidersHorizontal },
{ id: "profile", label: "Profile", icon: UserRound },
{ id: "streaming", label: "Streaming", icon: Video },
{ id: "billing", label: "Manage Billing", icon: CreditCard },
]

View File

@@ -25,6 +25,10 @@ export const users = pgTable("users", {
.$defaultFn(() => false)
.notNull(),
image: text("image"),
// Profile fields
bio: text("bio"),
website: text("website"),
location: text("location"),
// Access tiers: 'free' | 'creator' | 'dev' - determines feature access
tier: varchar("tier", { length: 32 }).notNull().default("free"),
createdAt: timestamp("createdAt")
@@ -247,7 +251,10 @@ export const streams = pgTable("streams", {
is_live: boolean("is_live").notNull().default(false),
viewer_count: integer("viewer_count").notNull().default(0),
stream_key: text("stream_key").notNull().unique(), // secret key for streaming
// Stream endpoints (set by Linux server)
// Cloudflare Stream integration
cloudflare_live_input_uid: text("cloudflare_live_input_uid"), // Cloudflare Live Input UID for automatic stream detection
cloudflare_customer_code: text("cloudflare_customer_code"), // Customer subdomain (optional, defaults to linsa's)
// Stream endpoints (can be auto-generated from cloudflare_live_input_uid or set manually)
hls_url: text("hls_url"), // HLS playback URL
webrtc_url: text("webrtc_url"), // WebRTC playback URL
thumbnail_url: text("thumbnail_url"),

View File

@@ -6,6 +6,10 @@ export type StreamPageData = {
name: string
username: string | null
image: string | null
bio?: string | null
website?: string | null
location?: string | null
joinedAt?: string | null
}
stream: {
id: string

View File

@@ -50,6 +50,7 @@ import { Route as ApiStripePortalRouteImport } from './routes/api/stripe/portal'
import { Route as ApiStripeCheckoutRouteImport } from './routes/api/stripe/checkout'
import { Route as ApiStripeBillingRouteImport } from './routes/api/stripe/billing'
import { Route as ApiStreamsUsernameRouteImport } from './routes/api/streams.$username'
import { Route as ApiStreamSettingsRouteImport } from './routes/api/stream.settings'
import { Route as ApiStreamReplaysReplayIdRouteImport } from './routes/api/stream-replays.$replayId'
import { Route as ApiSpotifyNowPlayingRouteImport } from './routes/api/spotify.now-playing'
import { Route as ApiFlowgladSplatRouteImport } from './routes/api/flowglad/$'
@@ -69,6 +70,7 @@ import { Route as DemoStartSsrFullSsrRouteImport } from './routes/demo/start.ssr
import { Route as DemoStartSsrDataOnlyRouteImport } from './routes/demo/start.ssr.data-only'
import { Route as ApiStreamsUsernameViewersRouteImport } from './routes/api/streams.$username.viewers'
import { Route as ApiStreamsUsernameReplaysRouteImport } from './routes/api/streams.$username.replays'
import { Route as ApiStreamsUsernameCheckHlsRouteImport } from './routes/api/streams.$username.check-hls'
import { Route as ApiCreatorUsernameAccessRouteImport } from './routes/api/creator/$username.access'
import { Route as ApiCanvasImagesImageIdRouteImport } from './routes/api/canvas.images.$imageId'
import { Route as ApiCanvasImagesImageIdGenerateRouteImport } from './routes/api/canvas.images.$imageId.generate'
@@ -278,6 +280,11 @@ const ApiStreamsUsernameRoute = ApiStreamsUsernameRouteImport.update({
path: '/api/streams/$username',
getParentRoute: () => rootRouteImport,
} as any)
const ApiStreamSettingsRoute = ApiStreamSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => ApiStreamRoute,
} as any)
const ApiStreamReplaysReplayIdRoute =
ApiStreamReplaysReplayIdRouteImport.update({
id: '/$replayId',
@@ -377,6 +384,12 @@ const ApiStreamsUsernameReplaysRoute =
path: '/replays',
getParentRoute: () => ApiStreamsUsernameRoute,
} as any)
const ApiStreamsUsernameCheckHlsRoute =
ApiStreamsUsernameCheckHlsRouteImport.update({
id: '/check-hls',
path: '/check-hls',
getParentRoute: () => ApiStreamsUsernameRoute,
} as any)
const ApiCreatorUsernameAccessRoute =
ApiCreatorUsernameAccessRouteImport.update({
id: '/api/creator/$username/access',
@@ -417,7 +430,7 @@ export interface FileRoutesByFullPath {
'/api/check-hls': typeof ApiCheckHlsRoute
'/api/context-items': typeof ApiContextItemsRoute
'/api/profile': typeof ApiProfileRoute
'/api/stream': typeof ApiStreamRoute
'/api/stream': typeof ApiStreamRouteWithChildren
'/api/stream-comments': typeof ApiStreamCommentsRoute
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
'/api/stream-status': typeof ApiStreamStatusRoute
@@ -440,6 +453,7 @@ export interface FileRoutesByFullPath {
'/api/flowglad/$': typeof ApiFlowgladSplatRoute
'/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute
'/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute
'/api/stream/settings': typeof ApiStreamSettingsRoute
'/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren
'/api/stripe/billing': typeof ApiStripeBillingRoute
'/api/stripe/checkout': typeof ApiStripeCheckoutRoute
@@ -452,6 +466,7 @@ export interface FileRoutesByFullPath {
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
'/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren
'/api/creator/$username/access': typeof ApiCreatorUsernameAccessRoute
'/api/streams/$username/check-hls': typeof ApiStreamsUsernameCheckHlsRoute
'/api/streams/$username/replays': typeof ApiStreamsUsernameReplaysRoute
'/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
@@ -481,7 +496,7 @@ export interface FileRoutesByTo {
'/api/check-hls': typeof ApiCheckHlsRoute
'/api/context-items': typeof ApiContextItemsRoute
'/api/profile': typeof ApiProfileRoute
'/api/stream': typeof ApiStreamRoute
'/api/stream': typeof ApiStreamRouteWithChildren
'/api/stream-comments': typeof ApiStreamCommentsRoute
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
'/api/stream-status': typeof ApiStreamStatusRoute
@@ -504,6 +519,7 @@ export interface FileRoutesByTo {
'/api/flowglad/$': typeof ApiFlowgladSplatRoute
'/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute
'/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute
'/api/stream/settings': typeof ApiStreamSettingsRoute
'/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren
'/api/stripe/billing': typeof ApiStripeBillingRoute
'/api/stripe/checkout': typeof ApiStripeCheckoutRoute
@@ -516,6 +532,7 @@ export interface FileRoutesByTo {
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
'/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren
'/api/creator/$username/access': typeof ApiCreatorUsernameAccessRoute
'/api/streams/$username/check-hls': typeof ApiStreamsUsernameCheckHlsRoute
'/api/streams/$username/replays': typeof ApiStreamsUsernameReplaysRoute
'/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
@@ -547,7 +564,7 @@ export interface FileRoutesById {
'/api/check-hls': typeof ApiCheckHlsRoute
'/api/context-items': typeof ApiContextItemsRoute
'/api/profile': typeof ApiProfileRoute
'/api/stream': typeof ApiStreamRoute
'/api/stream': typeof ApiStreamRouteWithChildren
'/api/stream-comments': typeof ApiStreamCommentsRoute
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
'/api/stream-status': typeof ApiStreamStatusRoute
@@ -570,6 +587,7 @@ export interface FileRoutesById {
'/api/flowglad/$': typeof ApiFlowgladSplatRoute
'/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute
'/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute
'/api/stream/settings': typeof ApiStreamSettingsRoute
'/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren
'/api/stripe/billing': typeof ApiStripeBillingRoute
'/api/stripe/checkout': typeof ApiStripeCheckoutRoute
@@ -582,6 +600,7 @@ export interface FileRoutesById {
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
'/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren
'/api/creator/$username/access': typeof ApiCreatorUsernameAccessRoute
'/api/streams/$username/check-hls': typeof ApiStreamsUsernameCheckHlsRoute
'/api/streams/$username/replays': typeof ApiStreamsUsernameReplaysRoute
'/api/streams/$username/viewers': typeof ApiStreamsUsernameViewersRoute
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
@@ -637,6 +656,7 @@ export interface FileRouteTypes {
| '/api/flowglad/$'
| '/api/spotify/now-playing'
| '/api/stream-replays/$replayId'
| '/api/stream/settings'
| '/api/streams/$username'
| '/api/stripe/billing'
| '/api/stripe/checkout'
@@ -649,6 +669,7 @@ export interface FileRouteTypes {
| '/demo/start/server-funcs'
| '/api/canvas/images/$imageId'
| '/api/creator/$username/access'
| '/api/streams/$username/check-hls'
| '/api/streams/$username/replays'
| '/api/streams/$username/viewers'
| '/demo/start/ssr/data-only'
@@ -701,6 +722,7 @@ export interface FileRouteTypes {
| '/api/flowglad/$'
| '/api/spotify/now-playing'
| '/api/stream-replays/$replayId'
| '/api/stream/settings'
| '/api/streams/$username'
| '/api/stripe/billing'
| '/api/stripe/checkout'
@@ -713,6 +735,7 @@ export interface FileRouteTypes {
| '/demo/start/server-funcs'
| '/api/canvas/images/$imageId'
| '/api/creator/$username/access'
| '/api/streams/$username/check-hls'
| '/api/streams/$username/replays'
| '/api/streams/$username/viewers'
| '/demo/start/ssr/data-only'
@@ -766,6 +789,7 @@ export interface FileRouteTypes {
| '/api/flowglad/$'
| '/api/spotify/now-playing'
| '/api/stream-replays/$replayId'
| '/api/stream/settings'
| '/api/streams/$username'
| '/api/stripe/billing'
| '/api/stripe/checkout'
@@ -778,6 +802,7 @@ export interface FileRouteTypes {
| '/demo/start/server-funcs'
| '/api/canvas/images/$imageId'
| '/api/creator/$username/access'
| '/api/streams/$username/check-hls'
| '/api/streams/$username/replays'
| '/api/streams/$username/viewers'
| '/demo/start/ssr/data-only'
@@ -809,7 +834,7 @@ export interface RootRouteChildren {
ApiCheckHlsRoute: typeof ApiCheckHlsRoute
ApiContextItemsRoute: typeof ApiContextItemsRoute
ApiProfileRoute: typeof ApiProfileRoute
ApiStreamRoute: typeof ApiStreamRoute
ApiStreamRoute: typeof ApiStreamRouteWithChildren
ApiStreamCommentsRoute: typeof ApiStreamCommentsRoute
ApiStreamReplaysRoute: typeof ApiStreamReplaysRouteWithChildren
ApiStreamStatusRoute: typeof ApiStreamStatusRoute
@@ -1128,6 +1153,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiStreamsUsernameRouteImport
parentRoute: typeof rootRouteImport
}
'/api/stream/settings': {
id: '/api/stream/settings'
path: '/settings'
fullPath: '/api/stream/settings'
preLoaderRoute: typeof ApiStreamSettingsRouteImport
parentRoute: typeof ApiStreamRoute
}
'/api/stream-replays/$replayId': {
id: '/api/stream-replays/$replayId'
path: '/$replayId'
@@ -1261,6 +1293,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiStreamsUsernameReplaysRouteImport
parentRoute: typeof ApiStreamsUsernameRoute
}
'/api/streams/$username/check-hls': {
id: '/api/streams/$username/check-hls'
path: '/check-hls'
fullPath: '/api/streams/$username/check-hls'
preLoaderRoute: typeof ApiStreamsUsernameCheckHlsRouteImport
parentRoute: typeof ApiStreamsUsernameRoute
}
'/api/creator/$username/access': {
id: '/api/creator/$username/access'
path: '/api/creator/$username/access'
@@ -1372,6 +1411,18 @@ const ApiCanvasRouteWithChildren = ApiCanvasRoute._addFileChildren(
ApiCanvasRouteChildren,
)
interface ApiStreamRouteChildren {
ApiStreamSettingsRoute: typeof ApiStreamSettingsRoute
}
const ApiStreamRouteChildren: ApiStreamRouteChildren = {
ApiStreamSettingsRoute: ApiStreamSettingsRoute,
}
const ApiStreamRouteWithChildren = ApiStreamRoute._addFileChildren(
ApiStreamRouteChildren,
)
interface ApiStreamReplaysRouteChildren {
ApiStreamReplaysReplayIdRoute: typeof ApiStreamReplaysReplayIdRoute
}
@@ -1408,11 +1459,13 @@ const ApiUsersRouteWithChildren = ApiUsersRoute._addFileChildren(
)
interface ApiStreamsUsernameRouteChildren {
ApiStreamsUsernameCheckHlsRoute: typeof ApiStreamsUsernameCheckHlsRoute
ApiStreamsUsernameReplaysRoute: typeof ApiStreamsUsernameReplaysRoute
ApiStreamsUsernameViewersRoute: typeof ApiStreamsUsernameViewersRoute
}
const ApiStreamsUsernameRouteChildren: ApiStreamsUsernameRouteChildren = {
ApiStreamsUsernameCheckHlsRoute: ApiStreamsUsernameCheckHlsRoute,
ApiStreamsUsernameReplaysRoute: ApiStreamsUsernameReplaysRoute,
ApiStreamsUsernameViewersRoute: ApiStreamsUsernameViewersRoute,
}
@@ -1442,7 +1495,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiCheckHlsRoute: ApiCheckHlsRoute,
ApiContextItemsRoute: ApiContextItemsRoute,
ApiProfileRoute: ApiProfileRoute,
ApiStreamRoute: ApiStreamRoute,
ApiStreamRoute: ApiStreamRouteWithChildren,
ApiStreamCommentsRoute: ApiStreamCommentsRoute,
ApiStreamReplaysRoute: ApiStreamReplaysRouteWithChildren,
ApiStreamStatusRoute: ApiStreamStatusRoute,

View File

@@ -2,14 +2,9 @@ import { useEffect, useRef, useState } from "react"
import { createFileRoute, Link } from "@tanstack/react-router"
import { getStreamByUsername, type StreamPageData } from "@/lib/stream/db"
import { VideoPlayer } from "@/components/VideoPlayer"
import { resolveStreamPlayback } from "@/lib/stream/playback"
import { JazzProvider } from "@/lib/jazz/provider"
import { CommentBox } from "@/components/CommentBox"
import { ProfileSidebar } from "@/components/ProfileSidebar"
import {
getSpotifyNowPlaying,
type SpotifyNowPlayingResponse,
} from "@/lib/spotify/now-playing"
import { authClient } from "@/lib/auth-client"
import { MessageCircle, LogIn, X, User } from "lucide-react"
@@ -20,34 +15,6 @@ export const Route = createFileRoute("/$username")({
const READY_PULSE_MS = 1200
// Hardcoded user for nikiv (hls_url will be updated from API)
function makeNikivData(hlsUrl: string): StreamPageData {
return {
user: {
id: "nikiv",
name: "Nikita Voloboev",
username: "nikiv",
image: "https://nikiv.dev/nikiv.jpg",
bio: "Building in public. Making tools I want to exist.",
website: "nikiv.dev",
location: null,
joinedAt: "2024-01-01",
},
stream: {
id: "nikiv-stream",
title: "Live Coding",
description: "Building in public",
is_live: false,
viewer_count: 0,
hls_url: hlsUrl,
webrtc_url: null,
playback: resolveStreamPlayback({ hlsUrl, webrtcUrl: null }),
thumbnail_url: null,
started_at: null,
},
}
}
function StreamPage() {
const { username } = Route.useParams()
const { data: session } = authClient.useSession()
@@ -58,32 +25,20 @@ function StreamPage() {
const [hlsLive, setHlsLive] = useState<boolean | null>(null)
const [hlsUrl, setHlsUrl] = useState<string | null>(null)
const [isConnecting, setIsConnecting] = useState(false)
const [nowPlaying, setNowPlaying] = useState<SpotifyNowPlayingResponse | null>(
null,
)
const [nowPlayingLoading, setNowPlayingLoading] = useState(false)
const [nowPlayingError, setNowPlayingError] = useState(false)
const [streamLive, setStreamLive] = useState(false)
const [showReadyPulse, setShowReadyPulse] = useState(false)
const readyPulseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const hasConnectedOnce = useRef(false)
// Mobile chat overlay
// Mobile overlays
const [showMobileChat, setShowMobileChat] = useState(false)
const [showMobileProfile, setShowMobileProfile] = useState(false)
const isAuthenticated = !!session?.user
// Fetch user and stream data from API
useEffect(() => {
let isActive = true
// Special handling for nikiv - URL comes from API (secret)
// Data and loading state are handled by the HLS check effect
if (username === "nikiv") {
return () => {
isActive = false
}
}
const loadData = async () => {
if (!isActive) return
setLoading(true)
@@ -92,6 +47,9 @@ function StreamPage() {
const result = await getStreamByUsername(username)
if (isActive) {
setData(result)
if (result?.stream?.hls_url) {
setHlsUrl(result.stream.hls_url)
}
}
} catch (err) {
if (isActive) {
@@ -111,34 +69,7 @@ function StreamPage() {
}
}, [username])
// Poll stream status for nikiv from nikiv.dev/api/stream-status
useEffect(() => {
if (username !== "nikiv") {
return
}
let isActive = true
const fetchStatus = async () => {
const status = await getStreamStatus()
console.log("[Stream Status] nikiv.dev/api/stream-status:", status)
if (isActive) {
setStreamLive(status.isLive)
}
}
// Fetch immediately
fetchStatus()
// Poll every 10 seconds
const interval = setInterval(fetchStatus, 10000)
return () => {
isActive = false
clearInterval(interval)
}
}, [username])
// Ready pulse animation
useEffect(() => {
if (readyPulseTimeoutRef.current) {
clearTimeout(readyPulseTimeoutRef.current)
@@ -165,218 +96,64 @@ function StreamPage() {
}, [playerReady])
const stream = data?.stream ?? null
// For nikiv, always use HLS directly (no WebRTC) - URL comes from API
const activePlayback = username === "nikiv" && hlsUrl
const activePlayback = hlsUrl
? { type: "hls" as const, url: hlsUrl }
: stream?.playback ?? null
const isHlsPlaylistLive = (manifest: string) => {
const upper = manifest.toUpperCase()
const hasEndlist = upper.includes("#EXT-X-ENDLIST")
const isVod = upper.includes("#EXT-X-PLAYLIST-TYPE:VOD")
const hasSegments =
upper.includes("#EXTINF") || upper.includes("#EXT-X-PART")
// Also check for #EXTM3U which is the start of any valid HLS manifest
const isValidManifest = upper.includes("#EXTM3U")
return isValidManifest && !hasEndlist && !isVod && hasSegments
}
// For nikiv, use server-side API to check HLS (avoids CORS, gets URL from secret)
// Poll HLS status via server-side API (avoids CORS issues)
useEffect(() => {
if (username !== "nikiv") return
if (!data?.user) return
let isActive = true
let isFirstCheck = true
const checkHlsViaApi = async () => {
const checkHls = async () => {
try {
const res = await fetch("/api/check-hls", { cache: "no-store" })
const res = await fetch(`/api/streams/${username}/check-hls`, { cache: "no-store" })
if (!isActive) return
const apiData = await res.json()
// Update HLS URL from API (comes from server secret)
// Update HLS URL if returned from API
if (apiData.hlsUrl && apiData.hlsUrl !== hlsUrl) {
setHlsUrl(apiData.hlsUrl)
setData(makeNikivData(apiData.hlsUrl))
}
if (apiData.isLive) {
// Stream is live - set connecting state if first time
if (!hasConnectedOnce.current) {
setIsConnecting(true)
}
setHlsLive(true)
} else {
// Only set offline if we haven't connected yet
// This prevents flickering when HLS check temporarily fails
if (!hasConnectedOnce.current) {
setHlsLive(false)
}
}
} catch {
// Silently ignore errors - don't change state on network issues
} finally {
// Mark loading as done after first check completes
if (isActive && isFirstCheck) {
isFirstCheck = false
setLoading(false)
}
// Silently ignore errors
}
}
// Initial check
setHlsLive(null)
checkHlsViaApi()
checkHls()
// Poll every 5 seconds to detect when stream goes live
const interval = setInterval(checkHlsViaApi, 5000)
// Poll every 5 seconds
const interval = setInterval(checkHls, 5000)
return () => {
isActive = false
clearInterval(interval)
}
}, [username, hlsUrl])
}, [data?.user, username, hlsUrl])
// For non-nikiv users, use direct HLS check
useEffect(() => {
if (username === "nikiv" || !activePlayback || activePlayback.type !== "hls") {
return
}
let isActive = true
const checkHlsLive = async () => {
try {
const res = await fetch(activePlayback.url, {
cache: "no-store",
mode: "cors",
})
if (!isActive) return
if (!res.ok) {
if (!hasConnectedOnce.current) {
setHlsLive(false)
}
return
}
const manifest = await res.text()
if (!isActive) return
const live = isHlsPlaylistLive(manifest)
if (live) {
if (!hasConnectedOnce.current) {
setIsConnecting(true)
}
setHlsLive(true)
} else if (!hasConnectedOnce.current) {
setHlsLive(false)
}
} catch {
// Silently ignore fetch errors
}
}
setHlsLive(null)
checkHlsLive()
const interval = setInterval(checkHlsLive, 5000)
return () => {
isActive = false
clearInterval(interval)
}
}, [
username,
activePlayback?.type,
activePlayback?.type === "hls" ? activePlayback.url : null,
])
useEffect(() => {
let isActive = true
if (!stream?.hls_url || activePlayback?.type === "hls") {
return () => {
isActive = false
}
}
setHlsLive(null)
fetch(stream.hls_url)
.then(async (res) => {
if (!isActive) return
if (!res.ok) {
setHlsLive(false)
return
}
const manifest = await res.text()
if (!isActive) return
setHlsLive(isHlsPlaylistLive(manifest))
})
.catch(() => {
if (isActive) {
setHlsLive(false)
}
})
return () => {
isActive = false
}
}, [activePlayback?.type, stream?.hls_url])
// For nikiv, use HLS live check from our API
// For other users, use stream?.is_live from the database
const isActuallyLive = username === "nikiv"
? hlsLive === true
: Boolean(stream?.is_live)
// Only show Spotify when we know stream is offline (not during initial check)
const shouldFetchSpotify = username === "nikiv" && !isActuallyLive && hlsLive === false
useEffect(() => {
if (!shouldFetchSpotify) {
setNowPlaying(null)
setNowPlayingLoading(false)
setNowPlayingError(false)
return
}
let isActive = true
const fetchNowPlaying = async (showLoading: boolean) => {
if (showLoading) {
setNowPlayingLoading(true)
}
try {
const response = await getSpotifyNowPlaying()
if (!isActive) return
setNowPlaying(response)
setNowPlayingError(false)
} catch {
if (!isActive) return
// Silently handle Spotify errors - it's not critical
setNowPlayingError(true)
} finally {
if (isActive && showLoading) {
setNowPlayingLoading(false)
}
}
}
fetchNowPlaying(true)
const interval = setInterval(() => fetchNowPlaying(false), 30000)
return () => {
isActive = false
clearInterval(interval)
}
}, [shouldFetchSpotify])
// Determine if stream is actually live
const isActuallyLive = hlsLive === true || Boolean(stream?.is_live)
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-black text-white">
<div className="text-xl">Loading...</div>
<div className="relative">
<div className="w-16 h-16 border-4 border-white/20 border-t-white rounded-full animate-spin" />
</div>
</div>
)
}
@@ -405,41 +182,31 @@ function StreamPage() {
)
}
const nowPlayingTrack = nowPlaying?.track ?? null
const nowPlayingArtists = nowPlayingTrack?.artists.length
? nowPlayingTrack.artists.join(", ")
: null
const nowPlayingText = nowPlayingTrack
? nowPlayingArtists
? `${nowPlayingArtists}${nowPlayingTrack.title}`
: nowPlayingTrack.title
: null
// Callback when player is ready
const handlePlayerReady = () => {
hasConnectedOnce.current = true
setIsConnecting(false)
setPlayerReady(true)
}
// Show loading state during initial check
const isChecking = hlsLive === null
// Build profile user object
const profileUser = {
id: data.user.id,
name: data.user.name,
username: data.user.username ?? username,
image: data.user.image,
bio: data.user.bio ?? null,
website: data.user.website ?? null,
location: data.user.location ?? null,
joinedAt: data.user.joinedAt ?? null,
}
return (
<JazzProvider>
<LiveNowSidebar currentUsername={username} />
<div className="h-screen w-screen bg-black flex flex-col md:flex-row">
{/* Main content area */}
{/* Main content area - Stream */}
<div className="flex-1 relative min-h-0">
{/* Viewer count overlay - hidden on mobile */}
{isActuallyLive && (
<div className="hidden md:block absolute top-4 right-4 z-10 rounded-lg bg-black/50 px-3 py-2 backdrop-blur-sm">
<ViewerCount username={username} />
</div>
)}
{/* Loading state - checking if stream is live */}
{isChecking ? (
<div className="flex h-full w-full flex-col items-center justify-center text-white">
<div className="relative">
@@ -448,14 +215,12 @@ function StreamPage() {
<p className="mt-6 text-lg text-neutral-400">Checking stream status...</p>
</div>
) : isActuallyLive && activePlayback ? (
/* Stream is live - show the player */
<div className="relative h-full w-full">
<VideoPlayer
src={activePlayback.url}
muted={false}
onReady={handlePlayerReady}
/>
{/* Loading overlay while connecting */}
{(isConnecting || !playerReady) && (
<div className="pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/80">
<div className="relative">
@@ -464,7 +229,6 @@ function StreamPage() {
<p className="mt-6 text-lg text-white">Connecting to stream...</p>
</div>
)}
{/* Ready pulse */}
{showReadyPulse && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<div className="animate-pulse text-4xl">🔴</div>
@@ -472,76 +236,43 @@ function StreamPage() {
)}
</div>
) : (
/* Stream is offline */
<div className="flex h-full w-full items-center justify-center text-white pb-16 md:pb-0">
{shouldFetchSpotify ? (
<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" />
Offline
</div>
<p className="mt-6 text-2xl md:text-3xl font-semibold">
Not live right now
</p>
<div className="mt-6 text-base md:text-lg text-neutral-300">
{nowPlayingLoading ? (
<span>Checking Spotify...</span>
) : nowPlaying?.isPlaying && nowPlayingTrack ? (
<span>
Currently playing{" "}
{nowPlayingTrack.url ? (
<a
href={nowPlayingTrack.url}
target="_blank"
rel="noopener noreferrer"
className="text-white hover:text-neutral-300 transition-colors"
>
{nowPlayingText ?? "Spotify"}
</a>
) : (
<span className="text-white">
{nowPlayingText ?? "Spotify"}
</span>
)}
</span>
) : null}
</div>
<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" />
Offline
</div>
<p className="mt-6 text-2xl md:text-3xl font-semibold">
Not live right now
</p>
{profileUser.website && (
<a
href="https://nikiv.dev"
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"
>
nikiv.dev
{profileUser.website.replace(/^https?:\/\//, "")}
</a>
</div>
) : (
<div className="text-center">
<p className="text-xl md:text-2xl font-medium text-neutral-400 mb-6">
stream soon
</p>
<a
href={username === "nikiv" ? "https://nikiv.dev" : "#"}
target="_blank"
rel="noopener noreferrer"
className="text-2xl md:text-4xl font-medium text-white hover:text-neutral-300 transition-colors"
>
{username === "nikiv" ? "nikiv.dev" : `@${username}`}
</a>
</div>
)}
)}
</div>
</div>
)}
</div>
{/* Desktop Chat sidebar */}
<div className="hidden md:block w-80 h-full border-l border-white/10 flex-shrink-0">
<CommentBox username={username} />
{/* Desktop Profile Sidebar with Chat */}
<div className="hidden md:flex w-96 h-full flex-shrink-0">
<ProfileSidebar
user={profileUser}
isLive={isActuallyLive}
viewerCount={stream?.viewer_count ?? 0}
>
<CommentBox username={username} />
</ProfileSidebar>
</div>
{/* Mobile bottom bar */}
<div className="md:hidden fixed bottom-0 left-0 right-0 z-30 bg-black/90 backdrop-blur-sm border-t border-white/10 px-4 py-3 flex items-center justify-center gap-6">
<div className="md:hidden fixed bottom-0 left-0 right-0 z-30 bg-black/90 backdrop-blur-sm border-t border-white/10 px-4 py-3 flex items-center justify-center gap-4">
{!isAuthenticated && (
<Link
to="/auth"
@@ -559,6 +290,14 @@ function StreamPage() {
<MessageCircle className="w-4 h-4" />
Chat
</button>
<button
type="button"
onClick={() => setShowMobileProfile(true)}
className="flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 text-white text-sm font-medium"
>
<User className="w-4 h-4" />
Profile
</button>
</div>
{/* Mobile chat overlay */}
@@ -579,6 +318,29 @@ function StreamPage() {
</div>
</div>
)}
{/* Mobile profile overlay */}
{showMobileProfile && (
<div className="md:hidden fixed inset-0 z-40 bg-black flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10">
<span className="text-white font-medium">Profile</span>
<button
type="button"
onClick={() => setShowMobileProfile(false)}
className="p-2 text-white/70 hover:text-white"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 min-h-0 overflow-auto">
<ProfileSidebar
user={profileUser}
isLive={isActuallyLive}
viewerCount={stream?.viewer_count ?? 0}
/>
</div>
</div>
)}
</div>
</JazzProvider>
)

View File

@@ -1,4 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"
import { eq } from "drizzle-orm"
import { getDb } from "@/db/connection"
import { users } from "@/db/schema"
const json = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), {
@@ -9,21 +12,52 @@ const json = (data: unknown, status = 200) =>
// Cloudflare customer subdomain
const CLOUDFLARE_CUSTOMER_CODE = "xctsztqzu046isdc"
function getHlsUrl(): string {
function getEnvFromContext(): { hlsUrl: string; databaseUrl: string | null } {
try {
const { getServerContext } = require("@tanstack/react-start/server") as {
getServerContext: () => { cloudflare?: { env?: Record<string, string> } } | null
}
const ctx = getServerContext()
const liveInputUid = ctx?.cloudflare?.env?.CLOUDFLARE_LIVE_INPUT_UID
const databaseUrl = ctx?.cloudflare?.env?.DATABASE_URL ?? process.env.DATABASE_URL ?? null
if (liveInputUid) {
return `https://customer-${CLOUDFLARE_CUSTOMER_CODE}.cloudflarestream.com/${liveInputUid}/manifest/video.m3u8`
return {
hlsUrl: `https://customer-${CLOUDFLARE_CUSTOMER_CODE}.cloudflarestream.com/${liveInputUid}/manifest/video.m3u8`,
databaseUrl,
}
}
} catch {}
// Fallback - should not happen in production
throw new Error("CLOUDFLARE_LIVE_INPUT_UID not configured")
}
async function getNikivProfile(databaseUrl: string | null) {
if (!databaseUrl) return null
try {
const database = getDb(databaseUrl)
const user = await database.query.users.findFirst({
where: eq(users.username, "nikiv"),
})
if (!user) return null
return {
id: user.id,
name: user.name,
username: user.username,
image: user.image,
bio: user.bio ?? null,
website: user.website ?? null,
location: user.location ?? null,
joinedAt: user.createdAt?.toISOString() ?? null,
}
} catch (err) {
console.error("[check-hls] Failed to fetch nikiv profile:", err)
return null
}
}
function isHlsPlaylistLive(manifest: string): boolean {
const upper = manifest.toUpperCase()
const hasEndlist = upper.includes("#EXT-X-ENDLIST")
@@ -44,23 +78,27 @@ export const Route = createFileRoute("/api/check-hls")({
handlers: {
GET: async () => {
try {
const hlsUrl = getHlsUrl()
const res = await fetch(hlsUrl, {
cache: "no-store",
})
const { hlsUrl, databaseUrl } = getEnvFromContext()
console.log("[check-hls] Response status:", res.status)
// Fetch profile and HLS status in parallel
const [profile, hlsRes] = await Promise.all([
getNikivProfile(databaseUrl),
fetch(hlsUrl, { cache: "no-store" }),
])
if (!res.ok) {
console.log("[check-hls] Response status:", hlsRes.status)
if (!hlsRes.ok) {
return json({
isLive: false,
hlsUrl,
status: res.status,
profile,
status: hlsRes.status,
error: "HLS not available",
})
}
const manifest = await res.text()
const manifest = await hlsRes.text()
const isLive = isHlsPlaylistLive(manifest)
console.log("[check-hls] Manifest check:", {
@@ -72,7 +110,8 @@ export const Route = createFileRoute("/api/check-hls")({
return json({
isLive,
hlsUrl,
status: res.status,
profile,
status: hlsRes.status,
manifestLength: manifest.length,
})
} catch (err) {
@@ -80,7 +119,8 @@ export const Route = createFileRoute("/api/check-hls")({
console.error("[check-hls] Error:", error.message)
return json({
isLive: false,
hlsUrl: getHlsUrl(),
hlsUrl: null,
profile: null,
error: error.message,
})
}

View File

@@ -0,0 +1,164 @@
import { createFileRoute } from "@tanstack/react-router"
import { eq } from "drizzle-orm"
import { getDb } from "@/db/connection"
import { users, streams } from "@/db/schema"
import crypto from "node:crypto"
const json = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json" },
})
const getServerContext = () => {
try {
const { getServerContext: gsc } = require("@tanstack/react-start/server") as {
getServerContext: () => { cloudflare?: { env?: Record<string, unknown> } } | null
}
return gsc()
} catch {}
return null
}
const resolveDatabaseUrl = () => {
const ctx = getServerContext()
const env = ctx?.cloudflare?.env as Record<string, string> | undefined
if (env?.DATABASE_URL) return env.DATABASE_URL
if (process.env.DATABASE_URL) return process.env.DATABASE_URL
throw new Error("DATABASE_URL is not configured")
}
// Get user from session cookie
async function getSessionUser(request: Request) {
const cookie = request.headers.get("cookie")
if (!cookie) return null
// Parse session token from cookie
const sessionMatch = cookie.match(/better-auth\.session_token=([^;]+)/)
if (!sessionMatch) return null
const sessionToken = decodeURIComponent(sessionMatch[1])
const database = getDb(resolveDatabaseUrl())
// Look up session and get user
const result = await database.execute<{ userId: string }>(
`SELECT "userId" FROM sessions WHERE token = $1 AND "expiresAt" > NOW()`,
[sessionToken]
)
if (!result.rows.length) return null
const user = await database.query.users.findFirst({
where: eq(users.id, result.rows[0].userId),
})
return user
}
export const Route = createFileRoute("/api/stream/settings")({
server: {
handlers: {
// GET - Fetch current user's stream settings
GET: async ({ request }) => {
const user = await getSessionUser(request)
if (!user) {
return json({ error: "Unauthorized" }, 401)
}
const database = getDb(resolveDatabaseUrl())
let stream = await database.query.streams.findFirst({
where: eq(streams.user_id, user.id),
})
// If no stream exists, create one
if (!stream) {
const streamKey = crypto.randomUUID()
const [newStream] = await database
.insert(streams)
.values({
user_id: user.id,
stream_key: streamKey,
title: "Live Stream",
})
.returning()
stream = newStream
}
return json({
id: stream.id,
title: stream.title,
description: stream.description,
cloudflare_live_input_uid: stream.cloudflare_live_input_uid,
cloudflare_customer_code: stream.cloudflare_customer_code,
hls_url: stream.hls_url,
stream_key: stream.stream_key,
})
},
// PUT - Update stream settings
PUT: async ({ request }) => {
const user = await getSessionUser(request)
if (!user) {
return json({ error: "Unauthorized" }, 401)
}
const body = await request.json()
const {
title,
description,
cloudflare_live_input_uid,
cloudflare_customer_code,
} = body
const database = getDb(resolveDatabaseUrl())
// Get or create stream
let stream = await database.query.streams.findFirst({
where: eq(streams.user_id, user.id),
})
if (!stream) {
const streamKey = crypto.randomUUID()
const [newStream] = await database
.insert(streams)
.values({
user_id: user.id,
stream_key: streamKey,
title: title || "Live Stream",
description,
cloudflare_live_input_uid,
cloudflare_customer_code,
})
.returning()
stream = newStream
} else {
// Update existing stream
const [updatedStream] = await database
.update(streams)
.set({
title: title ?? stream.title,
description: description ?? stream.description,
cloudflare_live_input_uid: cloudflare_live_input_uid ?? stream.cloudflare_live_input_uid,
cloudflare_customer_code: cloudflare_customer_code ?? stream.cloudflare_customer_code,
updated_at: new Date(),
})
.where(eq(streams.id, stream.id))
.returning()
stream = updatedStream
}
return json({
success: true,
stream: {
id: stream.id,
title: stream.title,
description: stream.description,
cloudflare_live_input_uid: stream.cloudflare_live_input_uid,
cloudflare_customer_code: stream.cloudflare_customer_code,
},
})
},
},
},
})

View File

@@ -0,0 +1,139 @@
import { createFileRoute } from "@tanstack/react-router"
import { eq } from "drizzle-orm"
import { getDb } from "@/db/connection"
import { users, streams } from "@/db/schema"
const json = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json" },
})
// Default Cloudflare customer subdomain (linsa's account)
const DEFAULT_CLOUDFLARE_CUSTOMER_CODE = "xctsztqzu046isdc"
const getServerEnv = () => {
try {
const { getServerContext } = require("@tanstack/react-start/server") as {
getServerContext: () => { cloudflare?: { env?: Record<string, string> } } | null
}
return getServerContext()?.cloudflare?.env ?? {}
} catch {}
return {}
}
const resolveDatabaseUrl = () => {
const env = getServerEnv()
if (env.DATABASE_URL) return env.DATABASE_URL
if (process.env.DATABASE_URL) return process.env.DATABASE_URL
throw new Error("DATABASE_URL is not configured")
}
// Construct Cloudflare Stream HLS URL from live input UID
const buildCloudflareHlsUrl = (liveInputUid: string, customerCode?: string | null): string => {
const code = customerCode || DEFAULT_CLOUDFLARE_CUSTOMER_CODE
return `https://customer-${code}.cloudflarestream.com/${liveInputUid}/manifest/video.m3u8`
}
// Fallback to env variable for backwards compatibility
const resolveEnvCloudflareHlsUrl = (): string | null => {
const env = getServerEnv()
const liveInputUid = env.CLOUDFLARE_LIVE_INPUT_UID
if (liveInputUid) {
return buildCloudflareHlsUrl(liveInputUid)
}
return null
}
function isHlsPlaylistLive(manifest: string): boolean {
const upper = manifest.toUpperCase()
const hasEndlist = upper.includes("#EXT-X-ENDLIST")
const isVod = upper.includes("#EXT-X-PLAYLIST-TYPE:VOD")
const hasSegments = upper.includes("#EXTINF") || upper.includes("#EXT-X-PART")
const isValidManifest = upper.includes("#EXTM3U")
const isMasterPlaylist = upper.includes("#EXT-X-STREAM-INF")
return isValidManifest && (isMasterPlaylist || (!hasEndlist && !isVod && hasSegments))
}
export const Route = createFileRoute("/api/streams/$username/check-hls")({
server: {
handlers: {
GET: async ({ params }) => {
const { username } = params
if (!username) {
return json({ error: "Username required" }, 400)
}
try {
const database = getDb(resolveDatabaseUrl())
const user = await database.query.users.findFirst({
where: eq(users.username, username),
})
if (!user) {
return json({ error: "User not found", isLive: false }, 404)
}
const stream = await database.query.streams.findFirst({
where: eq(streams.user_id, user.id),
})
// Priority for HLS URL:
// 1. Stream's cloudflare_live_input_uid (per-user Cloudflare Stream)
// 2. Stream's hls_url (manually configured)
// 3. Environment variable (backwards compatibility)
let hlsUrl: string | null = null
if (stream?.cloudflare_live_input_uid) {
hlsUrl = buildCloudflareHlsUrl(
stream.cloudflare_live_input_uid,
stream.cloudflare_customer_code
)
} else if (stream?.hls_url) {
hlsUrl = stream.hls_url
} else {
hlsUrl = resolveEnvCloudflareHlsUrl()
}
if (!hlsUrl) {
return json({
isLive: false,
hlsUrl: null,
error: "No stream configured. Add your Cloudflare Live Input UID in settings.",
})
}
const res = await fetch(hlsUrl, { cache: "no-store" })
if (!res.ok) {
return json({
isLive: false,
hlsUrl,
status: res.status,
error: "HLS not available",
})
}
const manifest = await res.text()
const isLive = isHlsPlaylistLive(manifest)
return json({
isLive,
hlsUrl,
status: res.status,
manifestLength: manifest.length,
})
} catch (err) {
const error = err as Error
console.error("[check-hls] Error:", error.message)
return json({
isLive: false,
error: error.message,
})
}
},
},
},
})

View File

@@ -82,6 +82,10 @@ const serve = async ({
name: user.name,
username: user.username,
image: user.image,
bio: user.bio ?? null,
website: user.website ?? null,
location: user.location ?? null,
joinedAt: user.createdAt?.toISOString() ?? null,
},
stream: stream
? {

View File

@@ -11,9 +11,12 @@ import {
Lock,
MessageCircle,
HelpCircle,
Video,
Copy,
ExternalLink,
} from "lucide-react"
type SectionId = "preferences" | "profile" | "billing"
type SectionId = "preferences" | "profile" | "streaming" | "billing"
const PLAN_CARD_NOISE =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.18'/%3E%3C/svg%3E"
@@ -462,6 +465,214 @@ function ProfileSection({
)
}
function StreamingSection({ username }: { username: string | null | undefined }) {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
// Stream settings
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const [liveInputUid, setLiveInputUid] = useState("")
const [customerCode, setCustomerCode] = useState("")
const [streamKey, setStreamKey] = useState("")
useEffect(() => {
const fetchSettings = async () => {
try {
const res = await fetch("/api/stream/settings", { credentials: "include" })
if (res.ok) {
const data = await res.json()
setTitle(data.title || "")
setDescription(data.description || "")
setLiveInputUid(data.cloudflare_live_input_uid || "")
setCustomerCode(data.cloudflare_customer_code || "")
setStreamKey(data.stream_key || "")
}
} catch {
// Ignore errors
} finally {
setLoading(false)
}
}
fetchSettings()
}, [])
const handleSave = async () => {
setSaving(true)
setError(null)
setSaved(false)
try {
const res = await fetch("/api/stream/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
title,
description,
cloudflare_live_input_uid: liveInputUid || null,
cloudflare_customer_code: customerCode || null,
}),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || "Failed to save")
} else {
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
} catch {
setError("Network error")
} finally {
setSaving(false)
}
}
const copyStreamKey = () => {
navigator.clipboard.writeText(streamKey)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const streamUrl = username ? `https://linsa.io/${username}` : null
return (
<div id="streaming" className="scroll-mt-24">
<SectionHeader
title="Streaming"
description="Configure your live stream settings."
/>
<div className="space-y-5">
{loading ? (
<div className="h-32 bg-white/5 rounded-2xl animate-pulse" />
) : (
<>
<SettingCard title="Stream Info">
<div className="space-y-4 py-2">
<div className="space-y-2">
<label className="text-sm text-white/70">Stream Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="My Live Stream"
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500"
/>
</div>
<div className="space-y-2">
<label className="text-sm text-white/70">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What are you streaming?"
rows={2}
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none"
/>
</div>
</div>
</SettingCard>
<SettingCard title="Cloudflare Stream">
<div className="space-y-4 py-2">
<div className="p-3 bg-teal-500/10 border border-teal-500/20 rounded-lg">
<p className="text-sm text-teal-300">
Enter your Cloudflare Live Input UID to enable automatic stream detection.
Your stream will go live automatically when you start streaming.
</p>
</div>
<div className="space-y-2">
<label className="text-sm text-white/70">Live Input UID</label>
<input
type="text"
value={liveInputUid}
onChange={(e) => setLiveInputUid(e.target.value)}
placeholder="e.g., bb7858eafc85de6c92963f3817477b5d"
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500 font-mono text-sm"
/>
<p className="text-xs text-white/50">
Find this in your Cloudflare Stream dashboard under Live Inputs.
</p>
</div>
<div className="space-y-2">
<label className="text-sm text-white/70">Customer Code (Optional)</label>
<input
type="text"
value={customerCode}
onChange={(e) => setCustomerCode(e.target.value)}
placeholder="Leave empty to use default"
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500 font-mono text-sm"
/>
<p className="text-xs text-white/50">
Only needed if using your own Cloudflare account.
</p>
</div>
</div>
</SettingCard>
<SettingCard title="Your Stream">
<div className="space-y-4 py-2">
{streamUrl && (
<div className="space-y-2">
<label className="text-sm text-white/70">Stream URL</label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-teal-400 text-sm">
{streamUrl}
</code>
<a
href={streamUrl}
target="_blank"
rel="noopener noreferrer"
className="p-2 bg-white/5 hover:bg-white/10 rounded-lg border border-white/10 text-white/70 hover:text-white transition-colors"
>
<ExternalLink className="w-4 h-4" />
</a>
</div>
</div>
)}
{streamKey && (
<div className="space-y-2">
<label className="text-sm text-white/70">Stream Key</label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white/70 text-sm font-mono truncate">
{streamKey.slice(0, 8)}...{streamKey.slice(-4)}
</code>
<button
type="button"
onClick={copyStreamKey}
className="p-2 bg-white/5 hover:bg-white/10 rounded-lg border border-white/10 text-white/70 hover:text-white transition-colors"
>
{copied ? <Check className="w-4 h-4 text-teal-400" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-white/50">
Use this key to stream to Linsa (coming soon).
</p>
</div>
)}
</div>
</SettingCard>
{error && <p className="text-sm text-rose-400">{error}</p>}
<div className="flex justify-end gap-2">
{saved && <span className="text-sm text-teal-400 flex items-center gap-1"><Check className="w-4 h-4" /> Saved</span>}
<button
type="button"
onClick={handleSave}
disabled={saving}
className="px-4 py-2 rounded-lg text-sm font-semibold text-white bg-teal-600 hover:bg-teal-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{saving ? "Saving..." : "Save Settings"}
</button>
</div>
</>
)}
</div>
</div>
)
}
function BillingSection() {
const [isSubscribed, setIsSubscribed] = useState(false)
const [loading, setLoading] = useState(true)
@@ -661,6 +872,8 @@ function SettingsPage() {
onChangeEmail={openEmailModal}
onChangePassword={openPasswordModal}
/>
) : activeSection === "streaming" ? (
<StreamingSection username={session?.user?.username} />
) : activeSection === "billing" ? (
<BillingSection />
) : null}