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

@@ -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");

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}
}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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
// =============================================================================

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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,

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"

View File

@@ -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)
}