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:
@@ -1,28 +1,177 @@
|
||||
import { FlowgladProvider } from "@flowglad/react"
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
type UsageSnapshot = {
|
||||
used: number
|
||||
limit: number
|
||||
remaining: number
|
||||
}
|
||||
|
||||
type StorageUsage = {
|
||||
archives?: UsageSnapshot
|
||||
storage?: UsageSnapshot
|
||||
}
|
||||
|
||||
type BillingStatus = {
|
||||
isGuest: boolean
|
||||
isPaid: boolean
|
||||
planName: string
|
||||
currentPeriodEnd?: string
|
||||
cancelAtPeriodEnd?: boolean
|
||||
isLoading: boolean
|
||||
error?: string
|
||||
usage?: StorageUsage
|
||||
}
|
||||
|
||||
type BillingContextValue = BillingStatus & {
|
||||
refresh: () => Promise<void>
|
||||
openCheckout: () => Promise<void>
|
||||
openPortal: () => Promise<void>
|
||||
}
|
||||
|
||||
const BillingContext = createContext<BillingContextValue | null>(null)
|
||||
|
||||
export function useBilling() {
|
||||
const context = useContext(BillingContext)
|
||||
if (!context) {
|
||||
return {
|
||||
isGuest: true,
|
||||
isPaid: false,
|
||||
planName: "Guest",
|
||||
isLoading: false,
|
||||
usage: undefined,
|
||||
refresh: async () => {},
|
||||
openCheckout: async () => {},
|
||||
openPortal: async () => {},
|
||||
} as BillingContextValue
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
type BillingProviderProps = {
|
||||
children: React.ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function BillingProvider({ children }: BillingProviderProps) {
|
||||
const flowgladEnabled = import.meta.env.VITE_FLOWGLAD_ENABLED === "true"
|
||||
|
||||
// Skip billing entirely when Flowglad isn't configured
|
||||
if (!flowgladEnabled) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
const { data: session, isPending } = authClient.useSession()
|
||||
const [status, setStatus] = useState<BillingStatus>({
|
||||
isGuest: true,
|
||||
isPaid: false,
|
||||
planName: "Guest",
|
||||
isLoading: true,
|
||||
usage: undefined,
|
||||
})
|
||||
|
||||
// Don't load billing until we know auth state
|
||||
if (isPending) {
|
||||
return <>{children}</>
|
||||
const fetchBillingStatus = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/stripe/billing")
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as Partial<BillingStatus>
|
||||
setStatus({
|
||||
isGuest: data.isGuest ?? true,
|
||||
isPaid: data.isPaid ?? false,
|
||||
planName: data.planName ?? "Guest",
|
||||
usage: data.usage,
|
||||
currentPeriodEnd: data.currentPeriodEnd,
|
||||
cancelAtPeriodEnd: data.cancelAtPeriodEnd,
|
||||
isLoading: false,
|
||||
})
|
||||
} else {
|
||||
setStatus((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: "Failed to load billing status",
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[billing] Failed to fetch status:", error)
|
||||
setStatus((prev) => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
error: "Failed to load billing status",
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) return
|
||||
|
||||
if (!session?.user) {
|
||||
setStatus({
|
||||
isGuest: true,
|
||||
isPaid: false,
|
||||
planName: "Guest",
|
||||
isLoading: false,
|
||||
usage: undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
fetchBillingStatus()
|
||||
}, [session?.user, isPending, fetchBillingStatus])
|
||||
|
||||
const openCheckout = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/stripe/checkout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
successUrl: `${window.location.origin}/archive?billing=success`,
|
||||
cancelUrl: `${window.location.origin}/archive?billing=canceled`,
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as { url?: string }
|
||||
if (data.url) {
|
||||
window.location.href = data.url
|
||||
}
|
||||
} else {
|
||||
console.error("[billing] Failed to create checkout session")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[billing] Checkout error:", error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const openPortal = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/stripe/portal", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
returnUrl: window.location.href,
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as { url?: string }
|
||||
if (data.url) {
|
||||
window.location.href = data.url
|
||||
}
|
||||
} else {
|
||||
console.error("[billing] Failed to create portal session")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[billing] Portal error:", error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const value: BillingContextValue = {
|
||||
...status,
|
||||
refresh: fetchBillingStatus,
|
||||
openCheckout,
|
||||
openPortal,
|
||||
}
|
||||
|
||||
return (
|
||||
<FlowgladProvider loadBilling={!!session?.user} serverRoute="/api/flowglad">
|
||||
{children}
|
||||
</FlowgladProvider>
|
||||
<BillingContext.Provider value={value}>{children}</BillingContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
312
packages/web/src/components/CommentBox.tsx
Normal file
312
packages/web/src/components/CommentBox.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { Send, LogIn } from "lucide-react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
type Comment = {
|
||||
id: string
|
||||
user_id: string
|
||||
user_name: string
|
||||
user_email: string
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
type AuthStep = "idle" | "email" | "otp"
|
||||
|
||||
interface CommentBoxProps {
|
||||
username: string
|
||||
}
|
||||
|
||||
export function CommentBox({ username }: CommentBoxProps) {
|
||||
const { data: session, isPending: sessionLoading } = authClient.useSession()
|
||||
const [comments, setComments] = useState<Comment[]>([])
|
||||
const [newComment, setNewComment] = useState("")
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Auth state
|
||||
const [authStep, setAuthStep] = useState<AuthStep>("idle")
|
||||
const [email, setEmail] = useState("")
|
||||
const [otp, setOtp] = useState("")
|
||||
const [authLoading, setAuthLoading] = useState(false)
|
||||
const [authError, setAuthError] = useState("")
|
||||
|
||||
const commentsEndRef = useRef<HTMLDivElement>(null)
|
||||
const emailInputRef = useRef<HTMLInputElement>(null)
|
||||
const otpInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Focus inputs when auth step changes
|
||||
useEffect(() => {
|
||||
if (authStep === "email") {
|
||||
emailInputRef.current?.focus()
|
||||
} else if (authStep === "otp") {
|
||||
otpInputRef.current?.focus()
|
||||
}
|
||||
}, [authStep])
|
||||
|
||||
// Fetch comments
|
||||
useEffect(() => {
|
||||
const fetchComments = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/stream-comments?username=${username}`)
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { comments?: Comment[] }
|
||||
setComments(data.comments || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch comments:", err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchComments()
|
||||
const interval = setInterval(fetchComments, 5000) // Poll every 5 seconds
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [username])
|
||||
|
||||
// Scroll to bottom when new comments arrive
|
||||
useEffect(() => {
|
||||
commentsEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [comments])
|
||||
|
||||
const handleSendOTP = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!email.trim()) return
|
||||
|
||||
setAuthLoading(true)
|
||||
setAuthError("")
|
||||
|
||||
try {
|
||||
const result = await authClient.emailOtp.sendVerificationOtp({
|
||||
email,
|
||||
type: "sign-in",
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
setAuthError(result.error.message || "Failed to send code")
|
||||
} else {
|
||||
setAuthStep("otp")
|
||||
}
|
||||
} catch (err) {
|
||||
setAuthError(err instanceof Error ? err.message : "Failed to send verification code")
|
||||
} finally {
|
||||
setAuthLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerifyOTP = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!otp.trim()) return
|
||||
|
||||
setAuthLoading(true)
|
||||
setAuthError("")
|
||||
|
||||
try {
|
||||
const result = await authClient.signIn.emailOtp({
|
||||
email,
|
||||
otp,
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
setAuthError(result.error.message || "Invalid code")
|
||||
} else {
|
||||
// Success - close auth form
|
||||
setAuthStep("idle")
|
||||
setEmail("")
|
||||
setOtp("")
|
||||
}
|
||||
} catch (err) {
|
||||
setAuthError(err instanceof Error ? err.message : "Failed to verify code")
|
||||
} finally {
|
||||
setAuthLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitComment = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newComment.trim() || !session?.user) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await fetch("/api/stream-comments", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
content: newComment.trim(),
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { comment: Comment }
|
||||
setComments((prev) => [...prev, data.comment])
|
||||
setNewComment("")
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to post comment:", err)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
const isAuthenticated = !!session?.user
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-black/40 backdrop-blur-sm rounded-xl border border-white/10 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-white/10">
|
||||
<h3 className="text-sm font-medium text-white/80">Chat</h3>
|
||||
</div>
|
||||
|
||||
{/* Comments list */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
||||
{isLoading ? (
|
||||
<div className="text-center text-white/40 text-sm py-4">Loading...</div>
|
||||
) : comments.length === 0 ? (
|
||||
<div className="text-center text-white/40 text-sm py-4">
|
||||
No messages yet. Be the first to say hi!
|
||||
</div>
|
||||
) : (
|
||||
comments.map((comment) => (
|
||||
<div key={comment.id} className="group">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-white/10 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xs font-medium text-white/70">
|
||||
{comment.user_name?.charAt(0).toUpperCase() || "?"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-medium text-white/60 truncate">
|
||||
{comment.user_name || "Anonymous"}
|
||||
</span>
|
||||
<span className="text-[10px] text-white/30">
|
||||
{formatTime(comment.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-white/90 break-words">{comment.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={commentsEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div className="border-t border-white/10 p-3">
|
||||
{sessionLoading ? (
|
||||
<div className="text-center text-white/40 text-sm py-2">Loading...</div>
|
||||
) : isAuthenticated ? (
|
||||
<form onSubmit={handleSubmitComment} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Send a message..."
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-white/20"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newComment.trim() || isSubmitting}
|
||||
className="px-3 py-2 bg-white text-black rounded-lg hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</form>
|
||||
) : authStep === "idle" ? (
|
||||
<button
|
||||
onClick={() => setAuthStep("email")}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-white text-black text-sm font-medium rounded-lg hover:bg-white/90 transition-colors"
|
||||
>
|
||||
<LogIn size={16} />
|
||||
Sign in to chat
|
||||
</button>
|
||||
) : authStep === "email" ? (
|
||||
<form onSubmit={handleSendOTP} className="space-y-2">
|
||||
<input
|
||||
ref={emailInputRef}
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-white/20"
|
||||
/>
|
||||
{authError && (
|
||||
<p className="text-xs text-red-400">{authError}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAuthStep("idle")
|
||||
setAuthError("")
|
||||
}}
|
||||
className="px-3 py-2 text-sm text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={authLoading || !email.trim()}
|
||||
className="flex-1 px-3 py-2 bg-white text-black text-sm font-medium rounded-lg hover:bg-white/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{authLoading ? "Sending..." : "Send code"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleVerifyOTP} className="space-y-2">
|
||||
<p className="text-xs text-white/60 text-center">
|
||||
Code sent to {email}
|
||||
</p>
|
||||
<input
|
||||
ref={otpInputRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="000000"
|
||||
required
|
||||
maxLength={6}
|
||||
value={otp}
|
||||
onChange={(e) => setOtp(e.target.value.replace(/\D/g, ""))}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-center text-lg font-mono tracking-widest text-white placeholder:text-white/30 focus:outline-none focus:border-white/20"
|
||||
/>
|
||||
{authError && (
|
||||
<p className="text-xs text-red-400">{authError}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAuthStep("email")
|
||||
setOtp("")
|
||||
setAuthError("")
|
||||
}}
|
||||
className="px-3 py-2 text-sm text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={authLoading || otp.length !== 6}
|
||||
className="flex-1 px-3 py-2 bg-white text-black text-sm font-medium rounded-lg hover:bg-white/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{authLoading ? "Verifying..." : "Sign in"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user