mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-11 20:00:23 +01:00
Implement setup task for worker admin environment and add new database schema and snapshot files
This commit is contained in:
109
packages/web/drizzle/0005_outgoing_proteus.sql
Normal file
109
packages/web/drizzle/0005_outgoing_proteus.sql
Normal file
@@ -0,0 +1,109 @@
|
||||
CREATE TABLE "archives" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"description" text,
|
||||
"type" varchar(32) NOT NULL,
|
||||
"content_url" text,
|
||||
"content_text" text,
|
||||
"thumbnail_url" text,
|
||||
"file_size_bytes" integer DEFAULT 0,
|
||||
"duration_seconds" integer,
|
||||
"mime_type" varchar(128),
|
||||
"is_public" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "storage_usage" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "storage_usage_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"user_id" text NOT NULL,
|
||||
"archives_used" integer DEFAULT 0 NOT NULL,
|
||||
"archives_limit" integer DEFAULT 10 NOT NULL,
|
||||
"storage_bytes_used" integer DEFAULT 0 NOT NULL,
|
||||
"storage_bytes_limit" integer DEFAULT 1073741824 NOT NULL,
|
||||
"period_start" timestamp with time zone NOT NULL,
|
||||
"period_end" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "stream_comments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"stream_username" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "stream_replays" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"stream_id" uuid NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"title" text DEFAULT 'Stream Replay' NOT NULL,
|
||||
"description" text,
|
||||
"status" varchar(32) DEFAULT 'processing' NOT NULL,
|
||||
"jazz_replay_id" text,
|
||||
"playback_url" text,
|
||||
"thumbnail_url" text,
|
||||
"duration_seconds" integer,
|
||||
"started_at" timestamp with time zone,
|
||||
"ended_at" timestamp with time zone,
|
||||
"is_public" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "streams" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"title" text DEFAULT 'Live Stream' NOT NULL,
|
||||
"description" text,
|
||||
"is_live" boolean DEFAULT false NOT NULL,
|
||||
"viewer_count" integer DEFAULT 0 NOT NULL,
|
||||
"stream_key" text NOT NULL,
|
||||
"hls_url" text,
|
||||
"webrtc_url" text,
|
||||
"thumbnail_url" text,
|
||||
"started_at" timestamp with time zone,
|
||||
"ended_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "streams_stream_key_unique" UNIQUE("stream_key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "stripe_customers" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "stripe_customers_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"user_id" text NOT NULL,
|
||||
"stripe_customer_id" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "stripe_customers_user_id_unique" UNIQUE("user_id"),
|
||||
CONSTRAINT "stripe_customers_stripe_customer_id_unique" UNIQUE("stripe_customer_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "stripe_subscriptions" (
|
||||
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "stripe_subscriptions_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"user_id" text NOT NULL,
|
||||
"stripe_subscription_id" text NOT NULL,
|
||||
"stripe_customer_id" text NOT NULL,
|
||||
"stripe_price_id" text NOT NULL,
|
||||
"status" varchar(32) NOT NULL,
|
||||
"current_period_start" timestamp with time zone,
|
||||
"current_period_end" timestamp with time zone,
|
||||
"cancel_at_period_end" boolean DEFAULT false,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "stripe_subscriptions_stripe_subscription_id_unique" UNIQUE("stripe_subscription_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "username" text;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "tier" varchar(32) DEFAULT 'free' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "archives" ADD CONSTRAINT "archives_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "storage_usage" ADD CONSTRAINT "storage_usage_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "stream_comments" ADD CONSTRAINT "stream_comments_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "stream_replays" ADD CONSTRAINT "stream_replays_stream_id_streams_id_fk" FOREIGN KEY ("stream_id") REFERENCES "public"."streams"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "stream_replays" ADD CONSTRAINT "stream_replays_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "streams" ADD CONSTRAINT "streams_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "stripe_customers" ADD CONSTRAINT "stripe_customers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "stripe_subscriptions" ADD CONSTRAINT "stripe_subscriptions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_username_unique" UNIQUE("username");
|
||||
1811
packages/web/drizzle/meta/0005_snapshot.json
Normal file
1811
packages/web/drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,13 @@
|
||||
"when": 1769000000000,
|
||||
"tag": "0004_add_stream_replays",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1766620803496,
|
||||
"tag": "0005_outgoing_proteus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,177 @@
|
||||
import { FlowgladProvider } from "@flowglad/react"
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
type UsageSnapshot = {
|
||||
used: number
|
||||
limit: number
|
||||
remaining: number
|
||||
}
|
||||
|
||||
type StorageUsage = {
|
||||
archives?: UsageSnapshot
|
||||
storage?: UsageSnapshot
|
||||
}
|
||||
|
||||
type BillingStatus = {
|
||||
isGuest: boolean
|
||||
isPaid: boolean
|
||||
planName: string
|
||||
currentPeriodEnd?: string
|
||||
cancelAtPeriodEnd?: boolean
|
||||
isLoading: boolean
|
||||
error?: string
|
||||
usage?: StorageUsage
|
||||
}
|
||||
|
||||
type BillingContextValue = BillingStatus & {
|
||||
refresh: () => Promise<void>
|
||||
openCheckout: () => Promise<void>
|
||||
openPortal: () => Promise<void>
|
||||
}
|
||||
|
||||
const BillingContext = createContext<BillingContextValue | null>(null)
|
||||
|
||||
export function useBilling() {
|
||||
const context = useContext(BillingContext)
|
||||
if (!context) {
|
||||
return {
|
||||
isGuest: true,
|
||||
isPaid: false,
|
||||
planName: "Guest",
|
||||
isLoading: false,
|
||||
usage: undefined,
|
||||
refresh: async () => {},
|
||||
openCheckout: async () => {},
|
||||
openPortal: async () => {},
|
||||
} as BillingContextValue
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
type BillingProviderProps = {
|
||||
children: React.ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function BillingProvider({ children }: BillingProviderProps) {
|
||||
const flowgladEnabled = import.meta.env.VITE_FLOWGLAD_ENABLED === "true"
|
||||
|
||||
// Skip billing entirely when Flowglad isn't configured
|
||||
if (!flowgladEnabled) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
const { data: session, isPending } = authClient.useSession()
|
||||
const [status, setStatus] = useState<BillingStatus>({
|
||||
isGuest: true,
|
||||
isPaid: false,
|
||||
planName: "Guest",
|
||||
isLoading: true,
|
||||
usage: undefined,
|
||||
})
|
||||
|
||||
// Don't load billing until we know auth state
|
||||
if (isPending) {
|
||||
return <>{children}</>
|
||||
const fetchBillingStatus = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/stripe/billing")
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as Partial<BillingStatus>
|
||||
setStatus({
|
||||
isGuest: data.isGuest ?? true,
|
||||
isPaid: data.isPaid ?? false,
|
||||
planName: data.planName ?? "Guest",
|
||||
usage: data.usage,
|
||||
currentPeriodEnd: data.currentPeriodEnd,
|
||||
cancelAtPeriodEnd: data.cancelAtPeriodEnd,
|
||||
isLoading: false,
|
||||
})
|
||||
} else {
|
||||
setStatus((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: "Failed to load billing status",
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[billing] Failed to fetch status:", error)
|
||||
setStatus((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: "Failed to load billing status",
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) return
|
||||
|
||||
if (!session?.user) {
|
||||
setStatus({
|
||||
isGuest: true,
|
||||
isPaid: false,
|
||||
planName: "Guest",
|
||||
isLoading: false,
|
||||
usage: undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
fetchBillingStatus()
|
||||
}, [session?.user, isPending, fetchBillingStatus])
|
||||
|
||||
const openCheckout = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/stripe/checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
successUrl: `${window.location.origin}/archive?billing=success`,
|
||||
cancelUrl: `${window.location.origin}/archive?billing=canceled`,
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as { url?: string }
|
||||
if (data.url) {
|
||||
window.location.href = data.url
|
||||
}
|
||||
} else {
|
||||
console.error("[billing] Failed to create checkout session")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[billing] Checkout error:", error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const openPortal = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/stripe/portal", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
returnUrl: window.location.href,
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as { url?: string }
|
||||
if (data.url) {
|
||||
window.location.href = data.url
|
||||
}
|
||||
} else {
|
||||
console.error("[billing] Failed to create portal session")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[billing] Portal error:", error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const value: BillingContextValue = {
|
||||
...status,
|
||||
refresh: fetchBillingStatus,
|
||||
openCheckout,
|
||||
openPortal,
|
||||
}
|
||||
|
||||
return (
|
||||
<FlowgladProvider loadBilling={!!session?.user} serverRoute="/api/flowglad">
|
||||
{children}
|
||||
</FlowgladProvider>
|
||||
<BillingContext.Provider value={value}>{children}</BillingContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
312
packages/web/src/components/CommentBox.tsx
Normal file
312
packages/web/src/components/CommentBox.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { Send, LogIn } from "lucide-react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
type Comment = {
|
||||
id: string
|
||||
user_id: string
|
||||
user_name: string
|
||||
user_email: string
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
type AuthStep = "idle" | "email" | "otp"
|
||||
|
||||
interface CommentBoxProps {
|
||||
username: string
|
||||
}
|
||||
|
||||
export function CommentBox({ username }: CommentBoxProps) {
|
||||
const { data: session, isPending: sessionLoading } = authClient.useSession()
|
||||
const [comments, setComments] = useState<Comment[]>([])
|
||||
const [newComment, setNewComment] = useState("")
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Auth state
|
||||
const [authStep, setAuthStep] = useState<AuthStep>("idle")
|
||||
const [email, setEmail] = useState("")
|
||||
const [otp, setOtp] = useState("")
|
||||
const [authLoading, setAuthLoading] = useState(false)
|
||||
const [authError, setAuthError] = useState("")
|
||||
|
||||
const commentsEndRef = useRef<HTMLDivElement>(null)
|
||||
const emailInputRef = useRef<HTMLInputElement>(null)
|
||||
const otpInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Focus inputs when auth step changes
|
||||
useEffect(() => {
|
||||
if (authStep === "email") {
|
||||
emailInputRef.current?.focus()
|
||||
} else if (authStep === "otp") {
|
||||
otpInputRef.current?.focus()
|
||||
}
|
||||
}, [authStep])
|
||||
|
||||
// Fetch comments
|
||||
useEffect(() => {
|
||||
const fetchComments = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/stream-comments?username=${username}`)
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { comments?: Comment[] }
|
||||
setComments(data.comments || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch comments:", err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchComments()
|
||||
const interval = setInterval(fetchComments, 5000) // Poll every 5 seconds
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [username])
|
||||
|
||||
// Scroll to bottom when new comments arrive
|
||||
useEffect(() => {
|
||||
commentsEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [comments])
|
||||
|
||||
const handleSendOTP = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!email.trim()) return
|
||||
|
||||
setAuthLoading(true)
|
||||
setAuthError("")
|
||||
|
||||
try {
|
||||
const result = await authClient.emailOtp.sendVerificationOtp({
|
||||
email,
|
||||
type: "sign-in",
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
setAuthError(result.error.message || "Failed to send code")
|
||||
} else {
|
||||
setAuthStep("otp")
|
||||
}
|
||||
} catch (err) {
|
||||
setAuthError(err instanceof Error ? err.message : "Failed to send verification code")
|
||||
} finally {
|
||||
setAuthLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerifyOTP = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!otp.trim()) return
|
||||
|
||||
setAuthLoading(true)
|
||||
setAuthError("")
|
||||
|
||||
try {
|
||||
const result = await authClient.signIn.emailOtp({
|
||||
email,
|
||||
otp,
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
setAuthError(result.error.message || "Invalid code")
|
||||
} else {
|
||||
// Success - close auth form
|
||||
setAuthStep("idle")
|
||||
setEmail("")
|
||||
setOtp("")
|
||||
}
|
||||
} catch (err) {
|
||||
setAuthError(err instanceof Error ? err.message : "Failed to verify code")
|
||||
} finally {
|
||||
setAuthLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitComment = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newComment.trim() || !session?.user) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await fetch("/api/stream-comments", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
content: newComment.trim(),
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { comment: Comment }
|
||||
setComments((prev) => [...prev, data.comment])
|
||||
setNewComment("")
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to post comment:", err)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
const isAuthenticated = !!session?.user
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-black/40 backdrop-blur-sm rounded-xl border border-white/10 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-white/10">
|
||||
<h3 className="text-sm font-medium text-white/80">Chat</h3>
|
||||
</div>
|
||||
|
||||
{/* Comments list */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
||||
{isLoading ? (
|
||||
<div className="text-center text-white/40 text-sm py-4">Loading...</div>
|
||||
) : comments.length === 0 ? (
|
||||
<div className="text-center text-white/40 text-sm py-4">
|
||||
No messages yet. Be the first to say hi!
|
||||
</div>
|
||||
) : (
|
||||
comments.map((comment) => (
|
||||
<div key={comment.id} className="group">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-white/10 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xs font-medium text-white/70">
|
||||
{comment.user_name?.charAt(0).toUpperCase() || "?"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-medium text-white/60 truncate">
|
||||
{comment.user_name || "Anonymous"}
|
||||
</span>
|
||||
<span className="text-[10px] text-white/30">
|
||||
{formatTime(comment.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-white/90 break-words">{comment.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={commentsEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div className="border-t border-white/10 p-3">
|
||||
{sessionLoading ? (
|
||||
<div className="text-center text-white/40 text-sm py-2">Loading...</div>
|
||||
) : isAuthenticated ? (
|
||||
<form onSubmit={handleSubmitComment} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Send a message..."
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-white/20"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newComment.trim() || isSubmitting}
|
||||
className="px-3 py-2 bg-white text-black rounded-lg hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</form>
|
||||
) : authStep === "idle" ? (
|
||||
<button
|
||||
onClick={() => setAuthStep("email")}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-white text-black text-sm font-medium rounded-lg hover:bg-white/90 transition-colors"
|
||||
>
|
||||
<LogIn size={16} />
|
||||
Sign in to chat
|
||||
</button>
|
||||
) : authStep === "email" ? (
|
||||
<form onSubmit={handleSendOTP} className="space-y-2">
|
||||
<input
|
||||
ref={emailInputRef}
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-white/20"
|
||||
/>
|
||||
{authError && (
|
||||
<p className="text-xs text-red-400">{authError}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAuthStep("idle")
|
||||
setAuthError("")
|
||||
}}
|
||||
className="px-3 py-2 text-sm text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={authLoading || !email.trim()}
|
||||
className="flex-1 px-3 py-2 bg-white text-black text-sm font-medium rounded-lg hover:bg-white/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{authLoading ? "Sending..." : "Send code"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleVerifyOTP} className="space-y-2">
|
||||
<p className="text-xs text-white/60 text-center">
|
||||
Code sent to {email}
|
||||
</p>
|
||||
<input
|
||||
ref={otpInputRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="000000"
|
||||
required
|
||||
maxLength={6}
|
||||
value={otp}
|
||||
onChange={(e) => setOtp(e.target.value.replace(/\D/g, ""))}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-center text-lg font-mono tracking-widest text-white placeholder:text-white/30 focus:outline-none focus:border-white/20"
|
||||
/>
|
||||
{authError && (
|
||||
<p className="text-xs text-red-400">{authError}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAuthStep("email")
|
||||
setOtp("")
|
||||
setAuthError("")
|
||||
}}
|
||||
className="px-3 py-2 text-sm text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={authLoading || otp.length !== 6}
|
||||
className="flex-1 px-3 py-2 bg-white text-black text-sm font-medium rounded-lg hover:bg-white/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{authLoading ? "Verifying..." : "Sign in"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -297,6 +297,25 @@ export const stream_replays = pgTable("stream_replays", {
|
||||
export const selectStreamReplaySchema = createSelectSchema(stream_replays)
|
||||
export type StreamReplay = z.infer<typeof selectStreamReplaySchema>
|
||||
|
||||
// =============================================================================
|
||||
// Stream Comments (live chat for streams)
|
||||
// =============================================================================
|
||||
|
||||
export const stream_comments = pgTable("stream_comments", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
stream_username: text("stream_username").notNull(), // Username of the streamer
|
||||
user_id: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
content: text("content").notNull(),
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
})
|
||||
|
||||
export const selectStreamCommentSchema = createSelectSchema(stream_comments)
|
||||
export type StreamComment = z.infer<typeof selectStreamCommentSchema>
|
||||
|
||||
// =============================================================================
|
||||
// Stripe Billing
|
||||
// =============================================================================
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { BillingWithChecks, Price, UsageMeter, Product } from "@flowglad/server"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type BillingWithChecks = any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Price = any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type UsageMeter = any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Product = any
|
||||
|
||||
/**
|
||||
* Computes the total usage credits for a given usage meter slug from the current subscription's feature items.
|
||||
|
||||
@@ -508,3 +508,23 @@ export function formatBytes(bytes: number): string {
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has an active subscription (server-side only)
|
||||
*/
|
||||
export async function hasActiveSubscription(userId: string): Promise<boolean> {
|
||||
const database = db()
|
||||
|
||||
const [subscription] = await database
|
||||
.select()
|
||||
.from(stripe_subscriptions)
|
||||
.where(
|
||||
and(
|
||||
eq(stripe_subscriptions.user_id, userId),
|
||||
eq(stripe_subscriptions.status, "active")
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return !!subscription
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { Route as ArchiveArchiveIdRouteImport } from './routes/archive.$archiveI
|
||||
import { Route as ApiUsersRouteImport } from './routes/api/users'
|
||||
import { Route as ApiUsageEventsRouteImport } from './routes/api/usage-events'
|
||||
import { Route as ApiStreamReplaysRouteImport } from './routes/api/stream-replays'
|
||||
import { Route as ApiStreamCommentsRouteImport } from './routes/api/stream-comments'
|
||||
import { Route as ApiStreamRouteImport } from './routes/api/stream'
|
||||
import { Route as ApiProfileRouteImport } from './routes/api/profile'
|
||||
import { Route as ApiContextItemsRouteImport } from './routes/api/context-items'
|
||||
@@ -43,7 +44,9 @@ import { Route as DemoApiNamesRouteImport } from './routes/demo/api.names'
|
||||
import { Route as ApiUsersUsernameRouteImport } from './routes/api/users.username'
|
||||
import { Route as ApiUsageEventsCreateRouteImport } from './routes/api/usage-events.create'
|
||||
import { Route as ApiStripeWebhooksRouteImport } from './routes/api/stripe/webhooks'
|
||||
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 ApiStreamReplaysReplayIdRouteImport } from './routes/api/stream-replays.$replayId'
|
||||
import { Route as ApiSpotifyNowPlayingRouteImport } from './routes/api/spotify.now-playing'
|
||||
@@ -165,6 +168,11 @@ const ApiStreamReplaysRoute = ApiStreamReplaysRouteImport.update({
|
||||
path: '/api/stream-replays',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiStreamCommentsRoute = ApiStreamCommentsRouteImport.update({
|
||||
id: '/api/stream-comments',
|
||||
path: '/api/stream-comments',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiStreamRoute = ApiStreamRouteImport.update({
|
||||
id: '/api/stream',
|
||||
path: '/api/stream',
|
||||
@@ -235,11 +243,21 @@ const ApiStripeWebhooksRoute = ApiStripeWebhooksRouteImport.update({
|
||||
path: '/api/stripe/webhooks',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiStripePortalRoute = ApiStripePortalRouteImport.update({
|
||||
id: '/api/stripe/portal',
|
||||
path: '/api/stripe/portal',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiStripeCheckoutRoute = ApiStripeCheckoutRouteImport.update({
|
||||
id: '/api/stripe/checkout',
|
||||
path: '/api/stripe/checkout',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiStripeBillingRoute = ApiStripeBillingRouteImport.update({
|
||||
id: '/api/stripe/billing',
|
||||
path: '/api/stripe/billing',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiStreamsUsernameRoute = ApiStreamsUsernameRouteImport.update({
|
||||
id: '/api/streams/$username',
|
||||
path: '/api/streams/$username',
|
||||
@@ -368,6 +386,7 @@ export interface FileRoutesByFullPath {
|
||||
'/api/context-items': typeof ApiContextItemsRoute
|
||||
'/api/profile': typeof ApiProfileRoute
|
||||
'/api/stream': typeof ApiStreamRoute
|
||||
'/api/stream-comments': typeof ApiStreamCommentsRoute
|
||||
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
|
||||
'/api/usage-events': typeof ApiUsageEventsRouteWithChildren
|
||||
'/api/users': typeof ApiUsersRouteWithChildren
|
||||
@@ -387,7 +406,9 @@ export interface FileRoutesByFullPath {
|
||||
'/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute
|
||||
'/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute
|
||||
'/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren
|
||||
'/api/stripe/billing': typeof ApiStripeBillingRoute
|
||||
'/api/stripe/checkout': typeof ApiStripeCheckoutRoute
|
||||
'/api/stripe/portal': typeof ApiStripePortalRoute
|
||||
'/api/stripe/webhooks': typeof ApiStripeWebhooksRoute
|
||||
'/api/usage-events/create': typeof ApiUsageEventsCreateRoute
|
||||
'/api/users/username': typeof ApiUsersUsernameRoute
|
||||
@@ -424,6 +445,7 @@ export interface FileRoutesByTo {
|
||||
'/api/context-items': typeof ApiContextItemsRoute
|
||||
'/api/profile': typeof ApiProfileRoute
|
||||
'/api/stream': typeof ApiStreamRoute
|
||||
'/api/stream-comments': typeof ApiStreamCommentsRoute
|
||||
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
|
||||
'/api/usage-events': typeof ApiUsageEventsRouteWithChildren
|
||||
'/api/users': typeof ApiUsersRouteWithChildren
|
||||
@@ -443,7 +465,9 @@ export interface FileRoutesByTo {
|
||||
'/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute
|
||||
'/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute
|
||||
'/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren
|
||||
'/api/stripe/billing': typeof ApiStripeBillingRoute
|
||||
'/api/stripe/checkout': typeof ApiStripeCheckoutRoute
|
||||
'/api/stripe/portal': typeof ApiStripePortalRoute
|
||||
'/api/stripe/webhooks': typeof ApiStripeWebhooksRoute
|
||||
'/api/usage-events/create': typeof ApiUsageEventsCreateRoute
|
||||
'/api/users/username': typeof ApiUsersUsernameRoute
|
||||
@@ -482,6 +506,7 @@ export interface FileRoutesById {
|
||||
'/api/context-items': typeof ApiContextItemsRoute
|
||||
'/api/profile': typeof ApiProfileRoute
|
||||
'/api/stream': typeof ApiStreamRoute
|
||||
'/api/stream-comments': typeof ApiStreamCommentsRoute
|
||||
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
|
||||
'/api/usage-events': typeof ApiUsageEventsRouteWithChildren
|
||||
'/api/users': typeof ApiUsersRouteWithChildren
|
||||
@@ -501,7 +526,9 @@ export interface FileRoutesById {
|
||||
'/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute
|
||||
'/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute
|
||||
'/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren
|
||||
'/api/stripe/billing': typeof ApiStripeBillingRoute
|
||||
'/api/stripe/checkout': typeof ApiStripeCheckoutRoute
|
||||
'/api/stripe/portal': typeof ApiStripePortalRoute
|
||||
'/api/stripe/webhooks': typeof ApiStripeWebhooksRoute
|
||||
'/api/usage-events/create': typeof ApiUsageEventsCreateRoute
|
||||
'/api/users/username': typeof ApiUsersUsernameRoute
|
||||
@@ -541,6 +568,7 @@ export interface FileRouteTypes {
|
||||
| '/api/context-items'
|
||||
| '/api/profile'
|
||||
| '/api/stream'
|
||||
| '/api/stream-comments'
|
||||
| '/api/stream-replays'
|
||||
| '/api/usage-events'
|
||||
| '/api/users'
|
||||
@@ -560,7 +588,9 @@ export interface FileRouteTypes {
|
||||
| '/api/spotify/now-playing'
|
||||
| '/api/stream-replays/$replayId'
|
||||
| '/api/streams/$username'
|
||||
| '/api/stripe/billing'
|
||||
| '/api/stripe/checkout'
|
||||
| '/api/stripe/portal'
|
||||
| '/api/stripe/webhooks'
|
||||
| '/api/usage-events/create'
|
||||
| '/api/users/username'
|
||||
@@ -597,6 +627,7 @@ export interface FileRouteTypes {
|
||||
| '/api/context-items'
|
||||
| '/api/profile'
|
||||
| '/api/stream'
|
||||
| '/api/stream-comments'
|
||||
| '/api/stream-replays'
|
||||
| '/api/usage-events'
|
||||
| '/api/users'
|
||||
@@ -616,7 +647,9 @@ export interface FileRouteTypes {
|
||||
| '/api/spotify/now-playing'
|
||||
| '/api/stream-replays/$replayId'
|
||||
| '/api/streams/$username'
|
||||
| '/api/stripe/billing'
|
||||
| '/api/stripe/checkout'
|
||||
| '/api/stripe/portal'
|
||||
| '/api/stripe/webhooks'
|
||||
| '/api/usage-events/create'
|
||||
| '/api/users/username'
|
||||
@@ -654,6 +687,7 @@ export interface FileRouteTypes {
|
||||
| '/api/context-items'
|
||||
| '/api/profile'
|
||||
| '/api/stream'
|
||||
| '/api/stream-comments'
|
||||
| '/api/stream-replays'
|
||||
| '/api/usage-events'
|
||||
| '/api/users'
|
||||
@@ -673,7 +707,9 @@ export interface FileRouteTypes {
|
||||
| '/api/spotify/now-playing'
|
||||
| '/api/stream-replays/$replayId'
|
||||
| '/api/streams/$username'
|
||||
| '/api/stripe/billing'
|
||||
| '/api/stripe/checkout'
|
||||
| '/api/stripe/portal'
|
||||
| '/api/stripe/webhooks'
|
||||
| '/api/usage-events/create'
|
||||
| '/api/users/username'
|
||||
@@ -712,6 +748,7 @@ export interface RootRouteChildren {
|
||||
ApiContextItemsRoute: typeof ApiContextItemsRoute
|
||||
ApiProfileRoute: typeof ApiProfileRoute
|
||||
ApiStreamRoute: typeof ApiStreamRoute
|
||||
ApiStreamCommentsRoute: typeof ApiStreamCommentsRoute
|
||||
ApiStreamReplaysRoute: typeof ApiStreamReplaysRouteWithChildren
|
||||
ApiUsageEventsRoute: typeof ApiUsageEventsRouteWithChildren
|
||||
ApiUsersRoute: typeof ApiUsersRouteWithChildren
|
||||
@@ -723,7 +760,9 @@ export interface RootRouteChildren {
|
||||
ApiFlowgladSplatRoute: typeof ApiFlowgladSplatRoute
|
||||
ApiSpotifyNowPlayingRoute: typeof ApiSpotifyNowPlayingRoute
|
||||
ApiStreamsUsernameRoute: typeof ApiStreamsUsernameRouteWithChildren
|
||||
ApiStripeBillingRoute: typeof ApiStripeBillingRoute
|
||||
ApiStripeCheckoutRoute: typeof ApiStripeCheckoutRoute
|
||||
ApiStripePortalRoute: typeof ApiStripePortalRoute
|
||||
ApiStripeWebhooksRoute: typeof ApiStripeWebhooksRoute
|
||||
DemoApiNamesRoute: typeof DemoApiNamesRoute
|
||||
DemoStartApiRequestRoute: typeof DemoStartApiRequestRoute
|
||||
@@ -876,6 +915,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiStreamReplaysRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/stream-comments': {
|
||||
id: '/api/stream-comments'
|
||||
path: '/api/stream-comments'
|
||||
fullPath: '/api/stream-comments'
|
||||
preLoaderRoute: typeof ApiStreamCommentsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/stream': {
|
||||
id: '/api/stream'
|
||||
path: '/api/stream'
|
||||
@@ -974,6 +1020,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiStripeWebhooksRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/stripe/portal': {
|
||||
id: '/api/stripe/portal'
|
||||
path: '/api/stripe/portal'
|
||||
fullPath: '/api/stripe/portal'
|
||||
preLoaderRoute: typeof ApiStripePortalRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/stripe/checkout': {
|
||||
id: '/api/stripe/checkout'
|
||||
path: '/api/stripe/checkout'
|
||||
@@ -981,6 +1034,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiStripeCheckoutRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/stripe/billing': {
|
||||
id: '/api/stripe/billing'
|
||||
path: '/api/stripe/billing'
|
||||
fullPath: '/api/stripe/billing'
|
||||
preLoaderRoute: typeof ApiStripeBillingRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/streams/$username': {
|
||||
id: '/api/streams/$username'
|
||||
path: '/api/streams/$username'
|
||||
@@ -1281,6 +1341,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ApiContextItemsRoute: ApiContextItemsRoute,
|
||||
ApiProfileRoute: ApiProfileRoute,
|
||||
ApiStreamRoute: ApiStreamRoute,
|
||||
ApiStreamCommentsRoute: ApiStreamCommentsRoute,
|
||||
ApiStreamReplaysRoute: ApiStreamReplaysRouteWithChildren,
|
||||
ApiUsageEventsRoute: ApiUsageEventsRouteWithChildren,
|
||||
ApiUsersRoute: ApiUsersRouteWithChildren,
|
||||
@@ -1292,7 +1353,9 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ApiFlowgladSplatRoute: ApiFlowgladSplatRoute,
|
||||
ApiSpotifyNowPlayingRoute: ApiSpotifyNowPlayingRoute,
|
||||
ApiStreamsUsernameRoute: ApiStreamsUsernameRouteWithChildren,
|
||||
ApiStripeBillingRoute: ApiStripeBillingRoute,
|
||||
ApiStripeCheckoutRoute: ApiStripeCheckoutRoute,
|
||||
ApiStripePortalRoute: ApiStripePortalRoute,
|
||||
ApiStripeWebhooksRoute: ApiStripeWebhooksRoute,
|
||||
DemoApiNamesRoute: DemoApiNamesRoute,
|
||||
DemoStartApiRequestRoute: DemoStartApiRequestRoute,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -461,7 +461,8 @@ app.post("/api/v1/admin/chat/messages", async (c) => {
|
||||
app.post("/api/v1/admin/context-items", async (c) => {
|
||||
const body = await parseBody(c)
|
||||
const userId = typeof body.userId === "string" ? body.userId.trim() : ""
|
||||
const type = typeof body.type === "string" ? body.type.trim() : ""
|
||||
const type =
|
||||
typeof body.type === "string" ? body.type.trim().toLowerCase() : ""
|
||||
const url = typeof body.url === "string" ? body.url.trim() : null
|
||||
const name =
|
||||
typeof body.name === "string" && body.name.trim()
|
||||
@@ -537,7 +538,7 @@ app.patch("/api/v1/admin/context-items/:itemId", async (c) => {
|
||||
|
||||
if (typeof body.name === "string") updates.name = body.name
|
||||
if (typeof body.type === "string") {
|
||||
const nextType = body.type.trim()
|
||||
const nextType = body.type.trim().toLowerCase()
|
||||
if (nextType !== "url" && nextType !== "file") {
|
||||
return c.json({ error: "type must be 'url' or 'file'" }, 400)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user