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"]
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_threads,
sessions,
streams,
users,
verifications,
} from "../src/db/schema"
@@ -116,7 +117,12 @@ async function ensureTables() {
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "emailVerified" boolean DEFAULT false,
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(
sql`UPDATE "users" SET "emailVerified" = COALESCE("emailVerified", "email_verified")`,
@@ -264,6 +270,68 @@ async function ensureTables() {
async function seed() {
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 demoEmail = "demo@ai.chat"
@@ -342,7 +410,7 @@ async function seed() {
seed()
.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) => {
console.error(err)

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}