mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
Implement setup task for worker admin environment and add new database schema and snapshot files
This commit is contained in:
110
flow.toml
110
flow.toml
@@ -128,6 +128,116 @@ description = "Set up Linsa: create .env, install deps, push schema to Neon."
|
|||||||
dependencies = ["node", "pnpm"]
|
dependencies = ["node", "pnpm"]
|
||||||
shortcuts = ["s"]
|
shortcuts = ["s"]
|
||||||
|
|
||||||
|
[[tasks]]
|
||||||
|
name = "setup-worker-admin"
|
||||||
|
interactive = true
|
||||||
|
command = """
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(pwd)"
|
||||||
|
WORKER_DIR="$ROOT/packages/worker"
|
||||||
|
WORKER_VARS="$WORKER_DIR/.dev.vars"
|
||||||
|
WEB_ENV_FILE="$ROOT/packages/web/.env"
|
||||||
|
|
||||||
|
echo "=== Worker Admin Setup ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ ! -f "$WORKER_VARS" ]; then
|
||||||
|
touch "$WORKER_VARS"
|
||||||
|
echo "Created $WORKER_VARS"
|
||||||
|
else
|
||||||
|
echo "$WORKER_VARS exists"
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURRENT_ADMIN=$(grep -E "^ADMIN_API_KEY=" "$WORKER_VARS" 2>/dev/null | tail -1 | cut -d'=' -f2- || true)
|
||||||
|
if [ -z "$CURRENT_ADMIN" ]; then
|
||||||
|
echo ""
|
||||||
|
read -s -p "Enter ADMIN_API_KEY (leave empty to generate): " ADMIN_API_KEY
|
||||||
|
echo ""
|
||||||
|
if [ -z "$ADMIN_API_KEY" ]; then
|
||||||
|
ADMIN_API_KEY=$(openssl rand -hex 32)
|
||||||
|
echo "Generated ADMIN_API_KEY."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ADMIN_API_KEY="$CURRENT_ADMIN"
|
||||||
|
echo "ADMIN_API_KEY already set in .dev.vars"
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURRENT_DB=$(grep -E "^DATABASE_URL=" "$WORKER_VARS" 2>/dev/null | tail -1 | cut -d'=' -f2- || true)
|
||||||
|
DATABASE_URL=""
|
||||||
|
if [ -n "$CURRENT_DB" ] && [[ "$CURRENT_DB" != *"user:password"* ]]; then
|
||||||
|
DATABASE_URL="$CURRENT_DB"
|
||||||
|
echo "DATABASE_URL already set in .dev.vars"
|
||||||
|
else
|
||||||
|
WEB_DB=""
|
||||||
|
if [ -f "$WEB_ENV_FILE" ]; then
|
||||||
|
WEB_DB=$(grep -E "^DATABASE_URL=" "$WEB_ENV_FILE" 2>/dev/null | cut -d'=' -f2- || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$WEB_DB" ] && [[ "$WEB_DB" != *"user:password"* ]]; then
|
||||||
|
read -p "Use DATABASE_URL from packages/web/.env for worker? (Y/n): " USE_WEB_DB
|
||||||
|
if [ -z "$USE_WEB_DB" ] || [ "$USE_WEB_DB" = "y" ] || [ "$USE_WEB_DB" = "Y" ]; then
|
||||||
|
DATABASE_URL="$WEB_DB"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$DATABASE_URL" ]; then
|
||||||
|
read -p "Paste DATABASE_URL for worker (optional, press Enter to skip): " DATABASE_URL
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
ADMIN_API_KEY="$ADMIN_API_KEY" DATABASE_URL="$DATABASE_URL" node - <<'NODE'
|
||||||
|
const fs = require("fs")
|
||||||
|
const path = require("path")
|
||||||
|
|
||||||
|
const varsPath = path.join("packages", "worker", ".dev.vars")
|
||||||
|
let text = ""
|
||||||
|
if (fs.existsSync(varsPath)) {
|
||||||
|
text = fs.readFileSync(varsPath, "utf8")
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureKey = (key, value) => {
|
||||||
|
if (!value) return
|
||||||
|
const pattern = new RegExp(`^${key}=.*$`, "m")
|
||||||
|
if (pattern.test(text)) {
|
||||||
|
text = text.replace(pattern, `${key}=${value}`)
|
||||||
|
} else {
|
||||||
|
if (text.length > 0 && !text.endsWith("\n")) {
|
||||||
|
text += "\n"
|
||||||
|
}
|
||||||
|
text += `${key}=${value}\n`
|
||||||
|
}
|
||||||
|
console.log(` Set ${key}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureKey("ADMIN_API_KEY", process.env.ADMIN_API_KEY || "")
|
||||||
|
ensureKey("DATABASE_URL", process.env.DATABASE_URL || "")
|
||||||
|
|
||||||
|
fs.writeFileSync(varsPath, text)
|
||||||
|
NODE
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
read -p "Set ADMIN_API_KEY for production worker via wrangler now? (y/N): " SET_PROD
|
||||||
|
if [ "$SET_PROD" = "y" ] || [ "$SET_PROD" = "Y" ]; then
|
||||||
|
cd "$WORKER_DIR"
|
||||||
|
if ! pnpm exec wrangler whoami >/dev/null 2>&1; then
|
||||||
|
echo "Not logged in to Cloudflare. Running wrangler login..."
|
||||||
|
pnpm exec wrangler login
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$ADMIN_API_KEY" | pnpm exec wrangler secret put ADMIN_API_KEY
|
||||||
|
echo "ADMIN_API_KEY set for worker"
|
||||||
|
cd "$ROOT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Worker admin setup complete."
|
||||||
|
echo "Run 'pnpm -C packages/worker dev' to start the worker."
|
||||||
|
"""
|
||||||
|
description = "Set up worker admin API env (.dev.vars) and optionally push ADMIN_API_KEY to Cloudflare."
|
||||||
|
dependencies = ["node", "pnpm"]
|
||||||
|
shortcuts = ["swa"]
|
||||||
|
|
||||||
[[tasks]]
|
[[tasks]]
|
||||||
name = "seed"
|
name = "seed"
|
||||||
command = """
|
command = """
|
||||||
|
|||||||
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,
|
"when": 1769000000000,
|
||||||
"tag": "0004_add_stream_replays",
|
"tag": "0004_add_stream_replays",
|
||||||
"breakpoints": true
|
"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"
|
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 = {
|
type BillingProviderProps = {
|
||||||
children: React.ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BillingProvider({ children }: BillingProviderProps) {
|
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 { 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
|
const fetchBillingStatus = useCallback(async () => {
|
||||||
if (isPending) {
|
try {
|
||||||
return <>{children}</>
|
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 (
|
return (
|
||||||
<FlowgladProvider loadBilling={!!session?.user} serverRoute="/api/flowglad">
|
<BillingContext.Provider value={value}>{children}</BillingContext.Provider>
|
||||||
{children}
|
|
||||||
</FlowgladProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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 const selectStreamReplaySchema = createSelectSchema(stream_replays)
|
||||||
export type StreamReplay = z.infer<typeof selectStreamReplaySchema>
|
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
|
// 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.
|
* 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))
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
|
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 ApiUsersRouteImport } from './routes/api/users'
|
||||||
import { Route as ApiUsageEventsRouteImport } from './routes/api/usage-events'
|
import { Route as ApiUsageEventsRouteImport } from './routes/api/usage-events'
|
||||||
import { Route as ApiStreamReplaysRouteImport } from './routes/api/stream-replays'
|
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 ApiStreamRouteImport } from './routes/api/stream'
|
||||||
import { Route as ApiProfileRouteImport } from './routes/api/profile'
|
import { Route as ApiProfileRouteImport } from './routes/api/profile'
|
||||||
import { Route as ApiContextItemsRouteImport } from './routes/api/context-items'
|
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 ApiUsersUsernameRouteImport } from './routes/api/users.username'
|
||||||
import { Route as ApiUsageEventsCreateRouteImport } from './routes/api/usage-events.create'
|
import { Route as ApiUsageEventsCreateRouteImport } from './routes/api/usage-events.create'
|
||||||
import { Route as ApiStripeWebhooksRouteImport } from './routes/api/stripe/webhooks'
|
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 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 ApiStreamsUsernameRouteImport } from './routes/api/streams.$username'
|
||||||
import { Route as ApiStreamReplaysReplayIdRouteImport } from './routes/api/stream-replays.$replayId'
|
import { Route as ApiStreamReplaysReplayIdRouteImport } from './routes/api/stream-replays.$replayId'
|
||||||
import { Route as ApiSpotifyNowPlayingRouteImport } from './routes/api/spotify.now-playing'
|
import { Route as ApiSpotifyNowPlayingRouteImport } from './routes/api/spotify.now-playing'
|
||||||
@@ -165,6 +168,11 @@ const ApiStreamReplaysRoute = ApiStreamReplaysRouteImport.update({
|
|||||||
path: '/api/stream-replays',
|
path: '/api/stream-replays',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiStreamCommentsRoute = ApiStreamCommentsRouteImport.update({
|
||||||
|
id: '/api/stream-comments',
|
||||||
|
path: '/api/stream-comments',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiStreamRoute = ApiStreamRouteImport.update({
|
const ApiStreamRoute = ApiStreamRouteImport.update({
|
||||||
id: '/api/stream',
|
id: '/api/stream',
|
||||||
path: '/api/stream',
|
path: '/api/stream',
|
||||||
@@ -235,11 +243,21 @@ const ApiStripeWebhooksRoute = ApiStripeWebhooksRouteImport.update({
|
|||||||
path: '/api/stripe/webhooks',
|
path: '/api/stripe/webhooks',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiStripePortalRoute = ApiStripePortalRouteImport.update({
|
||||||
|
id: '/api/stripe/portal',
|
||||||
|
path: '/api/stripe/portal',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiStripeCheckoutRoute = ApiStripeCheckoutRouteImport.update({
|
const ApiStripeCheckoutRoute = ApiStripeCheckoutRouteImport.update({
|
||||||
id: '/api/stripe/checkout',
|
id: '/api/stripe/checkout',
|
||||||
path: '/api/stripe/checkout',
|
path: '/api/stripe/checkout',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiStripeBillingRoute = ApiStripeBillingRouteImport.update({
|
||||||
|
id: '/api/stripe/billing',
|
||||||
|
path: '/api/stripe/billing',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiStreamsUsernameRoute = ApiStreamsUsernameRouteImport.update({
|
const ApiStreamsUsernameRoute = ApiStreamsUsernameRouteImport.update({
|
||||||
id: '/api/streams/$username',
|
id: '/api/streams/$username',
|
||||||
path: '/api/streams/$username',
|
path: '/api/streams/$username',
|
||||||
@@ -368,6 +386,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/api/context-items': typeof ApiContextItemsRoute
|
'/api/context-items': typeof ApiContextItemsRoute
|
||||||
'/api/profile': typeof ApiProfileRoute
|
'/api/profile': typeof ApiProfileRoute
|
||||||
'/api/stream': typeof ApiStreamRoute
|
'/api/stream': typeof ApiStreamRoute
|
||||||
|
'/api/stream-comments': typeof ApiStreamCommentsRoute
|
||||||
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
|
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
|
||||||
'/api/usage-events': typeof ApiUsageEventsRouteWithChildren
|
'/api/usage-events': typeof ApiUsageEventsRouteWithChildren
|
||||||
'/api/users': typeof ApiUsersRouteWithChildren
|
'/api/users': typeof ApiUsersRouteWithChildren
|
||||||
@@ -387,7 +406,9 @@ export interface FileRoutesByFullPath {
|
|||||||
'/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute
|
'/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute
|
||||||
'/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute
|
'/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute
|
||||||
'/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren
|
'/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren
|
||||||
|
'/api/stripe/billing': typeof ApiStripeBillingRoute
|
||||||
'/api/stripe/checkout': typeof ApiStripeCheckoutRoute
|
'/api/stripe/checkout': typeof ApiStripeCheckoutRoute
|
||||||
|
'/api/stripe/portal': typeof ApiStripePortalRoute
|
||||||
'/api/stripe/webhooks': typeof ApiStripeWebhooksRoute
|
'/api/stripe/webhooks': typeof ApiStripeWebhooksRoute
|
||||||
'/api/usage-events/create': typeof ApiUsageEventsCreateRoute
|
'/api/usage-events/create': typeof ApiUsageEventsCreateRoute
|
||||||
'/api/users/username': typeof ApiUsersUsernameRoute
|
'/api/users/username': typeof ApiUsersUsernameRoute
|
||||||
@@ -424,6 +445,7 @@ export interface FileRoutesByTo {
|
|||||||
'/api/context-items': typeof ApiContextItemsRoute
|
'/api/context-items': typeof ApiContextItemsRoute
|
||||||
'/api/profile': typeof ApiProfileRoute
|
'/api/profile': typeof ApiProfileRoute
|
||||||
'/api/stream': typeof ApiStreamRoute
|
'/api/stream': typeof ApiStreamRoute
|
||||||
|
'/api/stream-comments': typeof ApiStreamCommentsRoute
|
||||||
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
|
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
|
||||||
'/api/usage-events': typeof ApiUsageEventsRouteWithChildren
|
'/api/usage-events': typeof ApiUsageEventsRouteWithChildren
|
||||||
'/api/users': typeof ApiUsersRouteWithChildren
|
'/api/users': typeof ApiUsersRouteWithChildren
|
||||||
@@ -443,7 +465,9 @@ export interface FileRoutesByTo {
|
|||||||
'/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute
|
'/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute
|
||||||
'/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute
|
'/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute
|
||||||
'/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren
|
'/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren
|
||||||
|
'/api/stripe/billing': typeof ApiStripeBillingRoute
|
||||||
'/api/stripe/checkout': typeof ApiStripeCheckoutRoute
|
'/api/stripe/checkout': typeof ApiStripeCheckoutRoute
|
||||||
|
'/api/stripe/portal': typeof ApiStripePortalRoute
|
||||||
'/api/stripe/webhooks': typeof ApiStripeWebhooksRoute
|
'/api/stripe/webhooks': typeof ApiStripeWebhooksRoute
|
||||||
'/api/usage-events/create': typeof ApiUsageEventsCreateRoute
|
'/api/usage-events/create': typeof ApiUsageEventsCreateRoute
|
||||||
'/api/users/username': typeof ApiUsersUsernameRoute
|
'/api/users/username': typeof ApiUsersUsernameRoute
|
||||||
@@ -482,6 +506,7 @@ export interface FileRoutesById {
|
|||||||
'/api/context-items': typeof ApiContextItemsRoute
|
'/api/context-items': typeof ApiContextItemsRoute
|
||||||
'/api/profile': typeof ApiProfileRoute
|
'/api/profile': typeof ApiProfileRoute
|
||||||
'/api/stream': typeof ApiStreamRoute
|
'/api/stream': typeof ApiStreamRoute
|
||||||
|
'/api/stream-comments': typeof ApiStreamCommentsRoute
|
||||||
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
|
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
|
||||||
'/api/usage-events': typeof ApiUsageEventsRouteWithChildren
|
'/api/usage-events': typeof ApiUsageEventsRouteWithChildren
|
||||||
'/api/users': typeof ApiUsersRouteWithChildren
|
'/api/users': typeof ApiUsersRouteWithChildren
|
||||||
@@ -501,7 +526,9 @@ export interface FileRoutesById {
|
|||||||
'/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute
|
'/api/spotify/now-playing': typeof ApiSpotifyNowPlayingRoute
|
||||||
'/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute
|
'/api/stream-replays/$replayId': typeof ApiStreamReplaysReplayIdRoute
|
||||||
'/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren
|
'/api/streams/$username': typeof ApiStreamsUsernameRouteWithChildren
|
||||||
|
'/api/stripe/billing': typeof ApiStripeBillingRoute
|
||||||
'/api/stripe/checkout': typeof ApiStripeCheckoutRoute
|
'/api/stripe/checkout': typeof ApiStripeCheckoutRoute
|
||||||
|
'/api/stripe/portal': typeof ApiStripePortalRoute
|
||||||
'/api/stripe/webhooks': typeof ApiStripeWebhooksRoute
|
'/api/stripe/webhooks': typeof ApiStripeWebhooksRoute
|
||||||
'/api/usage-events/create': typeof ApiUsageEventsCreateRoute
|
'/api/usage-events/create': typeof ApiUsageEventsCreateRoute
|
||||||
'/api/users/username': typeof ApiUsersUsernameRoute
|
'/api/users/username': typeof ApiUsersUsernameRoute
|
||||||
@@ -541,6 +568,7 @@ export interface FileRouteTypes {
|
|||||||
| '/api/context-items'
|
| '/api/context-items'
|
||||||
| '/api/profile'
|
| '/api/profile'
|
||||||
| '/api/stream'
|
| '/api/stream'
|
||||||
|
| '/api/stream-comments'
|
||||||
| '/api/stream-replays'
|
| '/api/stream-replays'
|
||||||
| '/api/usage-events'
|
| '/api/usage-events'
|
||||||
| '/api/users'
|
| '/api/users'
|
||||||
@@ -560,7 +588,9 @@ export interface FileRouteTypes {
|
|||||||
| '/api/spotify/now-playing'
|
| '/api/spotify/now-playing'
|
||||||
| '/api/stream-replays/$replayId'
|
| '/api/stream-replays/$replayId'
|
||||||
| '/api/streams/$username'
|
| '/api/streams/$username'
|
||||||
|
| '/api/stripe/billing'
|
||||||
| '/api/stripe/checkout'
|
| '/api/stripe/checkout'
|
||||||
|
| '/api/stripe/portal'
|
||||||
| '/api/stripe/webhooks'
|
| '/api/stripe/webhooks'
|
||||||
| '/api/usage-events/create'
|
| '/api/usage-events/create'
|
||||||
| '/api/users/username'
|
| '/api/users/username'
|
||||||
@@ -597,6 +627,7 @@ export interface FileRouteTypes {
|
|||||||
| '/api/context-items'
|
| '/api/context-items'
|
||||||
| '/api/profile'
|
| '/api/profile'
|
||||||
| '/api/stream'
|
| '/api/stream'
|
||||||
|
| '/api/stream-comments'
|
||||||
| '/api/stream-replays'
|
| '/api/stream-replays'
|
||||||
| '/api/usage-events'
|
| '/api/usage-events'
|
||||||
| '/api/users'
|
| '/api/users'
|
||||||
@@ -616,7 +647,9 @@ export interface FileRouteTypes {
|
|||||||
| '/api/spotify/now-playing'
|
| '/api/spotify/now-playing'
|
||||||
| '/api/stream-replays/$replayId'
|
| '/api/stream-replays/$replayId'
|
||||||
| '/api/streams/$username'
|
| '/api/streams/$username'
|
||||||
|
| '/api/stripe/billing'
|
||||||
| '/api/stripe/checkout'
|
| '/api/stripe/checkout'
|
||||||
|
| '/api/stripe/portal'
|
||||||
| '/api/stripe/webhooks'
|
| '/api/stripe/webhooks'
|
||||||
| '/api/usage-events/create'
|
| '/api/usage-events/create'
|
||||||
| '/api/users/username'
|
| '/api/users/username'
|
||||||
@@ -654,6 +687,7 @@ export interface FileRouteTypes {
|
|||||||
| '/api/context-items'
|
| '/api/context-items'
|
||||||
| '/api/profile'
|
| '/api/profile'
|
||||||
| '/api/stream'
|
| '/api/stream'
|
||||||
|
| '/api/stream-comments'
|
||||||
| '/api/stream-replays'
|
| '/api/stream-replays'
|
||||||
| '/api/usage-events'
|
| '/api/usage-events'
|
||||||
| '/api/users'
|
| '/api/users'
|
||||||
@@ -673,7 +707,9 @@ export interface FileRouteTypes {
|
|||||||
| '/api/spotify/now-playing'
|
| '/api/spotify/now-playing'
|
||||||
| '/api/stream-replays/$replayId'
|
| '/api/stream-replays/$replayId'
|
||||||
| '/api/streams/$username'
|
| '/api/streams/$username'
|
||||||
|
| '/api/stripe/billing'
|
||||||
| '/api/stripe/checkout'
|
| '/api/stripe/checkout'
|
||||||
|
| '/api/stripe/portal'
|
||||||
| '/api/stripe/webhooks'
|
| '/api/stripe/webhooks'
|
||||||
| '/api/usage-events/create'
|
| '/api/usage-events/create'
|
||||||
| '/api/users/username'
|
| '/api/users/username'
|
||||||
@@ -712,6 +748,7 @@ export interface RootRouteChildren {
|
|||||||
ApiContextItemsRoute: typeof ApiContextItemsRoute
|
ApiContextItemsRoute: typeof ApiContextItemsRoute
|
||||||
ApiProfileRoute: typeof ApiProfileRoute
|
ApiProfileRoute: typeof ApiProfileRoute
|
||||||
ApiStreamRoute: typeof ApiStreamRoute
|
ApiStreamRoute: typeof ApiStreamRoute
|
||||||
|
ApiStreamCommentsRoute: typeof ApiStreamCommentsRoute
|
||||||
ApiStreamReplaysRoute: typeof ApiStreamReplaysRouteWithChildren
|
ApiStreamReplaysRoute: typeof ApiStreamReplaysRouteWithChildren
|
||||||
ApiUsageEventsRoute: typeof ApiUsageEventsRouteWithChildren
|
ApiUsageEventsRoute: typeof ApiUsageEventsRouteWithChildren
|
||||||
ApiUsersRoute: typeof ApiUsersRouteWithChildren
|
ApiUsersRoute: typeof ApiUsersRouteWithChildren
|
||||||
@@ -723,7 +760,9 @@ export interface RootRouteChildren {
|
|||||||
ApiFlowgladSplatRoute: typeof ApiFlowgladSplatRoute
|
ApiFlowgladSplatRoute: typeof ApiFlowgladSplatRoute
|
||||||
ApiSpotifyNowPlayingRoute: typeof ApiSpotifyNowPlayingRoute
|
ApiSpotifyNowPlayingRoute: typeof ApiSpotifyNowPlayingRoute
|
||||||
ApiStreamsUsernameRoute: typeof ApiStreamsUsernameRouteWithChildren
|
ApiStreamsUsernameRoute: typeof ApiStreamsUsernameRouteWithChildren
|
||||||
|
ApiStripeBillingRoute: typeof ApiStripeBillingRoute
|
||||||
ApiStripeCheckoutRoute: typeof ApiStripeCheckoutRoute
|
ApiStripeCheckoutRoute: typeof ApiStripeCheckoutRoute
|
||||||
|
ApiStripePortalRoute: typeof ApiStripePortalRoute
|
||||||
ApiStripeWebhooksRoute: typeof ApiStripeWebhooksRoute
|
ApiStripeWebhooksRoute: typeof ApiStripeWebhooksRoute
|
||||||
DemoApiNamesRoute: typeof DemoApiNamesRoute
|
DemoApiNamesRoute: typeof DemoApiNamesRoute
|
||||||
DemoStartApiRequestRoute: typeof DemoStartApiRequestRoute
|
DemoStartApiRequestRoute: typeof DemoStartApiRequestRoute
|
||||||
@@ -876,6 +915,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiStreamReplaysRouteImport
|
preLoaderRoute: typeof ApiStreamReplaysRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
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': {
|
'/api/stream': {
|
||||||
id: '/api/stream'
|
id: '/api/stream'
|
||||||
path: '/api/stream'
|
path: '/api/stream'
|
||||||
@@ -974,6 +1020,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiStripeWebhooksRouteImport
|
preLoaderRoute: typeof ApiStripeWebhooksRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
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': {
|
'/api/stripe/checkout': {
|
||||||
id: '/api/stripe/checkout'
|
id: '/api/stripe/checkout'
|
||||||
path: '/api/stripe/checkout'
|
path: '/api/stripe/checkout'
|
||||||
@@ -981,6 +1034,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ApiStripeCheckoutRouteImport
|
preLoaderRoute: typeof ApiStripeCheckoutRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
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': {
|
'/api/streams/$username': {
|
||||||
id: '/api/streams/$username'
|
id: '/api/streams/$username'
|
||||||
path: '/api/streams/$username'
|
path: '/api/streams/$username'
|
||||||
@@ -1281,6 +1341,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
ApiContextItemsRoute: ApiContextItemsRoute,
|
ApiContextItemsRoute: ApiContextItemsRoute,
|
||||||
ApiProfileRoute: ApiProfileRoute,
|
ApiProfileRoute: ApiProfileRoute,
|
||||||
ApiStreamRoute: ApiStreamRoute,
|
ApiStreamRoute: ApiStreamRoute,
|
||||||
|
ApiStreamCommentsRoute: ApiStreamCommentsRoute,
|
||||||
ApiStreamReplaysRoute: ApiStreamReplaysRouteWithChildren,
|
ApiStreamReplaysRoute: ApiStreamReplaysRouteWithChildren,
|
||||||
ApiUsageEventsRoute: ApiUsageEventsRouteWithChildren,
|
ApiUsageEventsRoute: ApiUsageEventsRouteWithChildren,
|
||||||
ApiUsersRoute: ApiUsersRouteWithChildren,
|
ApiUsersRoute: ApiUsersRouteWithChildren,
|
||||||
@@ -1292,7 +1353,9 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
ApiFlowgladSplatRoute: ApiFlowgladSplatRoute,
|
ApiFlowgladSplatRoute: ApiFlowgladSplatRoute,
|
||||||
ApiSpotifyNowPlayingRoute: ApiSpotifyNowPlayingRoute,
|
ApiSpotifyNowPlayingRoute: ApiSpotifyNowPlayingRoute,
|
||||||
ApiStreamsUsernameRoute: ApiStreamsUsernameRouteWithChildren,
|
ApiStreamsUsernameRoute: ApiStreamsUsernameRouteWithChildren,
|
||||||
|
ApiStripeBillingRoute: ApiStripeBillingRoute,
|
||||||
ApiStripeCheckoutRoute: ApiStripeCheckoutRoute,
|
ApiStripeCheckoutRoute: ApiStripeCheckoutRoute,
|
||||||
|
ApiStripePortalRoute: ApiStripePortalRoute,
|
||||||
ApiStripeWebhooksRoute: ApiStripeWebhooksRoute,
|
ApiStripeWebhooksRoute: ApiStripeWebhooksRoute,
|
||||||
DemoApiNamesRoute: DemoApiNamesRoute,
|
DemoApiNamesRoute: DemoApiNamesRoute,
|
||||||
DemoStartApiRequestRoute: DemoStartApiRequestRoute,
|
DemoStartApiRequestRoute: DemoStartApiRequestRoute,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { WebRTCPlayer } from "@/components/WebRTCPlayer"
|
|||||||
import { resolveStreamPlayback } from "@/lib/stream/playback"
|
import { resolveStreamPlayback } from "@/lib/stream/playback"
|
||||||
import { JazzProvider } from "@/lib/jazz/provider"
|
import { JazzProvider } from "@/lib/jazz/provider"
|
||||||
import { ViewerCount } from "@/components/ViewerCount"
|
import { ViewerCount } from "@/components/ViewerCount"
|
||||||
|
import { CommentBox } from "@/components/CommentBox"
|
||||||
import {
|
import {
|
||||||
getSpotifyNowPlaying,
|
getSpotifyNowPlaying,
|
||||||
type SpotifyNowPlayingResponse,
|
type SpotifyNowPlayingResponse,
|
||||||
@@ -359,13 +360,15 @@ function StreamPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<JazzProvider>
|
<JazzProvider>
|
||||||
<div className="h-screen w-screen bg-black">
|
<div className="h-screen w-screen bg-black flex">
|
||||||
{/* Viewer count overlay */}
|
{/* Main content area */}
|
||||||
<div className="absolute top-4 right-4 z-10 rounded-lg bg-black/50 px-3 py-2 backdrop-blur-sm">
|
<div className="flex-1 relative">
|
||||||
<ViewerCount username={username} />
|
{/* Viewer count overlay */}
|
||||||
</div>
|
<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" ? (
|
activePlayback.type === "webrtc" ? (
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
<WebRTCPlayer
|
<WebRTCPlayer
|
||||||
@@ -486,6 +489,12 @@ function StreamPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat sidebar */}
|
||||||
|
<div className="w-80 h-full border-l border-white/10 flex-shrink-0">
|
||||||
|
<CommentBox username={username} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</JazzProvider>
|
</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 { and, eq } from "drizzle-orm"
|
||||||
import { db } from "@/db/connection"
|
import { db } from "@/db/connection"
|
||||||
import { getAuth } from "@/lib/auth"
|
import { getAuth } from "@/lib/auth"
|
||||||
|
import { hasActiveSubscription } from "@/lib/billing"
|
||||||
import { stream_replays, streams } from "@/db/schema"
|
import { stream_replays, streams } from "@/db/schema"
|
||||||
|
|
||||||
const json = (data: unknown, status = 200) =>
|
const json = (data: unknown, status = 200) =>
|
||||||
@@ -80,6 +81,8 @@ const handleGet = async ({
|
|||||||
params: { replayId: string }
|
params: { replayId: string }
|
||||||
}) => {
|
}) => {
|
||||||
const database = db()
|
const database = db()
|
||||||
|
const auth = getAuth()
|
||||||
|
const session = await auth.api.getSession({ headers: request.headers })
|
||||||
|
|
||||||
const replay = await database.query.stream_replays.findFirst({
|
const replay = await database.query.stream_replays.findFirst({
|
||||||
where: eq(stream_replays.id, params.replayId),
|
where: eq(stream_replays.id, params.replayId),
|
||||||
@@ -89,8 +92,31 @@ const handleGet = async ({
|
|||||||
return json({ error: "Replay not found" }, 404)
|
return json({ error: "Replay not found" }, 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOwner = await canAccessReplay(request, replay.user_id)
|
const isOwner = session?.user?.id === replay.user_id
|
||||||
if (!isOwner && (!replay.is_public || replay.status !== "ready")) {
|
|
||||||
|
// 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)
|
return json({ error: "Forbidden" }, 403)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router"
|
|||||||
import { and, desc, eq } from "drizzle-orm"
|
import { and, desc, eq } from "drizzle-orm"
|
||||||
import { db } from "@/db/connection"
|
import { db } from "@/db/connection"
|
||||||
import { getAuth } from "@/lib/auth"
|
import { getAuth } from "@/lib/auth"
|
||||||
|
import { hasActiveSubscription } from "@/lib/billing"
|
||||||
import { stream_replays, users } from "@/db/schema"
|
import { stream_replays, users } from "@/db/schema"
|
||||||
|
|
||||||
const json = (data: unknown, status = 200) =>
|
const json = (data: unknown, status = 200) =>
|
||||||
@@ -37,17 +38,52 @@ const handleGet = async ({
|
|||||||
const session = await auth.api.getSession({ headers: request.headers })
|
const session = await auth.api.getSession({ headers: request.headers })
|
||||||
const isOwner = session?.user?.id === user.id
|
const isOwner = session?.user?.id === user.id
|
||||||
|
|
||||||
const conditions = [eq(stream_replays.user_id, user.id)]
|
// Owners can always see their own replays
|
||||||
if (!isOwner) {
|
if (isOwner) {
|
||||||
conditions.push(eq(stream_replays.is_public, true))
|
try {
|
||||||
conditions.push(eq(stream_replays.status, "ready"))
|
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 {
|
try {
|
||||||
const replays = await database
|
const replays = await database
|
||||||
.select()
|
.select()
|
||||||
.from(stream_replays)
|
.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))
|
.orderBy(desc(stream_replays.started_at), desc(stream_replays.created_at))
|
||||||
|
|
||||||
return json({ replays })
|
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 { useState, useEffect, useRef } from "react"
|
||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import { Mail, Apple, Github } from "lucide-react"
|
import { Mail } from "lucide-react"
|
||||||
import { authClient } from "@/lib/auth-client"
|
import { authClient } from "@/lib/auth-client"
|
||||||
|
|
||||||
export const Route = createFileRoute("/auth")({
|
export const Route = createFileRoute("/auth")({
|
||||||
@@ -10,29 +10,6 @@ export const Route = createFileRoute("/auth")({
|
|||||||
|
|
||||||
type Step = "email" | "otp"
|
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() {
|
function AuthPage() {
|
||||||
const [step, setStep] = useState<Step>("email")
|
const [step, setStep] = useState<Step>("email")
|
||||||
const emailInputRef = useRef<HTMLInputElement>(null)
|
const emailInputRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -252,38 +229,6 @@ function AuthPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
</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"
|
import { ShaderBackground } from "@/components/ShaderBackground"
|
||||||
|
|
||||||
const galleryItems = [
|
const galleryItems = [
|
||||||
@@ -25,6 +25,12 @@ function LandingPage() {
|
|||||||
<p className="mt-4 text-xl text-white/80 drop-shadow-lg">
|
<p className="mt-4 text-xl text-white/80 drop-shadow-lg">
|
||||||
Save anything privately. Share it.
|
Save anything privately. Share it.
|
||||||
</p>
|
</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">
|
<div className="mt-6 flex items-center gap-4">
|
||||||
<a
|
<a
|
||||||
href="https://x.com/linsa_io"
|
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) => {
|
app.post("/api/v1/admin/context-items", async (c) => {
|
||||||
const body = await parseBody(c)
|
const body = await parseBody(c)
|
||||||
const userId = typeof body.userId === "string" ? body.userId.trim() : ""
|
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 url = typeof body.url === "string" ? body.url.trim() : null
|
||||||
const name =
|
const name =
|
||||||
typeof body.name === "string" && body.name.trim()
|
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.name === "string") updates.name = body.name
|
||||||
if (typeof body.type === "string") {
|
if (typeof body.type === "string") {
|
||||||
const nextType = body.type.trim()
|
const nextType = body.type.trim().toLowerCase()
|
||||||
if (nextType !== "url" && nextType !== "file") {
|
if (nextType !== "url" && nextType !== "file") {
|
||||||
return c.json({ error: "type must be 'url' or 'file'" }, 400)
|
return c.json({ error: "type must be 'url' or 'file'" }, 400)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user