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

223
flow.toml
View File

@@ -1935,3 +1935,226 @@ pnpm exec wrangler secret list 2>&1 | grep '"name"' | sed 's/.*"name": "\([^"]*\
''' '''
dependencies = ["pnpm"] dependencies = ["pnpm"]
shortcuts = ["sec"] shortcuts = ["sec"]
# =============================================================================
# Stream & Profile Management
# =============================================================================
[[tasks]]
name = "update-profile"
interactive = true
description = "Update nikiv's profile in production database"
command = '''
set -euo pipefail
cd packages/web
# Load env
if [ -f .env ]; then
export $(grep -E "^PROD_DATABASE_URL=" .env | xargs)
fi
if [ -z "${PROD_DATABASE_URL:-}" ]; then
echo " PROD_DATABASE_URL not set in packages/web/.env"
exit 1
fi
echo "=== Update Profile ==="
echo ""
read -p "Enter username to update [nikiv]: " USERNAME
USERNAME="${USERNAME:-nikiv}"
read -p "Enter bio: " BIO
read -p "Enter website (e.g., nikiv.dev): " WEBSITE
read -p "Enter location (optional): " LOCATION
DATABASE_URL="$PROD_DATABASE_URL" pnpm tsx -e "
const { neon } = require('@neondatabase/serverless');
const sql = neon(process.env.DATABASE_URL);
async function run() {
const bio = process.argv[2] || null;
const website = process.argv[3] || null;
const location = process.argv[4] || null;
const username = process.argv[5];
const result = await sql\`
UPDATE users
SET bio = \${bio}, website = \${website}, location = \${location}, \"updatedAt\" = NOW()
WHERE username = \${username}
RETURNING id, name, username, bio, website, location
\`;
if (result.length === 0) {
console.log('User not found:', username);
return;
}
console.log('✓ Profile updated:');
console.log(JSON.stringify(result[0], null, 2));
}
run().catch(console.error);
" "$BIO" "$WEBSITE" "$LOCATION" "$USERNAME"
'''
dependencies = ["node", "pnpm"]
shortcuts = ["profile"]
[[tasks]]
name = "deploy-all"
description = "Push schema + deploy worker + deploy web"
command = '''
set -euo pipefail
echo "=== Full Deploy ==="
echo ""
cd packages/web
# Load env
if [ -f .env ]; then
export $(grep -E "^PROD_DATABASE_URL=" .env | xargs)
fi
# 1. Push schema
if [ -n "${PROD_DATABASE_URL:-}" ]; then
echo "1/3 Pushing schema..."
DATABASE_URL="$PROD_DATABASE_URL" pnpm drizzle-kit push --force 2>&1 | tail -5
echo "✓ Schema pushed"
else
echo "1/3 Skipping schema push (PROD_DATABASE_URL not set)"
fi
# 2. Deploy worker
echo ""
echo "2/3 Deploying worker..."
cd ../worker
pnpm deploy 2>&1 | tail -5
echo "✓ Worker deployed"
# 3. Deploy web
echo ""
echo "3/3 Deploying web..."
cd ../web
pnpm deploy 2>&1 | tail -10
echo "✓ Web deployed"
echo ""
echo "=== Deploy Complete ==="
'''
dependencies = ["node", "pnpm"]
shortcuts = ["da", "full"]
[[tasks]]
name = "deploy-web"
description = "Deploy web to Cloudflare"
command = '''
cd packages/web
pnpm deploy
'''
dependencies = ["pnpm"]
shortcuts = ["dw"]
[[tasks]]
name = "deploy-worker"
description = "Deploy worker to Cloudflare"
command = '''
cd packages/worker
pnpm deploy
'''
dependencies = ["pnpm"]
shortcuts = ["dwk"]
[[tasks]]
name = "schema-push"
description = "Push Drizzle schema to production (quick)"
command = '''
set -euo pipefail
cd packages/web
if [ -f .env ]; then
export $(grep -E "^PROD_DATABASE_URL=" .env | xargs)
fi
if [ -z "${PROD_DATABASE_URL:-}" ]; then
echo "❌ PROD_DATABASE_URL not set"
exit 1
fi
echo "Pushing schema to production..."
DATABASE_URL="$PROD_DATABASE_URL" pnpm drizzle-kit push --force 2>&1 | tail -5
echo "✓ Done"
'''
dependencies = ["node", "pnpm"]
shortcuts = ["sp", "schema"]
[[tasks]]
name = "stream-secret"
interactive = true
description = "Set Cloudflare stream secret (CLOUDFLARE_LIVE_INPUT_UID)"
command = '''
set -euo pipefail
cd packages/web
echo "=== Set Stream Secret ==="
echo ""
echo "Get your Live Input UID from Cloudflare Stream dashboard:"
echo "https://dash.cloudflare.com/?to=/:account/stream/inputs"
echo ""
read -p "Enter CLOUDFLARE_LIVE_INPUT_UID: " LIVE_INPUT_UID
if [ -n "$LIVE_INPUT_UID" ]; then
echo "$LIVE_INPUT_UID" | pnpm exec wrangler secret put CLOUDFLARE_LIVE_INPUT_UID
echo "✓ CLOUDFLARE_LIVE_INPUT_UID set"
else
echo "Skipped"
fi
'''
dependencies = ["pnpm"]
shortcuts = ["stream"]
[[tasks]]
name = "show-user"
description = "Show user profile from production database"
command = '''
set -euo pipefail
cd packages/web
if [ -f .env ]; then
export $(grep -E "^PROD_DATABASE_URL=" .env | xargs)
fi
if [ -z "${PROD_DATABASE_URL:-}" ]; then
echo "❌ PROD_DATABASE_URL not set"
exit 1
fi
USERNAME="${1:-nikiv}"
DATABASE_URL="$PROD_DATABASE_URL" pnpm tsx -e "
const { neon } = require('@neondatabase/serverless');
const sql = neon(process.env.DATABASE_URL);
async function run() {
const username = process.argv[2] || 'nikiv';
const result = await sql\`
SELECT id, name, email, username, image, bio, website, location, tier, \"createdAt\"
FROM users WHERE username = \${username}
\`;
if (result.length === 0) {
console.log('User not found:', username);
return;
}
console.log(JSON.stringify(result[0], null, 2));
}
run().catch(console.error);
" "$USERNAME"
'''
dependencies = ["node", "pnpm"]
shortcuts = ["user"]

View File

@@ -7,6 +7,7 @@ import {
chat_messages, chat_messages,
chat_threads, chat_threads,
sessions, sessions,
streams,
users, users,
verifications, verifications,
} from "../src/db/schema" } from "../src/db/schema"
@@ -116,7 +117,12 @@ async function ensureTables() {
ALTER TABLE "users" ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "emailVerified" boolean DEFAULT false, ADD COLUMN IF NOT EXISTS "emailVerified" boolean DEFAULT false,
ADD COLUMN IF NOT EXISTS "createdAt" timestamptz DEFAULT now(), ADD COLUMN IF NOT EXISTS "createdAt" timestamptz DEFAULT now(),
ADD COLUMN IF NOT EXISTS "updatedAt" timestamptz DEFAULT now() ADD COLUMN IF NOT EXISTS "updatedAt" timestamptz DEFAULT now(),
ADD COLUMN IF NOT EXISTS "username" text UNIQUE,
ADD COLUMN IF NOT EXISTS "bio" text,
ADD COLUMN IF NOT EXISTS "website" text,
ADD COLUMN IF NOT EXISTS "location" text,
ADD COLUMN IF NOT EXISTS "tier" varchar(32) DEFAULT 'free'
`) `)
await authDb.execute( await authDb.execute(
sql`UPDATE "users" SET "emailVerified" = COALESCE("emailVerified", "email_verified")`, sql`UPDATE "users" SET "emailVerified" = COALESCE("emailVerified", "email_verified")`,
@@ -264,6 +270,68 @@ async function ensureTables() {
async function seed() { async function seed() {
await ensureTables() await ensureTables()
// Create streams table if it doesn't exist
await appDb.execute(sql`
CREATE TABLE IF NOT EXISTS "streams" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"user_id" text NOT NULL REFERENCES "users"("id") ON DELETE cascade,
"title" text NOT NULL DEFAULT 'Live Stream',
"description" text,
"is_live" boolean NOT NULL DEFAULT false,
"viewer_count" integer NOT NULL DEFAULT 0,
"stream_key" text NOT NULL UNIQUE,
"cloudflare_live_input_uid" text,
"cloudflare_customer_code" text,
"hls_url" text,
"webrtc_url" text,
"thumbnail_url" text,
"started_at" timestamptz,
"ended_at" timestamptz,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now()
);
`)
// Add cloudflare columns if they don't exist (for existing tables)
await appDb.execute(sql`
ALTER TABLE "streams"
ADD COLUMN IF NOT EXISTS "cloudflare_live_input_uid" text,
ADD COLUMN IF NOT EXISTS "cloudflare_customer_code" text
`)
// ========== Seed nikiv user ==========
const nikivUserId = "nikiv"
const nikivEmail = "nikita.voloboev@gmail.com"
await authDb
.insert(users)
.values({
id: nikivUserId,
name: "Nikita Voloboev",
email: nikivEmail,
username: "nikiv",
emailVerified: true,
image: "https://nikiv.dev/nikiv.jpg",
bio: "Building in public. Making tools I want to exist.",
website: "nikiv.dev",
createdAt: new Date(),
updatedAt: new Date(),
})
.onConflictDoNothing({ target: users.id })
// Create stream for nikiv (HLS URL will come from env variable fallback)
const nikivStreamKey = crypto.randomUUID()
await appDb
.insert(streams)
.values({
user_id: nikivUserId,
title: "Live Coding",
description: "Building in public",
stream_key: nikivStreamKey,
})
.onConflictDoNothing()
// ========== Seed demo user ==========
const demoUserId = "demo-user" const demoUserId = "demo-user"
const demoEmail = "demo@ai.chat" const demoEmail = "demo@ai.chat"
@@ -342,7 +410,7 @@ async function seed() {
seed() seed()
.then(() => { .then(() => {
console.log("Seed complete: demo user and chat thread ready.") console.log("Seed complete: nikiv user, stream, demo user, and chat thread ready.")
}) })
.catch((err) => { .catch((err) => {
console.error(err) console.error(err)

View File

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

View File

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

View File

@@ -25,6 +25,10 @@ export const users = pgTable("users", {
.$defaultFn(() => false) .$defaultFn(() => false)
.notNull(), .notNull(),
image: text("image"), image: text("image"),
// Profile fields
bio: text("bio"),
website: text("website"),
location: text("location"),
// Access tiers: 'free' | 'creator' | 'dev' - determines feature access // Access tiers: 'free' | 'creator' | 'dev' - determines feature access
tier: varchar("tier", { length: 32 }).notNull().default("free"), tier: varchar("tier", { length: 32 }).notNull().default("free"),
createdAt: timestamp("createdAt") createdAt: timestamp("createdAt")
@@ -247,7 +251,10 @@ export const streams = pgTable("streams", {
is_live: boolean("is_live").notNull().default(false), is_live: boolean("is_live").notNull().default(false),
viewer_count: integer("viewer_count").notNull().default(0), viewer_count: integer("viewer_count").notNull().default(0),
stream_key: text("stream_key").notNull().unique(), // secret key for streaming 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 hls_url: text("hls_url"), // HLS playback URL
webrtc_url: text("webrtc_url"), // WebRTC playback URL webrtc_url: text("webrtc_url"), // WebRTC playback URL
thumbnail_url: text("thumbnail_url"), thumbnail_url: text("thumbnail_url"),

View File

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

View File

@@ -2,14 +2,9 @@ import { useEffect, useRef, useState } from "react"
import { createFileRoute, Link } from "@tanstack/react-router" import { createFileRoute, Link } from "@tanstack/react-router"
import { getStreamByUsername, type StreamPageData } from "@/lib/stream/db" import { getStreamByUsername, type StreamPageData } from "@/lib/stream/db"
import { VideoPlayer } from "@/components/VideoPlayer" import { VideoPlayer } from "@/components/VideoPlayer"
import { resolveStreamPlayback } from "@/lib/stream/playback"
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 {
getSpotifyNowPlaying,
type SpotifyNowPlayingResponse,
} from "@/lib/spotify/now-playing"
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"
@@ -20,34 +15,6 @@ export const Route = createFileRoute("/$username")({
const READY_PULSE_MS = 1200 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() { function StreamPage() {
const { username } = Route.useParams() const { username } = Route.useParams()
const { data: session } = authClient.useSession() const { data: session } = authClient.useSession()
@@ -58,32 +25,20 @@ function StreamPage() {
const [hlsLive, setHlsLive] = useState<boolean | null>(null) const [hlsLive, setHlsLive] = useState<boolean | null>(null)
const [hlsUrl, setHlsUrl] = useState<string | null>(null) const [hlsUrl, setHlsUrl] = useState<string | null>(null)
const [isConnecting, setIsConnecting] = useState(false) 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 [showReadyPulse, setShowReadyPulse] = useState(false)
const readyPulseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const readyPulseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const hasConnectedOnce = useRef(false) const hasConnectedOnce = useRef(false)
// Mobile chat overlay // Mobile overlays
const [showMobileChat, setShowMobileChat] = useState(false) const [showMobileChat, setShowMobileChat] = useState(false)
const [showMobileProfile, setShowMobileProfile] = useState(false)
const isAuthenticated = !!session?.user const isAuthenticated = !!session?.user
// Fetch user and stream data from API
useEffect(() => { useEffect(() => {
let isActive = true 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 () => { const loadData = async () => {
if (!isActive) return if (!isActive) return
setLoading(true) setLoading(true)
@@ -92,6 +47,9 @@ function StreamPage() {
const result = await getStreamByUsername(username) const result = await getStreamByUsername(username)
if (isActive) { if (isActive) {
setData(result) setData(result)
if (result?.stream?.hls_url) {
setHlsUrl(result.stream.hls_url)
}
} }
} catch (err) { } catch (err) {
if (isActive) { if (isActive) {
@@ -111,34 +69,7 @@ function StreamPage() {
} }
}, [username]) }, [username])
// Poll stream status for nikiv from nikiv.dev/api/stream-status // Ready pulse animation
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])
useEffect(() => { useEffect(() => {
if (readyPulseTimeoutRef.current) { if (readyPulseTimeoutRef.current) {
clearTimeout(readyPulseTimeoutRef.current) clearTimeout(readyPulseTimeoutRef.current)
@@ -165,218 +96,64 @@ function StreamPage() {
}, [playerReady]) }, [playerReady])
const stream = data?.stream ?? null const stream = data?.stream ?? null
// For nikiv, always use HLS directly (no WebRTC) - URL comes from API const activePlayback = hlsUrl
const activePlayback = username === "nikiv" && hlsUrl
? { type: "hls" as const, url: hlsUrl } ? { type: "hls" as const, url: hlsUrl }
: stream?.playback ?? null : stream?.playback ?? null
const isHlsPlaylistLive = (manifest: string) => { // Poll HLS status via server-side API (avoids CORS issues)
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)
useEffect(() => { useEffect(() => {
if (username !== "nikiv") return if (!data?.user) return
let isActive = true let isActive = true
let isFirstCheck = true
const checkHlsViaApi = async () => { const checkHls = async () => {
try { 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 if (!isActive) return
const apiData = await res.json() 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) { if (apiData.hlsUrl && apiData.hlsUrl !== hlsUrl) {
setHlsUrl(apiData.hlsUrl) setHlsUrl(apiData.hlsUrl)
setData(makeNikivData(apiData.hlsUrl))
} }
if (apiData.isLive) { if (apiData.isLive) {
// Stream is live - set connecting state if first time
if (!hasConnectedOnce.current) { if (!hasConnectedOnce.current) {
setIsConnecting(true) setIsConnecting(true)
} }
setHlsLive(true) setHlsLive(true)
} else { } else {
// Only set offline if we haven't connected yet
// This prevents flickering when HLS check temporarily fails
if (!hasConnectedOnce.current) { if (!hasConnectedOnce.current) {
setHlsLive(false) setHlsLive(false)
} }
} }
} catch { } catch {
// Silently ignore errors - don't change state on network issues // Silently ignore errors
} finally {
// Mark loading as done after first check completes
if (isActive && isFirstCheck) {
isFirstCheck = false
setLoading(false)
}
} }
} }
// Initial check
setHlsLive(null) setHlsLive(null)
checkHlsViaApi() checkHls()
// Poll every 5 seconds to detect when stream goes live // Poll every 5 seconds
const interval = setInterval(checkHlsViaApi, 5000) const interval = setInterval(checkHls, 5000)
return () => { return () => {
isActive = false isActive = false
clearInterval(interval) clearInterval(interval)
} }
}, [username, hlsUrl]) }, [data?.user, username, hlsUrl])
// For non-nikiv users, use direct HLS check // Determine if stream is actually live
useEffect(() => { const isActuallyLive = hlsLive === true || Boolean(stream?.is_live)
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])
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">
<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> </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 = () => { const handlePlayerReady = () => {
hasConnectedOnce.current = true hasConnectedOnce.current = true
setIsConnecting(false) setIsConnecting(false)
setPlayerReady(true) setPlayerReady(true)
} }
// Show loading state during initial check
const isChecking = hlsLive === null 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 ( return (
<JazzProvider> <JazzProvider>
<LiveNowSidebar currentUsername={username} />
<div className="h-screen w-screen bg-black flex flex-col md:flex-row"> <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"> <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 ? ( {isChecking ? (
<div className="flex h-full w-full flex-col items-center justify-center text-white"> <div className="flex h-full w-full flex-col items-center justify-center text-white">
<div className="relative"> <div className="relative">
@@ -448,14 +215,12 @@ function StreamPage() {
<p className="mt-6 text-lg text-neutral-400">Checking stream status...</p> <p className="mt-6 text-lg text-neutral-400">Checking stream status...</p>
</div> </div>
) : isActuallyLive && activePlayback ? ( ) : isActuallyLive && activePlayback ? (
/* Stream is live - show the player */
<div className="relative h-full w-full"> <div className="relative h-full w-full">
<VideoPlayer <VideoPlayer
src={activePlayback.url} src={activePlayback.url}
muted={false} muted={false}
onReady={handlePlayerReady} onReady={handlePlayerReady}
/> />
{/* Loading overlay while connecting */}
{(isConnecting || !playerReady) && ( {(isConnecting || !playerReady) && (
<div className="pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/80"> <div className="pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/80">
<div className="relative"> <div className="relative">
@@ -464,7 +229,6 @@ function StreamPage() {
<p className="mt-6 text-lg text-white">Connecting to stream...</p> <p className="mt-6 text-lg text-white">Connecting to stream...</p>
</div> </div>
)} )}
{/* Ready pulse */}
{showReadyPulse && ( {showReadyPulse && (
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center"> <div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<div className="animate-pulse text-4xl">🔴</div> <div className="animate-pulse text-4xl">🔴</div>
@@ -472,76 +236,43 @@ function StreamPage() {
)} )}
</div> </div>
) : ( ) : (
/* Stream is offline */
<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 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="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">
<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" />
<span className="h-2 w-2 rounded-full bg-neutral-500" /> Offline
Offline </div>
</div> <p className="mt-6 text-2xl md:text-3xl font-semibold">
<p className="mt-6 text-2xl md:text-3xl font-semibold"> Not live right now
Not live right now </p>
</p> {profileUser.website && (
<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>
<a <a
href="https://nikiv.dev" href={profileUser.website.startsWith("http") ? profileUser.website : `https://${profileUser.website}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="mt-8 text-2xl md:text-3xl font-medium text-white hover:text-neutral-300 transition-colors" 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> </a>
</div> )}
) : ( </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> </div>
{/* Desktop Chat sidebar */} {/* Desktop Profile Sidebar with Chat */}
<div className="hidden md:block w-80 h-full border-l border-white/10 flex-shrink-0"> <div className="hidden md:flex w-96 h-full flex-shrink-0">
<CommentBox username={username} /> <ProfileSidebar
user={profileUser}
isLive={isActuallyLive}
viewerCount={stream?.viewer_count ?? 0}
>
<CommentBox username={username} />
</ProfileSidebar>
</div> </div>
{/* Mobile bottom bar */} {/* 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 && ( {!isAuthenticated && (
<Link <Link
to="/auth" to="/auth"
@@ -559,6 +290,14 @@ function StreamPage() {
<MessageCircle className="w-4 h-4" /> <MessageCircle className="w-4 h-4" />
Chat Chat
</button> </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> </div>
{/* Mobile chat overlay */} {/* Mobile chat overlay */}
@@ -579,6 +318,29 @@ function StreamPage() {
</div> </div>
</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> </div>
</JazzProvider> </JazzProvider>
) )

View File

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

View File

@@ -11,9 +11,12 @@ import {
Lock, Lock,
MessageCircle, MessageCircle,
HelpCircle, HelpCircle,
Video,
Copy,
ExternalLink,
} from "lucide-react" } from "lucide-react"
type SectionId = "preferences" | "profile" | "billing" type SectionId = "preferences" | "profile" | "streaming" | "billing"
const PLAN_CARD_NOISE = 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" "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() { function BillingSection() {
const [isSubscribed, setIsSubscribed] = useState(false) const [isSubscribed, setIsSubscribed] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -661,6 +872,8 @@ function SettingsPage() {
onChangeEmail={openEmailModal} onChangeEmail={openEmailModal}
onChangePassword={openPasswordModal} onChangePassword={openPasswordModal}
/> />
) : activeSection === "streaming" ? (
<StreamingSection username={session?.user?.username} />
) : activeSection === "billing" ? ( ) : activeSection === "billing" ? (
<BillingSection /> <BillingSection />
) : null} ) : null}