mirror of
https://github.com/linsa-io/linsa.git
synced 2026-04-26 18:28:35 +02:00
Implement setup task for worker admin environment and add new database schema and snapshot files
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
125
packages/web/src/routes/api/stream-comments.ts
Normal file
125
packages/web/src/routes/api/stream-comments.ts
Normal 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",
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
105
packages/web/src/routes/api/stripe/billing.ts
Normal file
105
packages/web/src/routes/api/stripe/billing.ts
Normal 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",
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
66
packages/web/src/routes/api/stripe/portal.ts
Normal file
66
packages/web/src/routes/api/stripe/portal.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user