Implement setup task for worker admin environment and add new database schema and snapshot files

This commit is contained in:
Nikita
2025-12-24 16:31:01 -08:00
parent 26fa0b0ec9
commit cf4a43779e
19 changed files with 3015 additions and 89 deletions

View File

@@ -7,6 +7,7 @@ import { WebRTCPlayer } from "@/components/WebRTCPlayer"
import { resolveStreamPlayback } from "@/lib/stream/playback"
import { JazzProvider } from "@/lib/jazz/provider"
import { ViewerCount } from "@/components/ViewerCount"
import { CommentBox } from "@/components/CommentBox"
import {
getSpotifyNowPlaying,
type SpotifyNowPlayingResponse,
@@ -359,13 +360,15 @@ function StreamPage() {
return (
<JazzProvider>
<div className="h-screen w-screen bg-black">
{/* Viewer count overlay */}
<div className="absolute top-4 right-4 z-10 rounded-lg bg-black/50 px-3 py-2 backdrop-blur-sm">
<ViewerCount username={username} />
</div>
<div className="h-screen w-screen bg-black flex">
{/* Main content area */}
<div className="flex-1 relative">
{/* Viewer count overlay */}
<div className="absolute top-4 right-4 z-10 rounded-lg bg-black/50 px-3 py-2 backdrop-blur-sm">
<ViewerCount username={username} />
</div>
{isActuallyLive && activePlayback && showPlayer ? (
{isActuallyLive && activePlayback && showPlayer ? (
activePlayback.type === "webrtc" ? (
<div className="relative h-full w-full">
<WebRTCPlayer
@@ -486,6 +489,12 @@ function StreamPage() {
)}
</div>
)}
</div>
{/* Chat sidebar */}
<div className="w-80 h-full border-l border-white/10 flex-shrink-0">
<CommentBox username={username} />
</div>
</div>
</JazzProvider>
)

View File

@@ -0,0 +1,125 @@
import { createFileRoute } from "@tanstack/react-router"
import { getAuth } from "@/lib/auth"
import { db } from "@/db/connection"
import { stream_comments, users } from "@/db/schema"
import { eq } from "drizzle-orm"
export const Route = createFileRoute("/api/stream-comments")({
server: {
handlers: {
GET: async ({ request }) => {
const url = new URL(request.url)
const username = url.searchParams.get("username")
if (!username) {
return new Response(JSON.stringify({ error: "username is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
try {
const database = db()
const comments = await database
.select({
id: stream_comments.id,
user_id: stream_comments.user_id,
user_name: users.name,
user_email: users.email,
content: stream_comments.content,
created_at: stream_comments.created_at,
})
.from(stream_comments)
.leftJoin(users, eq(stream_comments.user_id, users.id))
.where(eq(stream_comments.stream_username, username))
.orderBy(stream_comments.created_at)
.limit(100)
return new Response(JSON.stringify({ comments }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
} catch (err) {
console.error("[stream-comments] GET error:", err)
return new Response(JSON.stringify({ error: "Failed to fetch comments" }), {
status: 500,
headers: { "Content-Type": "application/json" },
})
}
},
POST: async ({ request }) => {
const session = await getAuth().api.getSession({ headers: request.headers })
if (!session?.user?.id) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
try {
const body = await request.json()
const { username, content } = body as { username?: string; content?: string }
if (!username || !content?.trim()) {
return new Response(JSON.stringify({ error: "username and content are required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
})
}
const database = db()
const [newComment] = await database
.insert(stream_comments)
.values({
stream_username: username,
user_id: session.user.id,
content: content.trim(),
})
.returning()
// Get user info for response
const [user] = await database
.select({ name: users.name, email: users.email })
.from(users)
.where(eq(users.id, session.user.id))
.limit(1)
return new Response(
JSON.stringify({
comment: {
id: newComment.id,
user_id: newComment.user_id,
user_name: user?.name || "Anonymous",
user_email: user?.email || "",
content: newComment.content,
created_at: newComment.created_at,
},
}),
{
status: 201,
headers: { "Content-Type": "application/json" },
}
)
} catch (err) {
console.error("[stream-comments] POST error:", err)
return new Response(JSON.stringify({ error: "Failed to post comment" }), {
status: 500,
headers: { "Content-Type": "application/json" },
})
}
},
OPTIONS: () => {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
})
},
},
},
})

View File

@@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router"
import { and, eq } from "drizzle-orm"
import { db } from "@/db/connection"
import { getAuth } from "@/lib/auth"
import { hasActiveSubscription } from "@/lib/billing"
import { stream_replays, streams } from "@/db/schema"
const json = (data: unknown, status = 200) =>
@@ -80,6 +81,8 @@ const handleGet = async ({
params: { replayId: string }
}) => {
const database = db()
const auth = getAuth()
const session = await auth.api.getSession({ headers: request.headers })
const replay = await database.query.stream_replays.findFirst({
where: eq(stream_replays.id, params.replayId),
@@ -89,8 +92,31 @@ const handleGet = async ({
return json({ error: "Replay not found" }, 404)
}
const isOwner = await canAccessReplay(request, replay.user_id)
if (!isOwner && (!replay.is_public || replay.status !== "ready")) {
const isOwner = session?.user?.id === replay.user_id
// Owners can always view their own replays
if (isOwner) {
return json({ replay })
}
// Non-owners need subscription to view replays
if (!session?.user?.id) {
return json(
{ error: "Subscription required", code: "SUBSCRIPTION_REQUIRED" },
403
)
}
const hasSubscription = await hasActiveSubscription(session.user.id)
if (!hasSubscription) {
return json(
{ error: "Subscription required", code: "SUBSCRIPTION_REQUIRED" },
403
)
}
// With subscription, can view public ready replays
if (!replay.is_public || replay.status !== "ready") {
return json({ error: "Forbidden" }, 403)
}

View File

@@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router"
import { and, desc, eq } from "drizzle-orm"
import { db } from "@/db/connection"
import { getAuth } from "@/lib/auth"
import { hasActiveSubscription } from "@/lib/billing"
import { stream_replays, users } from "@/db/schema"
const json = (data: unknown, status = 200) =>
@@ -37,17 +38,52 @@ const handleGet = async ({
const session = await auth.api.getSession({ headers: request.headers })
const isOwner = session?.user?.id === user.id
const conditions = [eq(stream_replays.user_id, user.id)]
if (!isOwner) {
conditions.push(eq(stream_replays.is_public, true))
conditions.push(eq(stream_replays.status, "ready"))
// Owners can always see their own replays
if (isOwner) {
try {
const replays = await database
.select()
.from(stream_replays)
.where(eq(stream_replays.user_id, user.id))
.orderBy(
desc(stream_replays.started_at),
desc(stream_replays.created_at)
)
return json({ replays })
} catch (error) {
console.error("[stream-replays] Error fetching replays:", error)
return json({ error: "Failed to fetch replays" }, 500)
}
}
// Non-owners need subscription to view replays
if (!session?.user?.id) {
return json(
{ error: "Subscription required", code: "SUBSCRIPTION_REQUIRED" },
403
)
}
const hasSubscription = await hasActiveSubscription(session.user.id)
if (!hasSubscription) {
return json(
{ error: "Subscription required", code: "SUBSCRIPTION_REQUIRED" },
403
)
}
// With subscription, can view public ready replays
try {
const replays = await database
.select()
.from(stream_replays)
.where(and(...conditions))
.where(
and(
eq(stream_replays.user_id, user.id),
eq(stream_replays.is_public, true),
eq(stream_replays.status, "ready")
)
)
.orderBy(desc(stream_replays.started_at), desc(stream_replays.created_at))
return json({ replays })

View File

@@ -0,0 +1,105 @@
import { createFileRoute } from "@tanstack/react-router"
import { getAuth } from "@/lib/auth"
import { db } from "@/db/connection"
import { stripe_subscriptions, storage_usage } from "@/db/schema"
import { eq, and, gte, lte } from "drizzle-orm"
const json = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json" },
})
export const Route = createFileRoute("/api/stripe/billing")({
server: {
handlers: {
GET: async ({ request }) => {
const session = await getAuth().api.getSession({
headers: request.headers,
})
// Guest user
if (!session?.user?.id) {
return json({
isGuest: true,
isPaid: false,
planName: "Guest",
})
}
const database = db()
try {
// Check for active subscription
const [subscription] = await database
.select()
.from(stripe_subscriptions)
.where(
and(
eq(stripe_subscriptions.user_id, session.user.id),
eq(stripe_subscriptions.status, "active")
)
)
.limit(1)
if (subscription) {
// Get usage for current billing period
const now = new Date()
const [usage] = await database
.select()
.from(storage_usage)
.where(
and(
eq(storage_usage.user_id, session.user.id),
lte(storage_usage.period_start, now),
gte(storage_usage.period_end, now)
)
)
.limit(1)
return json({
isGuest: false,
isPaid: true,
planName: "Archive Pro",
usage: {
archives: {
used: usage?.archives_used ?? 0,
limit: usage?.archives_limit ?? 10,
remaining: Math.max(
0,
(usage?.archives_limit ?? 10) - (usage?.archives_used ?? 0)
),
},
storage: {
used: usage?.storage_bytes_used ?? 0,
limit: usage?.storage_bytes_limit ?? 1073741824,
remaining: Math.max(
0,
(usage?.storage_bytes_limit ?? 1073741824) -
(usage?.storage_bytes_used ?? 0)
),
},
},
currentPeriodEnd: subscription.current_period_end,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
})
}
// Free authenticated user
return json({
isGuest: false,
isPaid: false,
planName: "Free",
})
} catch (error) {
console.error("[billing] Error getting status:", error)
return json({
isGuest: false,
isPaid: false,
planName: "Free",
})
}
},
},
},
})

View File

@@ -0,0 +1,66 @@
import { createFileRoute } from "@tanstack/react-router"
import { getAuth } from "@/lib/auth"
import { getStripe } from "@/lib/stripe"
import { db } from "@/db/connection"
import { stripe_customers } from "@/db/schema"
import { eq } from "drizzle-orm"
const json = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json" },
})
export const Route = createFileRoute("/api/stripe/portal")({
server: {
handlers: {
POST: async ({ request }) => {
const session = await getAuth().api.getSession({
headers: request.headers,
})
if (!session?.user?.id) {
return json({ error: "Unauthorized" }, 401)
}
const stripe = getStripe()
if (!stripe) {
return json({ error: "Stripe not configured" }, 500)
}
const database = db()
try {
// Get Stripe customer
const [customer] = await database
.select()
.from(stripe_customers)
.where(eq(stripe_customers.user_id, session.user.id))
.limit(1)
if (!customer) {
return json({ error: "No billing account found" }, 404)
}
// Parse request body for return URL
const body = (await request.json().catch(() => ({}))) as {
returnUrl?: string
}
const origin = new URL(request.url).origin
const returnUrl = body.returnUrl ?? `${origin}/archive`
// Create portal session
const portalSession = await stripe.billingPortal.sessions.create({
customer: customer.stripe_customer_id,
return_url: returnUrl,
})
return json({ url: portalSession.url })
} catch (error) {
console.error("[stripe] Portal error:", error)
return json({ error: "Failed to create portal session" }, 500)
}
},
},
},
})

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from "react"
import { createFileRoute } from "@tanstack/react-router"
import { Mail, Apple, Github } from "lucide-react"
import { Mail } from "lucide-react"
import { authClient } from "@/lib/auth-client"
export const Route = createFileRoute("/auth")({
@@ -10,29 +10,6 @@ export const Route = createFileRoute("/auth")({
type Step = "email" | "otp"
function ChromeIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="4" />
<line x1="21.17" y1="8" x2="12" y2="8" />
<line x1="3.95" y1="6.06" x2="8.54" y2="14" />
<line x1="10.88" y1="21.94" x2="15.46" y2="14" />
</svg>
)
}
function AuthPage() {
const [step, setStep] = useState<Step>("email")
const emailInputRef = useRef<HTMLInputElement>(null)
@@ -252,38 +229,6 @@ function AuthPage() {
</div>
</form>
)}
<div className="mt-8 border-t border-white/10 pt-6">
<p className="text-xs uppercase tracking-[0.3em] text-white/40">
Coming soon
</p>
<div className="mt-4 grid grid-cols-3 gap-3">
<button
type="button"
disabled
className="flex items-center justify-center gap-2 rounded-2xl border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium text-white/70 transition hover:bg-white/10 disabled:cursor-not-allowed"
>
<Apple className="h-4 w-4" aria-hidden="true" />
Apple
</button>
<button
type="button"
disabled
className="flex items-center justify-center gap-2 rounded-2xl border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium text-white/70 transition hover:bg-white/10 disabled:cursor-not-allowed"
>
<ChromeIcon className="h-4 w-4" />
Google
</button>
<button
type="button"
disabled
className="flex items-center justify-center gap-2 rounded-2xl border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium text-white/70 transition hover:bg-white/10 disabled:cursor-not-allowed"
>
<Github className="h-4 w-4" aria-hidden="true" />
GitHub
</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { createFileRoute } from "@tanstack/react-router"
import { createFileRoute, Link } from "@tanstack/react-router"
import { ShaderBackground } from "@/components/ShaderBackground"
const galleryItems = [
@@ -25,6 +25,12 @@ function LandingPage() {
<p className="mt-4 text-xl text-white/80 drop-shadow-lg">
Save anything privately. Share it.
</p>
<Link
to="/auth"
className="mt-8 rounded-full bg-white px-8 py-3 text-lg font-semibold text-black transition-all hover:bg-white/90 hover:scale-105"
>
Sign up
</Link>
<div className="mt-6 flex items-center gap-4">
<a
href="https://x.com/linsa_io"