This commit is contained in:
Nikita
2025-12-21 13:37:19 -08:00
commit 8cd4b943a5
173 changed files with 44266 additions and 0 deletions

View File

@@ -0,0 +1,137 @@
import { useEffect, useState } from "react"
import { createFileRoute } from "@tanstack/react-router"
import { getStreamByUsername, type StreamPageData } from "@/lib/stream/db"
import { VideoPlayer } from "@/components/VideoPlayer"
export const Route = createFileRoute("/$username")({
ssr: false,
component: StreamPage,
})
// Cloudflare Stream HLS URL
const HLS_URL = "https://customer-xctsztqzu046isdc.cloudflarestream.com/bb7858eafc85de6c92963f3817477b5d/manifest/video.m3u8"
// Hardcoded user for nikiv
const NIKIV_DATA: StreamPageData = {
user: {
id: "nikiv",
name: "Nikita",
username: "nikiv",
image: null,
},
stream: {
id: "nikiv-stream",
title: "Live Coding",
description: "Building in public",
is_live: true,
viewer_count: 0,
hls_url: HLS_URL,
thumbnail_url: null,
started_at: null,
},
}
function StreamPage() {
const { username } = Route.useParams()
const [data, setData] = useState<StreamPageData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [streamReady, setStreamReady] = useState(false)
useEffect(() => {
// Special handling for nikiv - hardcoded stream
if (username === "nikiv") {
setData(NIKIV_DATA)
setLoading(false)
// Check if stream is actually live
fetch(HLS_URL)
.then((res) => setStreamReady(res.ok))
.catch(() => setStreamReady(false))
return
}
const loadData = async () => {
setLoading(true)
setError(null)
try {
const result = await getStreamByUsername(username)
setData(result)
if (result?.stream?.hls_url) {
const res = await fetch(result.stream.hls_url)
setStreamReady(res.ok)
}
} catch (err) {
setError("Failed to load stream")
console.error(err)
} finally {
setLoading(false)
}
}
loadData()
}, [username])
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-black text-white">
<div className="text-xl">Loading...</div>
</div>
)
}
if (error) {
return (
<div className="flex min-h-screen items-center justify-center bg-black text-white">
<div className="text-center">
<h1 className="text-4xl font-bold">Error</h1>
<p className="mt-2 text-neutral-400">{error}</p>
</div>
</div>
)
}
if (!data) {
return (
<div className="flex min-h-screen items-center justify-center bg-black text-white">
<div className="text-center">
<h1 className="text-4xl font-bold">User not found</h1>
<p className="mt-2 text-neutral-400">
This username doesn't exist or hasn't set up streaming.
</p>
</div>
</div>
)
}
const { user, stream } = data
return (
<div className="h-screen w-screen bg-black">
{stream?.is_live && stream.hls_url && streamReady ? (
<VideoPlayer src={stream.hls_url} muted={false} />
) : stream?.is_live && stream.hls_url ? (
<div className="flex h-full w-full items-center justify-center text-white">
<div className="text-center">
<div className="animate-pulse text-4xl">🔴</div>
<p className="mt-4 text-xl text-neutral-400">
Connecting to stream...
</p>
</div>
</div>
) : (
<div className="flex h-full w-full items-center justify-center text-white">
<div className="text-center">
<p className="text-2xl font-medium">Streaming soon</p>
<a
href="https://nikiv.dev"
target="_blank"
rel="noopener noreferrer"
className="mt-4 inline-block text-lg text-neutral-400 underline hover:text-white transition-colors"
>
nikiv.dev
</a>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,103 @@
import * as React from "react"
import {
Outlet,
HeadContent,
Scripts,
createRootRoute,
Link,
} from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
import { BillingProvider } from "@/components/BillingProvider"
import appCss from "../styles.css?url"
const SITE_URL = "https://linsa.io"
const SITE_NAME = "Linsa"
const SITE_TITLE = "Linsa Save anything privately. Share it."
const SITE_DESCRIPTION = "Save anything privately. Share it."
function DevtoolsToggle() {
const [show, setShow] = React.useState(false)
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Use Ctrl+Shift+D to avoid conflicts with browser shortcuts
if (e.ctrlKey && e.shiftKey && e.key === "D") {
e.preventDefault()
setShow((prev) => !prev)
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [])
if (!show) return null
return <TanStackRouterDevtools />
}
function NotFound() {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="text-center">
<h1 className="text-4xl font-bold text-slate-900 mb-4">404</h1>
<p className="text-slate-600 mb-4">Page not found</p>
<Link to="/" className="text-slate-900 underline hover:no-underline">
Go home
</Link>
</div>
</div>
)
}
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ title: SITE_TITLE },
{ name: "description", content: SITE_DESCRIPTION },
{
name: "keywords",
content: "save, bookmarks, private, share, organize",
},
{ name: "author", content: SITE_NAME },
{ name: "theme-color", content: "#03050a" },
{ property: "og:type", content: "website" },
{ property: "og:url", content: SITE_URL },
{ property: "og:title", content: SITE_TITLE },
{ property: "og:description", content: SITE_DESCRIPTION },
{ property: "og:site_name", content: SITE_NAME },
{ name: "twitter:card", content: "summary" },
{ name: "twitter:title", content: SITE_TITLE },
{ name: "twitter:description", content: SITE_DESCRIPTION },
{ name: "twitter:creator", content: "@linaborisova" },
],
links: [
{ rel: "canonical", href: SITE_URL },
{ rel: "icon", href: "/favicon.ico" },
{ rel: "stylesheet", href: appCss },
],
}),
shellComponent: RootDocument,
notFoundComponent: NotFound,
component: () => (
<BillingProvider>
<Outlet />
<DevtoolsToggle />
</BillingProvider>
),
})
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
{children}
<Scripts />
</body>
</html>
)
}

View File

@@ -0,0 +1,67 @@
import { createFileRoute } from "@tanstack/react-router"
import { getAuth } from "@/lib/auth"
export const Route = createFileRoute("/api/auth/$")({
server: {
handlers: {
GET: async ({ request }) => {
console.log("[api/auth] GET request:", request.url)
try {
const auth = getAuth()
console.log("[api/auth] Auth instance created")
const response = await auth.handler(request)
console.log("[api/auth] Response status:", response.status)
// Log response body for debugging
if (response.status >= 400) {
const cloned = response.clone()
const body = await cloned.text()
console.log("[api/auth] Error response body:", body)
}
return response
} catch (error) {
console.error("[api/auth] GET error:", error)
console.error("[api/auth] GET error stack:", error instanceof Error ? error.stack : "no stack")
return new Response(JSON.stringify({ error: String(error) }), {
status: 500,
headers: { "Content-Type": "application/json" },
})
}
},
POST: async ({ request }) => {
const url = new URL(request.url)
console.log("[api/auth] POST request:", url.pathname)
// Clone request to read body for logging
const clonedReq = request.clone()
try {
const bodyText = await clonedReq.text()
console.log("[api/auth] POST body:", bodyText)
} catch {
console.log("[api/auth] Could not read body")
}
try {
const auth = getAuth()
console.log("[api/auth] Auth instance created, calling handler...")
const response = await auth.handler(request)
console.log("[api/auth] Response status:", response.status)
// Log response body for debugging
if (response.status >= 400) {
const cloned = response.clone()
const body = await cloned.text()
console.log("[api/auth] Error response body:", body)
}
return response
} catch (error) {
console.error("[api/auth] POST error:", error)
console.error("[api/auth] POST error stack:", error instanceof Error ? error.stack : "no stack")
return new Response(JSON.stringify({ error: String(error) }), {
status: 500,
headers: { "Content-Type": "application/json" },
})
}
},
},
},
})

View File

@@ -0,0 +1,141 @@
import { createFileRoute } from "@tanstack/react-router"
import { getAuth } from "@/lib/auth"
import { db } from "@/db/connection"
import { browser_sessions, browser_session_tabs } from "@/db/schema"
import { eq, and } from "drizzle-orm"
const jsonResponse = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json" },
})
export const Route = createFileRoute("/api/browser-sessions/$sessionId")({
server: {
handlers: {
GET: async ({
request,
params,
}: {
request: Request
params: { sessionId: string }
}) => {
const session = await getAuth().api.getSession({
headers: request.headers,
})
if (!session?.user?.id) {
return jsonResponse({ error: "Unauthorized" }, 401)
}
const { sessionId } = params
// Get session
const [browserSession] = await db()
.select()
.from(browser_sessions)
.where(
and(
eq(browser_sessions.id, sessionId),
eq(browser_sessions.user_id, session.user.id),
),
)
.limit(1)
if (!browserSession) {
return jsonResponse({ error: "Session not found" }, 404)
}
// Get tabs
const tabs = await db()
.select()
.from(browser_session_tabs)
.where(eq(browser_session_tabs.session_id, sessionId))
.orderBy(browser_session_tabs.position)
return jsonResponse({ session: browserSession, tabs })
},
PATCH: async ({
request,
params,
}: {
request: Request
params: { sessionId: string }
}) => {
const session = await getAuth().api.getSession({
headers: request.headers,
})
if (!session?.user?.id) {
return jsonResponse({ error: "Unauthorized" }, 401)
}
const { sessionId } = params
const body = (await request.json().catch(() => ({}))) as {
name?: string
is_favorite?: boolean
}
// Verify ownership
const [existing] = await db()
.select()
.from(browser_sessions)
.where(
and(
eq(browser_sessions.id, sessionId),
eq(browser_sessions.user_id, session.user.id),
),
)
.limit(1)
if (!existing) {
return jsonResponse({ error: "Session not found" }, 404)
}
// Build update
const updates: Partial<{ name: string; is_favorite: boolean }> = {}
if (body.name !== undefined) updates.name = body.name
if (body.is_favorite !== undefined) updates.is_favorite = body.is_favorite
if (Object.keys(updates).length === 0) {
return jsonResponse({ error: "No updates provided" }, 400)
}
const [updated] = await db()
.update(browser_sessions)
.set(updates)
.where(eq(browser_sessions.id, sessionId))
.returning()
return jsonResponse({ session: updated })
},
DELETE: async ({
request,
params,
}: {
request: Request
params: { sessionId: string }
}) => {
const session = await getAuth().api.getSession({
headers: request.headers,
})
if (!session?.user?.id) {
return jsonResponse({ error: "Unauthorized" }, 401)
}
const { sessionId } = params
await db()
.delete(browser_sessions)
.where(
and(
eq(browser_sessions.id, sessionId),
eq(browser_sessions.user_id, session.user.id),
),
)
return jsonResponse({ success: true })
},
},
},
})

View File

@@ -0,0 +1,364 @@
import { createFileRoute } from "@tanstack/react-router"
import { getAuth } from "@/lib/auth"
import { db } from "@/db/connection"
import { browser_sessions, browser_session_tabs } from "@/db/schema"
import { eq, and, desc, ilike, or, sql } from "drizzle-orm"
interface TabInput {
title: string
url: string
favicon_url?: string
}
interface SaveSessionBody {
action: "save"
name: string
browser?: string
tabs: TabInput[]
captured_at?: string // ISO date string
}
interface ListSessionsBody {
action: "list"
page?: number
limit?: number
search?: string
}
interface GetSessionBody {
action: "get"
session_id: string
}
interface UpdateSessionBody {
action: "update"
session_id: string
name?: string
is_favorite?: boolean
}
interface DeleteSessionBody {
action: "delete"
session_id: string
}
interface SearchTabsBody {
action: "searchTabs"
query: string
limit?: number
}
type RequestBody =
| SaveSessionBody
| ListSessionsBody
| GetSessionBody
| UpdateSessionBody
| DeleteSessionBody
| SearchTabsBody
const jsonResponse = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json" },
})
export const Route = createFileRoute("/api/browser-sessions")({
server: {
handlers: {
POST: async ({ request }: { request: Request }) => {
const session = await getAuth().api.getSession({
headers: request.headers,
})
if (!session?.user?.id) {
return jsonResponse({ error: "Unauthorized" }, 401)
}
const database = db()
const body = (await request.json().catch(() => ({}))) as RequestBody
try {
switch (body.action) {
case "save": {
const { name, browser = "safari", tabs, captured_at } = body
if (!name || !tabs || !Array.isArray(tabs)) {
return jsonResponse({ error: "Missing name or tabs" }, 400)
}
// Create session
const [newSession] = await database
.insert(browser_sessions)
.values({
user_id: session.user.id,
name,
browser,
tab_count: tabs.length,
captured_at: captured_at ? new Date(captured_at) : new Date(),
})
.returning()
// Insert tabs
if (tabs.length > 0) {
await database.insert(browser_session_tabs).values(
tabs.map((tab, index) => ({
session_id: newSession.id,
title: tab.title || "",
url: tab.url,
position: index,
favicon_url: tab.favicon_url,
})),
)
}
return jsonResponse({ session: newSession })
}
case "list": {
const page = Math.max(1, body.page || 1)
const limit = Math.min(100, Math.max(1, body.limit || 50))
const offset = (page - 1) * limit
const search = body.search?.trim()
// Build query
let query = database
.select()
.from(browser_sessions)
.where(eq(browser_sessions.user_id, session.user.id))
.orderBy(desc(browser_sessions.captured_at))
.limit(limit)
.offset(offset)
if (search) {
query = database
.select()
.from(browser_sessions)
.where(
and(
eq(browser_sessions.user_id, session.user.id),
ilike(browser_sessions.name, `%${search}%`),
),
)
.orderBy(desc(browser_sessions.captured_at))
.limit(limit)
.offset(offset)
}
const sessions = await query
// Get total count
const [countResult] = await database
.select({ count: sql<number>`count(*)` })
.from(browser_sessions)
.where(
search
? and(
eq(browser_sessions.user_id, session.user.id),
ilike(browser_sessions.name, `%${search}%`),
)
: eq(browser_sessions.user_id, session.user.id),
)
const total = Number(countResult?.count || 0)
return jsonResponse({
sessions,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
})
}
case "get": {
const { session_id } = body
if (!session_id) {
return jsonResponse({ error: "Missing session_id" }, 400)
}
// Get session
const [browserSession] = await database
.select()
.from(browser_sessions)
.where(
and(
eq(browser_sessions.id, session_id),
eq(browser_sessions.user_id, session.user.id),
),
)
.limit(1)
if (!browserSession) {
return jsonResponse({ error: "Session not found" }, 404)
}
// Get tabs
const tabs = await database
.select()
.from(browser_session_tabs)
.where(eq(browser_session_tabs.session_id, session_id))
.orderBy(browser_session_tabs.position)
return jsonResponse({ session: browserSession, tabs })
}
case "update": {
const { session_id, name, is_favorite } = body
if (!session_id) {
return jsonResponse({ error: "Missing session_id" }, 400)
}
// Verify ownership
const [existing] = await database
.select()
.from(browser_sessions)
.where(
and(
eq(browser_sessions.id, session_id),
eq(browser_sessions.user_id, session.user.id),
),
)
.limit(1)
if (!existing) {
return jsonResponse({ error: "Session not found" }, 404)
}
// Build update
const updates: Partial<{
name: string
is_favorite: boolean
}> = {}
if (name !== undefined) updates.name = name
if (is_favorite !== undefined) updates.is_favorite = is_favorite
if (Object.keys(updates).length === 0) {
return jsonResponse({ error: "No updates provided" }, 400)
}
const [updated] = await database
.update(browser_sessions)
.set(updates)
.where(eq(browser_sessions.id, session_id))
.returning()
return jsonResponse({ session: updated })
}
case "delete": {
const { session_id } = body
if (!session_id) {
return jsonResponse({ error: "Missing session_id" }, 400)
}
// Delete (cascade will handle tabs)
await database
.delete(browser_sessions)
.where(
and(
eq(browser_sessions.id, session_id),
eq(browser_sessions.user_id, session.user.id),
),
)
return jsonResponse({ success: true })
}
case "searchTabs": {
const { query, limit = 100 } = body
if (!query?.trim()) {
return jsonResponse({ error: "Missing query" }, 400)
}
const searchTerm = `%${query.trim()}%`
// Search tabs across user's sessions
const tabs = await database
.select({
tab: browser_session_tabs,
session: browser_sessions,
})
.from(browser_session_tabs)
.innerJoin(
browser_sessions,
eq(browser_session_tabs.session_id, browser_sessions.id),
)
.where(
and(
eq(browser_sessions.user_id, session.user.id),
or(
ilike(browser_session_tabs.title, searchTerm),
ilike(browser_session_tabs.url, searchTerm),
),
),
)
.orderBy(desc(browser_sessions.captured_at))
.limit(Math.min(limit, 500))
return jsonResponse({
results: tabs.map((t) => ({
...t.tab,
session_name: t.session.name,
session_captured_at: t.session.captured_at,
})),
})
}
default:
return jsonResponse({ error: "Unknown action" }, 400)
}
} catch (error) {
console.error("[browser-sessions] error", error)
return jsonResponse({ error: "Operation failed" }, 500)
}
},
GET: async ({ request }: { request: Request }) => {
const session = await getAuth().api.getSession({
headers: request.headers,
})
if (!session?.user?.id) {
return jsonResponse({ error: "Unauthorized" }, 401)
}
const url = new URL(request.url)
const page = Math.max(1, parseInt(url.searchParams.get("page") || "1"))
const limit = Math.min(
100,
Math.max(1, parseInt(url.searchParams.get("limit") || "50")),
)
const offset = (page - 1) * limit
const sessions = await db()
.select()
.from(browser_sessions)
.where(eq(browser_sessions.user_id, session.user.id))
.orderBy(desc(browser_sessions.captured_at))
.limit(limit)
.offset(offset)
const [countResult] = await db()
.select({ count: sql<number>`count(*)` })
.from(browser_sessions)
.where(eq(browser_sessions.user_id, session.user.id))
const total = Number(countResult?.count || 0)
return jsonResponse({
sessions,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
})
},
},
},
})

View File

@@ -0,0 +1,46 @@
import { createFileRoute } from "@tanstack/react-router"
import {
getCanvasOwner,
getCanvasSnapshotById,
} from "@/lib/canvas/db"
import { resolveCanvasUser } from "@/lib/canvas/user-session"
const json = (data: unknown, status = 200, setCookie?: string) => {
const headers = new Headers({ "content-type": "application/json" })
if (setCookie) {
headers.set("set-cookie", setCookie)
}
return new Response(JSON.stringify(data), {
status,
headers,
})
}
export const Route = createFileRoute("/api/canvas/$canvasId")({
server: {
handlers: {
GET: async ({ request, params }) => {
try {
const { userId, setCookie } = await resolveCanvasUser(request)
const canvasId = params.canvasId
const owner = await getCanvasOwner(canvasId)
if (!owner || owner.ownerId !== userId) {
return json({ error: "Forbidden" }, 403, setCookie)
}
const snapshot = await getCanvasSnapshotById(canvasId)
if (!snapshot) {
return json({ error: "Not found" }, 404, setCookie)
}
return json(snapshot, 200, setCookie)
} catch (error) {
if (error instanceof Response) return error
console.error("[api/canvas/:canvasId] GET", error)
return json({ error: "Failed to load canvas" }, 500)
}
},
},
},
})

View File

@@ -0,0 +1,178 @@
import { createFileRoute } from "@tanstack/react-router"
import {
getCanvasImageRecord,
getCanvasOwner,
updateCanvasImage,
} from "@/lib/canvas/db"
import { generateGeminiImage, DEFAULT_GEMINI_IMAGE_MODEL } from "@/lib/ai/gemini-image"
import { generateOpenAIImage } from "@/lib/ai/openai-image"
import { resolveCanvasUser } from "@/lib/canvas/user-session"
import { STYLE_PRESETS } from "@/features/canvas/styles-presets"
import { checkUsageAllowed, recordUsage } from "@/lib/billing"
const json = (data: unknown, status = 200, setCookie?: string) => {
const headers = new Headers({ "content-type": "application/json" })
if (setCookie) {
headers.set("set-cookie", setCookie)
}
return new Response(JSON.stringify(data), {
status,
headers,
})
}
const applyStylePrompt = (styleId: string | null | undefined, prompt: string) => {
if (!styleId || styleId === "default") {
return { resolvedStyleId: "default", prompt: prompt.trim() }
}
const preset = STYLE_PRESETS.find((item) => item.id === styleId)
if (!preset || preset.id === "default") {
return { resolvedStyleId: preset?.id ?? "default", prompt: prompt.trim() }
}
const stylePrompt = preset.prompt.trim()
const basePrompt = prompt.trim()
const combined = stylePrompt ? `${stylePrompt}\n\n${basePrompt}` : basePrompt
return { resolvedStyleId: preset.id, prompt: combined }
}
const normalizeGeminiModelId = (modelId?: string | null) => {
if (!modelId) return DEFAULT_GEMINI_IMAGE_MODEL
if (
modelId.includes("gemini-2.0-flash-exp-image-generation") ||
modelId === "gemini-1.5-flash" ||
modelId === "gemini-1.5-flash-latest"
) {
return DEFAULT_GEMINI_IMAGE_MODEL
}
return modelId
}
export const Route = createFileRoute("/api/canvas/images/$imageId/generate")({
server: {
handlers: {
POST: async ({ request, params }) => {
try {
const { userId, setCookie } = await resolveCanvasUser(request)
const imageId = params.imageId
const record = await getCanvasImageRecord(imageId)
if (!record) {
return json({ error: "Not found" }, 404, setCookie)
}
const owner = await getCanvasOwner(record.canvas_id)
if (!owner || owner.ownerId !== userId) {
return json({ error: "Forbidden" }, 403, setCookie)
}
// Check usage limits
const usageCheck = await checkUsageAllowed(request)
if (!usageCheck.allowed) {
return json(
{
error: "Usage limit exceeded",
reason: usageCheck.reason,
remaining: usageCheck.remaining,
limit: usageCheck.limit,
},
429,
setCookie,
)
}
const body = await request.json().catch(() => ({}))
const prompt =
typeof body.prompt === "string" && body.prompt.trim().length > 0
? body.prompt
: record.prompt
if (!prompt || !prompt.trim()) {
return json({ error: "Prompt required" }, 400, setCookie)
}
const basePrompt = prompt.trim()
const modelId =
typeof body.modelId === "string" && body.modelId.trim().length > 0
? body.modelId
: record.model_id
const styleId =
typeof body.styleId === "string" && body.styleId.trim().length > 0
? body.styleId
: record.style_id
const { prompt: styledPrompt, resolvedStyleId } = applyStylePrompt(styleId, basePrompt)
const temperature =
typeof body.temperature === "number" && Number.isFinite(body.temperature)
? body.temperature
: undefined
const provider = modelId?.includes("gpt-image") || modelId?.includes("dall") ? "openai" : "gemini"
const resolvedModelId =
provider === "gemini" ? normalizeGeminiModelId(modelId) : modelId ?? undefined
let generation: {
base64: string
mimeType: string
description?: string
provider: string
}
if (provider === "openai") {
const result = await generateOpenAIImage({
prompt: styledPrompt,
model: resolvedModelId,
})
generation = {
base64: result.base64Image,
mimeType: result.mimeType,
description: result.revisedPrompt ?? styledPrompt,
provider: "openai.dall-e-3",
}
} else {
const result = await generateGeminiImage({
prompt: styledPrompt,
model: resolvedModelId,
temperature,
})
generation = {
base64: result.base64Image,
mimeType: result.mimeType,
description: styledPrompt,
provider: "google.gemini",
}
}
const image = await updateCanvasImage({
imageId,
data: {
prompt: basePrompt,
modelId: provider === "gemini" ? resolvedModelId : modelId ?? record.model_id,
modelUsed: provider === "gemini" ? resolvedModelId : modelId ?? record.model_id,
styleId: resolvedStyleId,
imageDataBase64: generation.base64,
metadata: {
provider: generation.provider,
mimeType: generation.mimeType,
description: generation.description ?? styledPrompt,
generatedAt: new Date().toISOString(),
},
},
})
// Record usage for paid users
await recordUsage(request, 1, `canvas-${imageId}-${Date.now()}`)
return json({ image }, 200, setCookie)
} catch (error) {
if (error instanceof Response) return error
console.error("[api/canvas/images/:id/generate] POST", error)
const message =
error instanceof Error && error.message
? error.message
: "Gemini generation failed"
return json({ error: message }, 500)
}
},
},
},
})

View File

@@ -0,0 +1,96 @@
import { createFileRoute } from "@tanstack/react-router"
import { resolveCanvasUser } from "@/lib/canvas/user-session"
import {
deleteCanvasImage,
getCanvasImageRecord,
getCanvasOwner,
updateCanvasImage,
} from "@/lib/canvas/db"
const json = (data: unknown, status = 200, setCookie?: string) => {
const headers = new Headers({ "content-type": "application/json" })
if (setCookie) {
headers.set("set-cookie", setCookie)
}
return new Response(JSON.stringify(data), {
status,
headers,
})
}
export const Route = createFileRoute("/api/canvas/images/$imageId")({
server: {
handlers: {
PATCH: async ({ request, params }) => {
try {
const { userId, setCookie } = await resolveCanvasUser(request)
const imageId = params.imageId
const record = await getCanvasImageRecord(imageId)
if (!record) {
return json({ error: "Not found" }, 404, setCookie)
}
const owner = await getCanvasOwner(record.canvas_id)
if (!owner || owner.ownerId !== userId) {
return json({ error: "Forbidden" }, 403, setCookie)
}
const body = await request.json().catch(() => ({}))
const image = await updateCanvasImage({
imageId,
data: {
name: typeof body.name === "string" ? body.name : undefined,
prompt: typeof body.prompt === "string" ? body.prompt : undefined,
modelId: typeof body.modelId === "string" ? body.modelId : undefined,
styleId: typeof body.styleId === "string" ? body.styleId : undefined,
position:
body.position &&
typeof body.position.x === "number" &&
typeof body.position.y === "number"
? { x: body.position.x, y: body.position.y }
: undefined,
size:
body.size &&
typeof body.size.width === "number" &&
typeof body.size.height === "number"
? { width: body.size.width, height: body.size.height }
: undefined,
rotation:
typeof body.rotation === "number" && Number.isFinite(body.rotation)
? body.rotation
: undefined,
},
})
return json({ image }, 200, setCookie)
} catch (error) {
if (error instanceof Response) return error
console.error("[api/canvas/images/:id] PATCH", error)
return json({ error: "Failed to update image" }, 500)
}
},
DELETE: async ({ request, params }) => {
try {
const { userId, setCookie } = await resolveCanvasUser(request)
const imageId = params.imageId
const record = await getCanvasImageRecord(imageId)
if (!record) {
return json({ error: "Not found" }, 404, setCookie)
}
const owner = await getCanvasOwner(record.canvas_id)
if (!owner || owner.ownerId !== userId) {
return json({ error: "Forbidden" }, 403, setCookie)
}
await deleteCanvasImage(imageId)
return json({ id: imageId }, 200, setCookie)
} catch (error) {
if (error instanceof Response) return error
console.error("[api/canvas/images/:id] DELETE", error)
return json({ error: "Failed to delete image" }, 500)
}
},
},
},
})

View File

@@ -0,0 +1,64 @@
import { createFileRoute } from "@tanstack/react-router"
import { createCanvasImage, getCanvasOwner } from "@/lib/canvas/db"
import { resolveCanvasUser } from "@/lib/canvas/user-session"
const json = (data: unknown, status = 200, setCookie?: string) => {
const headers = new Headers({ "content-type": "application/json" })
if (setCookie) {
headers.set("set-cookie", setCookie)
}
return new Response(JSON.stringify(data), {
status,
headers,
})
}
export const Route = createFileRoute("/api/canvas/images")({
server: {
handlers: {
POST: async ({ request }) => {
try {
const { userId, setCookie } = await resolveCanvasUser(request)
const body = await request.json().catch(() => ({}))
const canvasId = typeof body.canvasId === "string" ? body.canvasId : null
if (!canvasId) {
return json({ error: "canvasId required" }, 400, setCookie)
}
const owner = await getCanvasOwner(canvasId)
if (!owner || owner.ownerId !== userId) {
return json({ error: "Forbidden" }, 403, setCookie)
}
const image = await createCanvasImage({
canvasId,
name: typeof body.name === "string" ? body.name : undefined,
prompt: typeof body.prompt === "string" ? body.prompt : undefined,
position:
body.position &&
typeof body.position.x === "number" &&
typeof body.position.y === "number"
? { x: body.position.x, y: body.position.y }
: undefined,
size:
body.size &&
typeof body.size.width === "number" &&
typeof body.size.height === "number"
? { width: body.size.width, height: body.size.height }
: undefined,
modelId: typeof body.modelId === "string" ? body.modelId : undefined,
styleId: typeof body.styleId === "string" ? body.styleId : undefined,
branchParentId:
typeof body.branchParentId === "string" ? body.branchParentId : undefined,
})
return json({ image }, 200, setCookie)
} catch (error) {
if (error instanceof Response) return error
console.error("[api/canvas/images] POST", error)
return json({ error: "Failed to create canvas image" }, 500)
}
},
},
},
})

View File

@@ -0,0 +1,99 @@
import { createFileRoute } from "@tanstack/react-router"
import {
createCanvasForUser,
getCanvasOwner,
listCanvasesForUser,
updateCanvasRecord,
} from "@/lib/canvas/db"
import { resolveCanvasUser } from "@/lib/canvas/user-session"
const json = (data: unknown, status = 200, setCookie?: string) => {
const headers = new Headers({ "content-type": "application/json" })
if (setCookie) {
headers.set("set-cookie", setCookie)
}
return new Response(JSON.stringify(data), {
status,
headers,
})
}
export const Route = createFileRoute("/api/canvas")({
server: {
handlers: {
GET: async ({ request }) => {
try {
const { userId, setCookie } = await resolveCanvasUser(request)
const canvases = await listCanvasesForUser(userId)
return json({ canvases }, 200, setCookie)
} catch (error) {
if (error instanceof Response) return error
console.error("[api/canvas] GET", error)
return json({ error: "Failed to load canvases" }, 500)
}
},
POST: async ({ request }) => {
try {
const { userId, setCookie } = await resolveCanvasUser(request)
const body = await request.json().catch(() => ({}))
const name =
typeof body.name === "string" && body.name.trim().length > 0
? body.name.trim()
: undefined
const snapshot = await createCanvasForUser({ userId, name })
return json(snapshot, 201, setCookie)
} catch (error) {
if (error instanceof Response) return error
console.error("[api/canvas] POST", error)
return json({ error: "Failed to create canvas" }, 500)
}
},
PATCH: async ({ request }) => {
try {
const { userId, setCookie } = await resolveCanvasUser(request)
const body = await request.json().catch(() => ({}))
const canvasId = typeof body.canvasId === "string" ? body.canvasId : null
if (!canvasId) {
return json({ error: "canvasId required" }, 400, setCookie)
}
const owner = await getCanvasOwner(canvasId)
if (!owner || owner.ownerId !== userId) {
return json({ error: "Forbidden" }, 403, setCookie)
}
const updated = await updateCanvasRecord({
canvasId,
data: {
name: typeof body.name === "string" ? body.name : undefined,
width:
typeof body.width === "number" && Number.isFinite(body.width)
? body.width
: undefined,
height:
typeof body.height === "number" && Number.isFinite(body.height)
? body.height
: undefined,
defaultModel:
typeof body.defaultModel === "string" ? body.defaultModel : undefined,
defaultStyle:
typeof body.defaultStyle === "string" ? body.defaultStyle : undefined,
backgroundPrompt:
body.backgroundPrompt === null
? null
: typeof body.backgroundPrompt === "string"
? body.backgroundPrompt
: undefined,
},
})
return json({ canvas: updated }, 200, setCookie)
} catch (error) {
if (error instanceof Response) return error
console.error("[api/canvas] PATCH", error)
return json({ error: "Failed to update canvas" }, 500)
}
},
},
},
})

View File

@@ -0,0 +1,56 @@
import { createFileRoute } from "@tanstack/react-router"
import { getAuth } from "@/lib/auth"
import {
optionsResponse,
prepareElectricUrl,
proxyElectricRequest,
} from "@/lib/electric-proxy"
import { db } from "@/db/connection"
const serve = async ({ request }: { request: 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" },
})
}
// Get user's thread IDs first
const userThreads = await db().query.chat_threads.findMany({
where(fields, { eq }) {
return eq(fields.user_id, session.user.id)
},
columns: { id: true },
})
// threadIds are integers from DB, but validate for safety
const threadIds = userThreads
.map((t) => t.id)
.filter((id): id is number => Number.isInteger(id))
const originUrl = prepareElectricUrl(request.url)
originUrl.searchParams.set("table", "chat_messages")
// Filter messages by user's thread IDs (no subquery)
if (threadIds.length > 0) {
originUrl.searchParams.set(
"where",
`"thread_id" IN (${threadIds.join(",")})`,
)
} else {
// User has no threads, return empty by filtering impossible condition
originUrl.searchParams.set("where", `"thread_id" = -1`)
}
return proxyElectricRequest(originUrl, request)
}
export const Route = createFileRoute("/api/chat-messages")({
server: {
handlers: {
GET: serve,
OPTIONS: ({ request }) => optionsResponse(request),
},
},
})

View File

@@ -0,0 +1,44 @@
import { createFileRoute } from "@tanstack/react-router"
import { getAuth } from "@/lib/auth"
import {
optionsResponse,
prepareElectricUrl,
proxyElectricRequest,
} from "@/lib/electric-proxy"
// Validate user ID contains only safe characters (alphanumeric, hyphens, underscores)
const isValidUserId = (id: string): boolean => /^[a-zA-Z0-9_-]+$/.test(id)
const serve = async ({ request }: { request: 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" },
})
}
const userId = session.user.id
if (!isValidUserId(userId)) {
return new Response(JSON.stringify({ error: "Invalid user ID" }), {
status: 400,
headers: { "content-type": "application/json" },
})
}
const originUrl = prepareElectricUrl(request.url)
originUrl.searchParams.set("table", "chat_threads")
const filter = `"user_id" = '${userId}'`
originUrl.searchParams.set("where", filter)
return proxyElectricRequest(originUrl, request)
}
export const Route = createFileRoute("/api/chat-threads")({
server: {
handlers: {
GET: serve,
OPTIONS: ({ request }) => optionsResponse(request),
},
},
})

View File

@@ -0,0 +1,191 @@
import { createFileRoute } from "@tanstack/react-router"
import { streamText } from "ai"
import { getAuth } from "@/lib/auth"
import { db } from "@/db/connection"
import {
chat_messages,
chat_threads,
context_items,
thread_context_items,
} from "@/db/schema"
import { getOpenRouter, getDefaultModel } from "@/lib/ai/provider"
import { eq, inArray } from "drizzle-orm"
import { checkUsageAllowed, recordUsage } from "@/lib/billing"
export const Route = createFileRoute("/api/chat/ai")({
server: {
handlers: {
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" },
})
}
const body = (await request.json().catch(() => ({}))) as {
threadId?: number | string
messages?: Array<{ role: "user" | "assistant"; content: string }>
model?: string
}
const threadId = Number(body.threadId)
const messages = body.messages ?? []
const model = body.model || getDefaultModel()
if (!threadId || messages.length === 0) {
return new Response(
JSON.stringify({ error: "Missing threadId or messages" }),
{
status: 400,
headers: { "content-type": "application/json" },
},
)
}
const database = db()
// Verify thread ownership
const [thread] = await database
.select()
.from(chat_threads)
.where(eq(chat_threads.id, threadId))
.limit(1)
if (!thread || thread.user_id !== session.user.id) {
return new Response(JSON.stringify({ error: "Forbidden" }), {
status: 403,
headers: { "content-type": "application/json" },
})
}
// Check usage limits
const usageCheck = await checkUsageAllowed(request)
if (!usageCheck.allowed) {
return new Response(
JSON.stringify({
error: "Usage limit exceeded",
reason: usageCheck.reason,
remaining: usageCheck.remaining,
limit: usageCheck.limit,
}),
{
status: 429,
headers: { "content-type": "application/json" },
},
)
}
// Load context items linked to this thread
const linkedItems = await database
.select({ context_item_id: thread_context_items.context_item_id })
.from(thread_context_items)
.where(eq(thread_context_items.thread_id, threadId))
let contextContent = ""
if (linkedItems.length > 0) {
const itemIds = linkedItems.map((l) => l.context_item_id)
const items = await database
.select()
.from(context_items)
.where(inArray(context_items.id, itemIds))
// Build context content from website content
const contextParts = items
.filter((item) => item.content && !item.refreshing)
.map((item) => {
return `--- Content from ${item.name} (${item.url}) ---\n${item.content}\n--- End of ${item.name} ---`
})
if (contextParts.length > 0) {
contextContent = contextParts.join("\n\n")
}
}
const openrouter = getOpenRouter()
console.log(
"[ai] openrouter:",
openrouter ? "configured" : "not configured",
)
console.log(
"[ai] OPENROUTER_API_KEY set:",
!!process.env.OPENROUTER_API_KEY,
)
if (!openrouter) {
// Fallback to streaming-compatible demo response
const lastUserMessage = messages
.filter((m) => m.role === "user")
.pop()
const reply = `Demo reply: I received "${lastUserMessage?.content}". Configure OPENROUTER_API_KEY for real responses.`
// Save the assistant message
await database.insert(chat_messages).values({
thread_id: threadId,
role: "assistant",
content: reply,
})
// Return a streaming-compatible response using AI SDK data stream format
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
// AI SDK data stream format: 0: for text chunks
controller.enqueue(encoder.encode(`0:${JSON.stringify(reply)}\n`))
controller.close()
},
})
return new Response(stream, {
status: 200,
headers: {
"content-type": "text/plain; charset=utf-8",
"x-vercel-ai-data-stream": "v1",
},
})
}
// Use AI SDK streaming with OpenRouter
console.log("[ai] calling streamText with model:", model)
console.log("[ai] context content length:", contextContent.length)
// Build system prompt with context
let systemPrompt = "You are a helpful assistant."
if (contextContent) {
systemPrompt = `You are a helpful assistant. You have access to the following context information that you should use to answer questions:\n\n${contextContent}\n\nUse the above context to help answer the user's questions when relevant.`
}
try {
const result = streamText({
model: openrouter.chat(model),
system: systemPrompt,
messages: messages.map((m) => ({
role: m.role,
content: m.content,
})),
async onFinish({ text }) {
console.log("[ai] onFinish, text length:", text.length)
// Save the assistant message when streaming completes
await database.insert(chat_messages).values({
thread_id: threadId,
role: "assistant",
content: text,
})
// Record usage for paid users
await recordUsage(request, 1, `chat-${threadId}-${Date.now()}`)
},
})
console.log("[ai] returning stream response")
// Return the streaming response (AI SDK v5 uses toTextStreamResponse)
return result.toTextStreamResponse()
} catch (error) {
console.error("[ai] streamText error:", error)
throw error
}
},
},
},
})

View File

@@ -0,0 +1,106 @@
import { createFileRoute } from "@tanstack/react-router"
import { streamText } from "ai"
import { getOpenRouter, getDefaultModel } from "@/lib/ai/provider"
import { db } from "@/db/connection"
import { chat_threads, chat_messages } from "@/db/schema"
export const Route = createFileRoute("/api/chat/guest")({
server: {
handlers: {
POST: async ({ request }) => {
const body = (await request.json().catch(() => ({}))) as {
messages?: Array<{ role: "user" | "assistant"; content: string }>
model?: string
threadId?: number
}
const messages = body.messages ?? []
const model = body.model || getDefaultModel()
if (messages.length === 0) {
return new Response(JSON.stringify({ error: "Missing messages" }), {
status: 400,
headers: { "content-type": "application/json" },
})
}
const database = db()
let threadId = body.threadId
// Create thread if not provided
if (!threadId) {
const lastUserMessage = messages.filter((m) => m.role === "user").pop()
const title = lastUserMessage?.content?.slice(0, 40) || "New chat"
const [thread] = await database
.insert(chat_threads)
.values({ title, user_id: null })
.returning({ id: chat_threads.id })
threadId = thread.id
}
// Save the user message
const lastUserMessage = messages.filter((m) => m.role === "user").pop()
if (lastUserMessage) {
await database.insert(chat_messages).values({
thread_id: threadId,
role: "user",
content: lastUserMessage.content,
})
}
const openrouter = getOpenRouter()
if (!openrouter) {
const reply = `Demo reply: I received "${lastUserMessage?.content}". Configure OPENROUTER_API_KEY for real responses.`
// Save assistant message
await database.insert(chat_messages).values({
thread_id: threadId,
role: "assistant",
content: reply,
})
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(JSON.stringify({ threadId }) + "\n"))
controller.enqueue(encoder.encode(reply))
controller.close()
},
})
return new Response(stream, {
status: 200,
headers: { "content-type": "text/plain; charset=utf-8" },
})
}
try {
const result = streamText({
model: openrouter.chat(model),
system: "You are a helpful assistant.",
messages: messages.map((m) => ({
role: m.role,
content: m.content,
})),
async onFinish({ text }) {
// Save assistant message when streaming completes
await database.insert(chat_messages).values({
thread_id: threadId!,
role: "assistant",
content: text,
})
},
})
// Return threadId in a custom header so client can track it
const response = result.toTextStreamResponse()
response.headers.set("X-Thread-Id", String(threadId))
return response
} catch (error) {
console.error("[guest-ai] streamText error:", error)
throw error
}
},
},
},
})

View File

@@ -0,0 +1,139 @@
import { createFileRoute } from "@tanstack/react-router"
import { getAuth } from "@/lib/auth"
import { db } from "@/db/connection"
import { chat_threads, chat_messages } from "@/db/schema"
import { eq } from "drizzle-orm"
export const Route = createFileRoute("/api/chat/mutations")({
server: {
handlers: {
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" },
})
}
const database = db()
const body = await request.json().catch(() => ({}))
const { action } = body as { action?: string }
try {
switch (action) {
case "createThread": {
const title =
(typeof body.title === "string" && body.title.trim()) ||
"New chat"
const [thread] = await database
.insert(chat_threads)
.values({
title,
user_id: session.user.id,
})
.returning()
return new Response(
JSON.stringify({ thread }),
defaultJsonHeaders(200),
)
}
case "addMessage": {
const threadId = Number(body.threadId)
const role =
typeof body.role === "string" ? body.role.trim() : "user"
const content =
typeof body.content === "string" ? body.content.trim() : ""
if (!threadId || !content || !role) {
return new Response(
JSON.stringify({ error: "Missing threadId/content/role" }),
defaultJsonHeaders(400),
)
}
const owner = await database.query.chat_threads.findFirst({
where(fields, { eq }) {
return eq(fields.id, threadId)
},
})
if (!owner || owner.user_id !== session.user.id) {
return new Response(
JSON.stringify({ error: "Forbidden" }),
defaultJsonHeaders(403),
)
}
const [message] = await database
.insert(chat_messages)
.values({
thread_id: threadId,
role,
content,
})
.returning()
return new Response(
JSON.stringify({ message }),
defaultJsonHeaders(200),
)
}
case "renameThread": {
const threadId = Number(body.threadId)
const title =
typeof body.title === "string" ? body.title.trim() : ""
if (!threadId || !title) {
return new Response(
JSON.stringify({ error: "Missing threadId/title" }),
defaultJsonHeaders(400),
)
}
const [thread] = await database
.update(chat_threads)
.set({ title })
.where(eq(chat_threads.id, threadId))
.returning()
return new Response(
JSON.stringify({ thread }),
defaultJsonHeaders(200),
)
}
case "deleteAllThreads": {
// Delete all threads for the current user (messages cascade)
await database
.delete(chat_threads)
.where(eq(chat_threads.user_id, session.user.id))
return new Response(
JSON.stringify({ success: true }),
defaultJsonHeaders(200),
)
}
default:
return new Response(
JSON.stringify({ error: "Unknown action" }),
defaultJsonHeaders(400),
)
}
} catch (error) {
console.error("[chat/mutations] error", error)
return new Response(
JSON.stringify({ error: "Mutation failed" }),
defaultJsonHeaders(500),
)
}
},
},
},
})
const defaultJsonHeaders = (status: number) => ({
status,
headers: { "content-type": "application/json" },
})

View File

@@ -0,0 +1,349 @@
import { createFileRoute } from "@tanstack/react-router"
import { getAuth } from "@/lib/auth"
import { db } from "@/db/connection"
import { context_items, thread_context_items } from "@/db/schema"
import { eq, and, inArray, desc } from "drizzle-orm"
interface ContextItemsBody {
action?: string
url?: string
threadId?: number | string
itemId?: number | string
}
const defaultJsonHeaders = (status: number) => ({
status,
headers: { "content-type": "application/json" },
})
// Fetch webpage content as markdown
async function fetchAndUpdateContent(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
db: any,
itemId: number,
url: string,
) {
try {
// Use Jina Reader API for converting webpages to markdown
const response = await fetch(`https://r.jina.ai/${url}`, {
headers: {
Accept: "text/markdown",
},
})
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`)
}
const content = await response.text()
await db
.update(context_items)
.set({
content,
refreshing: false,
updated_at: new Date(),
})
.where(eq(context_items.id, itemId))
} catch (error) {
console.error(`[fetchAndUpdateContent] Failed for ${url}:`, error)
// Mark as not refreshing even on error
await db
.update(context_items)
.set({ refreshing: false })
.where(eq(context_items.id, itemId))
}
}
export const Route = createFileRoute("/api/context-items")({
server: {
handlers: {
POST: async ({ request }: { request: 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" },
})
}
const database = db()
const body = (await request.json().catch(() => ({}))) as ContextItemsBody
const { action } = body
try {
switch (action) {
case "addUrl": {
const url = typeof body.url === "string" ? body.url.trim() : ""
const threadId = body.threadId ? Number(body.threadId) : null
if (!url) {
return new Response(
JSON.stringify({ error: "Missing url" }),
defaultJsonHeaders(400),
)
}
// Parse URL to get display name
let parsedUrl: URL
try {
parsedUrl = new URL(url)
} catch {
return new Response(
JSON.stringify({ error: "Invalid URL" }),
defaultJsonHeaders(400),
)
}
const name = parsedUrl.hostname + parsedUrl.pathname
// Create context item with refreshing=true
const [item] = await database
.insert(context_items)
.values({
user_id: session.user.id,
type: "url",
url,
name,
refreshing: true,
})
.returning()
// If threadId provided, link to thread
if (threadId) {
await database.insert(thread_context_items).values({
thread_id: threadId,
context_item_id: item.id,
})
}
// Fetch content in background and update
fetchAndUpdateContent(database, item.id, url).catch(console.error)
return new Response(
JSON.stringify({ item }),
defaultJsonHeaders(200),
)
}
case "refreshUrl": {
const itemId = Number(body.itemId)
if (!itemId) {
return new Response(
JSON.stringify({ error: "Missing itemId" }),
defaultJsonHeaders(400),
)
}
// Verify ownership
const [item] = await database
.select()
.from(context_items)
.where(eq(context_items.id, itemId))
.limit(1)
if (!item || item.user_id !== session.user.id) {
return new Response(
JSON.stringify({ error: "Forbidden" }),
defaultJsonHeaders(403),
)
}
if (!item.url) {
return new Response(
JSON.stringify({ error: "Item has no URL" }),
defaultJsonHeaders(400),
)
}
// Mark as refreshing
await database
.update(context_items)
.set({ refreshing: true })
.where(eq(context_items.id, itemId))
// Fetch content
fetchAndUpdateContent(database, itemId, item.url).catch(console.error)
return new Response(
JSON.stringify({ success: true }),
defaultJsonHeaders(200),
)
}
case "deleteItem": {
const itemId = Number(body.itemId)
if (!itemId) {
return new Response(
JSON.stringify({ error: "Missing itemId" }),
defaultJsonHeaders(400),
)
}
// Verify ownership and delete
await database
.delete(context_items)
.where(
and(
eq(context_items.id, itemId),
eq(context_items.user_id, session.user.id),
),
)
return new Response(
JSON.stringify({ success: true }),
defaultJsonHeaders(200),
)
}
case "linkToThread": {
const itemId = Number(body.itemId)
const threadId = Number(body.threadId)
if (!itemId || !threadId) {
return new Response(
JSON.stringify({ error: "Missing itemId/threadId" }),
defaultJsonHeaders(400),
)
}
// Verify ownership
const [item] = await database
.select()
.from(context_items)
.where(eq(context_items.id, itemId))
.limit(1)
if (!item || item.user_id !== session.user.id) {
return new Response(
JSON.stringify({ error: "Forbidden" }),
defaultJsonHeaders(403),
)
}
await database
.insert(thread_context_items)
.values({
thread_id: threadId,
context_item_id: itemId,
})
.onConflictDoNothing()
return new Response(
JSON.stringify({ success: true }),
defaultJsonHeaders(200),
)
}
case "unlinkFromThread": {
const itemId = Number(body.itemId)
const threadId = Number(body.threadId)
if (!itemId || !threadId) {
return new Response(
JSON.stringify({ error: "Missing itemId/threadId" }),
defaultJsonHeaders(400),
)
}
await database
.delete(thread_context_items)
.where(
and(
eq(thread_context_items.context_item_id, itemId),
eq(thread_context_items.thread_id, threadId),
),
)
return new Response(
JSON.stringify({ success: true }),
defaultJsonHeaders(200),
)
}
case "getItems": {
const items = await database
.select()
.from(context_items)
.where(eq(context_items.user_id, session.user.id))
.orderBy(desc(context_items.created_at))
return new Response(
JSON.stringify({ items }),
defaultJsonHeaders(200),
)
}
case "getThreadItems": {
const threadId = Number(body.threadId)
if (!threadId) {
return new Response(
JSON.stringify({ error: "Missing threadId" }),
defaultJsonHeaders(400),
)
}
const links = await database
.select()
.from(thread_context_items)
.where(eq(thread_context_items.thread_id, threadId))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const itemIds = links.map((l: any) => l.context_item_id)
if (itemIds.length === 0) {
return new Response(
JSON.stringify({ items: [] }),
defaultJsonHeaders(200),
)
}
const items = await database
.select()
.from(context_items)
.where(inArray(context_items.id, itemIds))
return new Response(
JSON.stringify({ items }),
defaultJsonHeaders(200),
)
}
default:
return new Response(
JSON.stringify({ error: "Unknown action" }),
defaultJsonHeaders(400),
)
}
} catch (error) {
console.error("[context-items] error", error)
return new Response(
JSON.stringify({ error: "Operation failed" }),
defaultJsonHeaders(500),
)
}
},
GET: async ({ request }: { request: 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" },
})
}
const items = await db()
.select()
.from(context_items)
.where(eq(context_items.user_id, session.user.id))
.orderBy(desc(context_items.created_at))
return new Response(JSON.stringify({ items }), defaultJsonHeaders(200))
},
},
},
})

View File

@@ -0,0 +1,71 @@
import { createFileRoute } from "@tanstack/react-router"
import { createRequestHandler } from "@flowglad/server"
import { getFlowgladServer } from "@/lib/flowglad"
const json = (data: { error?: unknown; data?: unknown }, status = 200) =>
new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json" },
})
export const Route = createFileRoute("/api/flowglad/$")({
server: {
handlers: {
GET: async ({ request, params }) => {
const flowglad = getFlowgladServer(request)
if (!flowglad) {
return json({ error: "Flowglad not configured" }, 500)
}
const pathString = params._splat ?? ""
const path = pathString.split("/").filter(Boolean)
const url = new URL(request.url)
const query = Object.fromEntries(url.searchParams)
try {
const handler = createRequestHandler({ flowgladServer: flowglad })
const result = await handler({
path,
method: "GET",
query,
})
return json({ error: result.error, data: result.data }, result.status)
} catch (error) {
console.error("[flowglad] GET error:", error)
if (error instanceof Error && error.message === "Unauthenticated") {
return json({ error: "Unauthorized" }, 401)
}
return json({ error: "Internal error" }, 500)
}
},
POST: async ({ request, params }) => {
const flowglad = getFlowgladServer(request)
if (!flowglad) {
return json({ error: "Flowglad not configured" }, 500)
}
const pathString = params._splat ?? ""
const path = pathString.split("/").filter(Boolean)
const body = await request.json().catch(() => ({}))
try {
const handler = createRequestHandler({ flowgladServer: flowglad })
const result = await handler({
path,
method: "POST",
body,
})
return json({ error: result.error, data: result.data }, result.status)
} catch (error) {
console.error("[flowglad] POST error:", error)
if (error instanceof Error && error.message === "Unauthenticated") {
return json({ error: "Unauthorized" }, 401)
}
return json({ error: "Internal error" }, 500)
}
},
},
},
})

View File

@@ -0,0 +1,172 @@
import { createFileRoute } from "@tanstack/react-router"
import { eq } from "drizzle-orm"
import { getDb } from "@/db/connection"
import { users, streams } from "@/db/schema"
import { getAuth } from "@/lib/auth"
import { randomUUID } from "crypto"
const resolveDatabaseUrl = (request: Request) => {
try {
const { getServerContext } = require("@tanstack/react-start/server") as {
getServerContext: () => { cloudflare?: { env?: Record<string, string> } } | null
}
const ctx = getServerContext()
const url = ctx?.cloudflare?.env?.DATABASE_URL
if (url) return url
} catch {}
if (process.env.DATABASE_URL) return process.env.DATABASE_URL
throw new Error("DATABASE_URL is not configured")
}
// GET current user profile
const getProfile = async ({ request }: { request: Request }) => {
const auth = getAuth()
const session = await auth.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 database = getDb(resolveDatabaseUrl(request))
const user = await database.query.users.findFirst({
where: eq(users.id, session.user.id),
})
if (!user) {
return new Response(JSON.stringify({ error: "User not found" }), {
status: 404,
headers: { "content-type": "application/json" },
})
}
// Also get stream info
const stream = await database.query.streams.findFirst({
where: eq(streams.user_id, user.id),
})
return new Response(
JSON.stringify({
id: user.id,
name: user.name,
email: user.email,
username: user.username,
image: user.image,
stream: stream
? {
id: stream.id,
title: stream.title,
is_live: stream.is_live,
hls_url: stream.hls_url,
stream_key: stream.stream_key,
}
: null,
}),
{ status: 200, headers: { "content-type": "application/json" } }
)
} catch (error) {
console.error("Profile GET error:", error)
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "content-type": "application/json" },
})
}
}
// PUT update profile (name, username)
const updateProfile = async ({ request }: { request: Request }) => {
const auth = getAuth()
const session = await auth.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 { name, username } = body as { name?: string; username?: string }
const database = getDb(resolveDatabaseUrl(request))
// Validate username format
if (username !== undefined) {
if (username.length < 3) {
return new Response(
JSON.stringify({ error: "Username must be at least 3 characters" }),
{ status: 400, headers: { "content-type": "application/json" } }
)
}
if (!/^[a-z0-9_-]+$/.test(username)) {
return new Response(
JSON.stringify({ error: "Username can only contain lowercase letters, numbers, hyphens, and underscores" }),
{ status: 400, headers: { "content-type": "application/json" } }
)
}
// Check if username is taken
const existing = await database.query.users.findFirst({
where: eq(users.username, username),
})
if (existing && existing.id !== session.user.id) {
return new Response(
JSON.stringify({ error: "Username is already taken" }),
{ status: 409, headers: { "content-type": "application/json" } }
)
}
}
// Update user
const updates: Record<string, string> = { updatedAt: new Date().toISOString() }
if (name !== undefined) updates.name = name
if (username !== undefined) updates.username = username
await database
.update(users)
.set(updates)
.where(eq(users.id, session.user.id))
// If username is set for first time, create a stream record
if (username) {
const existingStream = await database.query.streams.findFirst({
where: eq(streams.user_id, session.user.id),
})
if (!existingStream) {
await database.insert(streams).values({
id: randomUUID(),
user_id: session.user.id,
title: `${name || username}'s Stream`,
stream_key: randomUUID().replace(/-/g, ""),
is_live: false,
viewer_count: 0,
})
}
}
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "content-type": "application/json" },
})
} catch (error) {
console.error("Profile PUT error:", error)
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "content-type": "application/json" },
})
}
}
export const Route = createFileRoute("/api/profile")({
server: {
handlers: {
GET: getProfile,
PUT: updateProfile,
},
},
})

View File

@@ -0,0 +1,130 @@
import { createFileRoute } from "@tanstack/react-router"
import { eq } from "drizzle-orm"
import { getDb } from "@/db/connection"
import { streams } from "@/db/schema"
import { getAuth } from "@/lib/auth"
const resolveDatabaseUrl = (request: Request) => {
try {
const { getServerContext } = require("@tanstack/react-start/server") as {
getServerContext: () => { cloudflare?: { env?: Record<string, string> } } | null
}
const ctx = getServerContext()
const url = ctx?.cloudflare?.env?.DATABASE_URL
if (url) return url
} catch {}
if (process.env.DATABASE_URL) return process.env.DATABASE_URL
throw new Error("DATABASE_URL is not configured")
}
// GET current user's stream
const getStream = async ({ request }: { request: Request }) => {
const auth = getAuth()
const session = await auth.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 database = getDb(resolveDatabaseUrl(request))
const stream = await database.query.streams.findFirst({
where: eq(streams.user_id, session.user.id),
})
if (!stream) {
return new Response(JSON.stringify({ error: "No stream configured" }), {
status: 404,
headers: { "content-type": "application/json" },
})
}
return new Response(JSON.stringify(stream), {
status: 200,
headers: { "content-type": "application/json" },
})
} catch (error) {
console.error("Stream GET error:", error)
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "content-type": "application/json" },
})
}
}
// PUT update stream settings
const updateStream = async ({ request }: { request: Request }) => {
const auth = getAuth()
const session = await auth.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 { title, description, hls_url, is_live } = body as {
title?: string
description?: string
hls_url?: string
is_live?: boolean
}
const database = getDb(resolveDatabaseUrl(request))
const stream = await database.query.streams.findFirst({
where: eq(streams.user_id, session.user.id),
})
if (!stream) {
return new Response(JSON.stringify({ error: "No stream configured" }), {
status: 404,
headers: { "content-type": "application/json" },
})
}
const updates: Record<string, unknown> = { updated_at: new Date() }
if (title !== undefined) updates.title = title
if (description !== undefined) updates.description = description
if (hls_url !== undefined) updates.hls_url = hls_url
if (is_live !== undefined) {
updates.is_live = is_live
if (is_live && !stream.started_at) {
updates.started_at = new Date()
} else if (!is_live) {
updates.ended_at = new Date()
}
}
await database
.update(streams)
.set(updates)
.where(eq(streams.id, stream.id))
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "content-type": "application/json" },
})
} catch (error) {
console.error("Stream PUT error:", error)
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "content-type": "application/json" },
})
}
}
export const Route = createFileRoute("/api/stream")({
server: {
handlers: {
GET: getStream,
PUT: updateStream,
},
},
})

View File

@@ -0,0 +1,101 @@
import { createFileRoute } from "@tanstack/react-router"
import { eq } from "drizzle-orm"
import { getDb } from "@/db/connection"
import { users, streams } from "@/db/schema"
const resolveDatabaseUrl = (request: Request) => {
try {
const { getServerContext } = require("@tanstack/react-start/server") as {
getServerContext: () => { cloudflare?: { env?: Record<string, string> } } | null
}
const ctx = getServerContext()
const url = ctx?.cloudflare?.env?.DATABASE_URL
if (url) {
return url
}
} catch {
// probably not running inside server context
}
if (process.env.DATABASE_URL) {
return process.env.DATABASE_URL
}
throw new Error("DATABASE_URL is not configured")
}
const serve = async ({
request,
params,
}: {
request: Request
params: { username: string }
}) => {
const { username } = params
if (!username) {
return new Response(JSON.stringify({ error: "Username required" }), {
status: 400,
headers: { "content-type": "application/json" },
})
}
try {
const database = getDb(resolveDatabaseUrl(request))
const user = await database.query.users.findFirst({
where: eq(users.username, username),
})
if (!user) {
return new Response(JSON.stringify({ error: "User not found" }), {
status: 404,
headers: { "content-type": "application/json" },
})
}
const stream = await database.query.streams.findFirst({
where: eq(streams.user_id, user.id),
})
const data = {
user: {
id: user.id,
name: user.name,
username: user.username,
image: user.image,
},
stream: stream
? {
id: stream.id,
title: stream.title,
description: stream.description,
is_live: stream.is_live,
viewer_count: stream.viewer_count,
hls_url: stream.hls_url,
thumbnail_url: stream.thumbnail_url,
started_at: stream.started_at?.toISOString() ?? null,
}
: null,
}
return new Response(JSON.stringify(data), {
status: 200,
headers: { "content-type": "application/json" },
})
} catch (error) {
console.error("Stream API error:", error)
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "content-type": "application/json" },
})
}
}
export const Route = createFileRoute("/api/streams/$username")({
server: {
handlers: {
GET: serve,
},
},
})

View File

@@ -0,0 +1,136 @@
import { createFileRoute } from "@tanstack/react-router"
import { getFlowgladServer } from "@/lib/flowglad"
import { getAuth } from "@/lib/auth"
const json = (
data: { error?: string; success?: boolean; currentBalance?: number },
status = 200,
) =>
new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json" },
})
export const Route = createFileRoute("/api/usage-events/create")({
server: {
handlers: {
POST: async ({ request }) => {
try {
// Check authentication
const auth = getAuth()
const session = await auth.api.getSession({
headers: request.headers,
})
if (!session?.user) {
return json({ error: "Unauthorized" }, 401)
}
// Get request body
const body = (await request.json().catch(() => ({}))) as {
meterSlug?: string
amount?: number
}
const { meterSlug, amount } = body
// Validate input
if (!meterSlug || typeof meterSlug !== "string") {
return json({ error: "meterSlug is required" }, 400)
}
if (!amount || typeof amount !== "number" || amount <= 0) {
return json({ error: "amount must be a positive number" }, 400)
}
if (
meterSlug !== "free_requests" &&
meterSlug !== "premium_requests"
) {
return json(
{
error:
"meterSlug must be either 'free_requests' or 'premium_requests'",
},
400,
)
}
// Get Flowglad server instance
const flowglad = getFlowgladServer(request)
if (!flowglad) {
return json({ error: "Flowglad not configured" }, 500)
}
// Get billing info
const billing = await flowglad.getBilling()
// Check if user has active subscription
const hasActiveSubscription =
billing.currentSubscriptions &&
billing.currentSubscriptions.length > 0
if (!hasActiveSubscription) {
return json({ error: "No active subscription found" }, 400)
}
// Get current balance
const balanceInfo = billing.checkUsageBalance?.(meterSlug)
const currentBalance = balanceInfo?.availableBalance ?? 0
// Validate balance
if (currentBalance < amount) {
return json(
{
error: `Maximum usage exceeded. Your balance is ${currentBalance}.`,
currentBalance,
},
400,
)
}
// Get subscription
const subscription = billing.currentSubscriptions![0]
// Find usage price for the meter
const usagePrice = billing.pricingModel?.products
?.flatMap((p) => p.prices || [])
?.find((p) => p.type === "usage" && p.slug === meterSlug) as
| { id: string }
| undefined
if (!usagePrice) {
return json(
{
error: `No usage price found for meter: ${meterSlug}`,
},
400,
)
}
// Create usage event
const transactionId = `manual-${Date.now()}-${Math.random().toString(36).slice(2)}`
await flowglad.createUsageEvent({
subscriptionId: subscription.id,
priceId: usagePrice.id,
amount,
transactionId,
})
return json({ success: true }, 200)
} catch (error) {
console.error("[api/usage-events] POST error:", error)
return json(
{
error:
error instanceof Error
? error.message
: "Internal server error",
},
500,
)
}
},
},
},
})

View File

@@ -0,0 +1,119 @@
import { createFileRoute } from "@tanstack/react-router"
import { flowglad } from "@/lib/flowglad"
import { getAuth } from "@/lib/auth"
import { findUsagePriceByMeterSlug } from "@/lib/billing-helpers"
const json = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json" },
})
/**
* POST /api/usage-events
*
* Creates a usage event for the current customer.
*
* Body: {
* usageMeterSlug: string; // e.g., 'ai_requests'
* amount: number; // e.g., 1
* transactionId?: string; // Optional: for idempotency
* }
*/
export const Route = createFileRoute("/api/usage-events")({
server: {
handlers: {
POST: async ({ request }) => {
try {
// Authenticate user
const session = await getAuth().api.getSession({
headers: request.headers,
})
if (!session?.user) {
return json({ error: "Unauthorized" }, 401)
}
const userId = session.user.id
// Parse and validate request body
const body = await request.json().catch(() => ({}))
const { usageMeterSlug, amount, transactionId } = body as {
usageMeterSlug?: string
amount?: number
transactionId?: string
}
if (!usageMeterSlug || typeof usageMeterSlug !== "string") {
return json({ error: "usageMeterSlug is required" }, 400)
}
if (typeof amount !== "number" || amount <= 0 || !Number.isInteger(amount)) {
return json({ error: "amount must be a positive integer" }, 400)
}
// Get Flowglad server
const flowgladServer = flowglad(userId)
if (!flowgladServer) {
return json({ error: "Billing not configured" }, 500)
}
// Get billing info
const billing = await flowgladServer.getBilling()
if (!billing.customer) {
return json({ error: "Customer not found" }, 404)
}
// Get current subscription
const currentSubscription = billing.currentSubscriptions?.[0]
if (!currentSubscription) {
return json({ error: "No active subscription found" }, 404)
}
// Find usage price for the meter
const usagePrice = findUsagePriceByMeterSlug(
usageMeterSlug,
billing.pricingModel,
)
if (!usagePrice) {
return json(
{
error: `Usage price not found for meter: ${usageMeterSlug}`,
},
404,
)
}
// Generate transaction ID if not provided (for idempotency)
const finalTransactionId =
transactionId ??
`usage_${Date.now()}_${Math.random().toString(36).substring(7)}`
// Create usage event
const usageEvent = await flowgladServer.createUsageEvent({
subscriptionId: currentSubscription.id,
priceSlug: usagePrice.slug!,
amount,
transactionId: finalTransactionId,
})
return json({
success: true,
usageEvent,
})
} catch (error) {
console.error("[usage-events] Error:", error)
return json(
{
error: error instanceof Error ? error.message : "Failed to create usage event",
},
500,
)
}
},
},
},
})

View File

@@ -0,0 +1,33 @@
import { createFileRoute } from "@tanstack/react-router"
import { getAuth } from "@/lib/auth"
import {
optionsResponse,
prepareElectricUrl,
proxyElectricRequest,
} from "@/lib/electric-proxy"
const serve = async ({ request }: { request: Request }) => {
const auth = getAuth()
const session = await auth.api.getSession({ headers: request.headers })
if (!session) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "content-type": "application/json" },
})
}
const originUrl = prepareElectricUrl(request.url)
originUrl.searchParams.set("table", "users")
return proxyElectricRequest(originUrl, request)
}
export const Route = createFileRoute("/api/users")({
server: {
handlers: {
GET: serve,
OPTIONS: ({ request }) => optionsResponse(request),
},
},
})

View File

@@ -0,0 +1,291 @@
import { useState, useEffect, useRef } from "react"
import { createFileRoute } from "@tanstack/react-router"
import { Mail, Apple, Github } from "lucide-react"
import { authClient } from "@/lib/auth-client"
export const Route = createFileRoute("/auth")({
component: AuthPage,
ssr: false,
})
type Step = "email" | "otp"
function ChromeIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="4" />
<line x1="21.17" y1="8" x2="12" y2="8" />
<line x1="3.95" y1="6.06" x2="8.54" y2="14" />
<line x1="10.88" y1="21.94" x2="15.46" y2="14" />
</svg>
)
}
function AuthPage() {
const [step, setStep] = useState<Step>("email")
const emailInputRef = useRef<HTMLInputElement>(null)
const otpInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (step === "email") {
emailInputRef.current?.focus()
} else {
otpInputRef.current?.focus()
}
}, [step])
const [email, setEmail] = useState("")
const [otp, setOtp] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("")
const handleSendOTP = async (e: React.FormEvent) => {
e.preventDefault()
if (!email.trim()) return
setIsLoading(true)
setError("")
console.log("[auth-page] Sending OTP to:", email)
try {
const result = await authClient.emailOtp.sendVerificationOtp({
email,
type: "sign-in",
})
console.log("[auth-page] OTP result:", result)
if (result.error) {
console.error("[auth-page] OTP error:", result.error)
setError(result.error.message || "Failed to send code")
} else {
console.log("[auth-page] OTP sent successfully, moving to OTP step")
setStep("otp")
}
} catch (err) {
console.error("[auth-page] Send OTP exception:", err)
setError(err instanceof Error ? err.message : "Failed to send verification code")
} finally {
setIsLoading(false)
}
}
const handleVerifyOTP = async (e: React.FormEvent) => {
e.preventDefault()
if (!otp.trim()) return
setIsLoading(true)
setError("")
console.log("[auth-page] Verifying OTP for:", email)
try {
const result = await authClient.signIn.emailOtp({
email,
otp,
})
console.log("[auth-page] Verify result:", result)
if (result.error) {
console.error("[auth-page] Verify error:", result.error)
setError(result.error.message || "Invalid code")
} else {
console.log("[auth-page] Sign in successful, redirecting...")
window.location.href = "/"
}
} catch (err) {
console.error("[auth-page] Verify OTP exception:", err)
setError(err instanceof Error ? err.message : "Failed to verify code")
} finally {
setIsLoading(false)
}
}
const handleResend = async () => {
setIsLoading(true)
setError("")
setOtp("")
console.log("[auth-page] Resending OTP to:", email)
try {
const result = await authClient.emailOtp.sendVerificationOtp({
email,
type: "sign-in",
})
console.log("[auth-page] Resend result:", result)
if (result.error) {
setError(result.error.message || "Failed to resend code")
}
} catch (err) {
console.error("[auth-page] Resend exception:", err)
setError("Failed to resend code")
} finally {
setIsLoading(false)
}
}
const handleBack = () => {
setStep("email")
setOtp("")
setError("")
}
return (
<div className="min-h-screen bg-[#050505] flex items-center justify-center px-4 py-10 text-white">
<div className="w-full max-w-md">
<div className="rounded-3xl border border-white/10 bg-black/70 px-8 py-10 shadow-[0_10px_40px_rgba(0,0,0,0.45)]">
<header className="space-y-2 text-left">
<span className="inline-flex items-center gap-2 text-xs uppercase tracking-[0.35em] text-white/40">
<Mail className="h-3.5 w-3.5" aria-hidden="true" />
Welcome to Linsa!
</span>
<h1 className="text-3xl font-semibold tracking-tight">
{step === "email" ? "Any Generation. Instantly." : "Enter your code"}
</h1>
<p className="text-sm text-white/70">
{step === "email"
? "Text, images/video on canvas. Fancy context management. Just think it and it's there."
: `We sent a 6-digit code to ${email}`}
</p>
</header>
{step === "email" ? (
<form onSubmit={handleSendOTP} className="mt-8 space-y-5">
<div className="space-y-2 text-left">
<p className="text-sm font-medium text-white">
Enter your email and we'll send you a verification code.
</p>
</div>
<label className="block text-left text-xs font-semibold uppercase tracking-wide text-white/60">
Email
<input
ref={emailInputRef}
type="email"
placeholder="you@gmail.com"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-2 w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white placeholder:text-white/40 focus:border-white/40 focus:outline-none focus:ring-0"
/>
</label>
{error && (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={isLoading || !email.trim()}
className="w-full rounded-2xl bg-white px-4 py-3 text-sm font-semibold text-black transition hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-60"
>
{isLoading ? "Sending code..." : "Send verification code"}
</button>
</form>
) : (
<form onSubmit={handleVerifyOTP} className="mt-8 space-y-5">
<label className="block text-left text-xs font-semibold uppercase tracking-wide text-white/60">
Verification Code
<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="mt-2 w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-center text-2xl font-mono tracking-[0.5em] text-white placeholder:text-white/40 focus:border-white/40 focus:outline-none focus:ring-0"
/>
</label>
{error && (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={isLoading || otp.length !== 6}
className="w-full rounded-2xl bg-white px-4 py-3 text-sm font-semibold text-black transition hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-60"
>
{isLoading ? "Verifying..." : "Sign in"}
</button>
<div className="flex items-center justify-between text-sm">
<button
type="button"
onClick={handleBack}
className="text-white/60 hover:text-white transition"
>
Back
</button>
<button
type="button"
onClick={handleResend}
disabled={isLoading}
className="text-white/60 hover:text-white transition disabled:opacity-50"
>
Resend code
</button>
</div>
</form>
)}
<div className="mt-8 border-t border-white/10 pt-6">
<p className="text-xs uppercase tracking-[0.3em] text-white/40">
Coming soon
</p>
<div className="mt-4 grid grid-cols-3 gap-3">
<button
type="button"
disabled
className="flex items-center justify-center gap-2 rounded-2xl border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium text-white/70 transition hover:bg-white/10 disabled:cursor-not-allowed"
>
<Apple className="h-4 w-4" aria-hidden="true" />
Apple
</button>
<button
type="button"
disabled
className="flex items-center justify-center gap-2 rounded-2xl border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium text-white/70 transition hover:bg-white/10 disabled:cursor-not-allowed"
>
<ChromeIcon className="h-4 w-4" />
Google
</button>
<button
type="button"
disabled
className="flex items-center justify-center gap-2 rounded-2xl border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium text-white/70 transition hover:bg-white/10 disabled:cursor-not-allowed"
>
<Github className="h-4 w-4" aria-hidden="true" />
GitHub
</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"
import BlockPage from "@/components/blocks/BlockPage"
export const Route = createFileRoute("/blocks")({
ssr: false,
component: BlockPage,
})

View File

@@ -0,0 +1,95 @@
import { useEffect, useState } from "react"
import { createFileRoute, Link } from "@tanstack/react-router"
import { BladeCanvasExperience } from "@/features/canvas/BladeCanvasExperience"
import { fetchCanvasSnapshot } from "@/lib/canvas/client"
import type { SerializedCanvas } from "@/lib/canvas/types"
export const Route = createFileRoute("/canvas/$canvasId")({
ssr: false,
component: CanvasDetailPage,
})
function CanvasDetailPage() {
const { canvasId } = Route.useParams()
const [snapshot, setSnapshot] = useState<SerializedCanvas | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let active = true
const load = async () => {
setLoading(true)
setError(null)
try {
const data = await fetchCanvasSnapshot(canvasId)
if (active) {
setSnapshot(data)
}
} catch (err) {
console.error("[canvas] failed to load snapshot", err)
if (active) {
setError("Unable to open this canvas")
setSnapshot(null)
}
} finally {
if (active) {
setLoading(false)
}
}
}
void load()
return () => {
active = false
}
}, [canvasId])
if (loading) {
return (
<div className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-[#03050a] text-white/70">
<p className="text-xs uppercase tracking-[0.4em]">Loading canvas</p>
<Link
to="/canvas"
className="text-[11px] uppercase tracking-[0.3em] text-white/40 hover:text-white"
>
Back to projects
</Link>
</div>
)
}
if (error || !snapshot) {
return (
<div className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-[#03050a] text-white">
<p className="text-lg text-white/80">{error ?? "Canvas not found."}</p>
<div className="flex gap-3">
<button
type="button"
className="rounded-full bg-white/10 px-6 py-2 text-xs font-semibold uppercase tracking-[0.3em] text-white backdrop-blur transition hover:bg-white/20"
onClick={() => window.location.reload()}
>
Retry
</button>
<Link
to="/canvas"
className="rounded-full border border-white/30 px-6 py-2 text-xs font-semibold uppercase tracking-[0.3em] text-white/80 transition hover:border-white hover:text-white"
>
Projects
</Link>
</div>
</div>
)
}
return (
<div className="h-screen w-screen overflow-hidden bg-[#01040d]">
<BladeCanvasExperience
initialCanvas={snapshot.canvas}
initialImages={snapshot.images}
/>
</div>
)
}

View File

@@ -0,0 +1,238 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
import {
createCanvasProject,
fetchCanvasList,
} from "@/lib/canvas/client"
import type {
SerializedCanvas,
SerializedCanvasSummary,
} from "@/lib/canvas/types"
export const Route = createFileRoute("/canvas/")({
ssr: false,
component: CanvasProjectsPage,
})
function summarize(snapshot: SerializedCanvas): SerializedCanvasSummary {
return {
canvas: snapshot.canvas,
previewImage: snapshot.images[0] ?? null,
imageCount: snapshot.images.length,
}
}
function CanvasProjectsPage() {
const [projects, setProjects] = useState<SerializedCanvasSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const navigate = useNavigate()
const loadProjects = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await fetchCanvasList()
setProjects(data)
} catch (err) {
console.error("[canvas] failed to load projects", err)
setError("Failed to load projects")
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadProjects()
}, [loadProjects])
const handleCreateProject = useCallback(async () => {
if (creating) {
return
}
setCreating(true)
setError(null)
try {
const snapshot = await createCanvasProject()
const summary = summarize(snapshot)
setProjects((prev) => [summary, ...prev])
navigate({ to: "/canvas/$canvasId", params: { canvasId: snapshot.canvas.id } })
} catch (err) {
console.error("[canvas] failed to create project", err)
setError("Unable to create a new project")
} finally {
setCreating(false)
}
}, [creating, navigate])
const showSkeletonGrid = loading && projects.length === 0
return (
<div className="min-h-screen bg-[#030611] px-6 py-10 text-white">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8">
<header className="flex flex-wrap items-end justify-between gap-4 border-b border-white/5 pb-6">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.4em] text-white/50">
Canvas
</p>
<h1 className="mt-2 text-4xl font-semibold tracking-tight">
My Projects
</h1>
<p className="mt-3 max-w-2xl text-sm text-white/60">
Choose a canvas to continue exploring ideas. Each project preserves
its own layout, prompts, and styles.
</p>
</div>
<div className="flex flex-col items-end gap-2 text-sm text-white/60">
{loading ? (
<span className="text-xs uppercase tracking-[0.3em] text-white/40">
Loading
</span>
) : null}
<button
type="button"
className="rounded-full bg-white/90 px-5 py-2 text-sm font-semibold uppercase tracking-[0.3em] text-slate-900 transition hover:bg-white disabled:cursor-not-allowed disabled:bg-white/40"
onClick={handleCreateProject}
disabled={creating}
>
{creating ? "Creating" : "New Project"}
</button>
</div>
</header>
{error ? (
<div className="flex items-center justify-between rounded-xl border border-red-400/40 bg-red-500/10 px-4 py-3 text-sm text-red-100">
<span>{error}</span>
<button
type="button"
className="rounded-full border border-red-200/40 px-3 py-1 text-xs uppercase tracking-[0.2em]"
onClick={() => void loadProjects()}
>
Retry
</button>
</div>
) : null}
{showSkeletonGrid ? (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="h-64 rounded-3xl border border-white/5 bg-white/5/50 animate-pulse"
/>
))}
</div>
) : (
<ProjectsGrid projects={projects} />
)}
{!projects.length && !loading ? (
<div className="rounded-3xl border border-dashed border-white/20 bg-white/5/20 px-10 py-12 text-center text-white/70">
<p className="text-lg font-semibold">You don't have any projects yet.</p>
<p className="mt-2 text-sm">
Start a new canvas to begin planning, brainstorming, or designing.
</p>
<button
type="button"
className="mt-6 rounded-full border border-white/40 px-5 py-2 text-xs font-semibold uppercase tracking-[0.3em]"
onClick={handleCreateProject}
disabled={creating}
>
{creating ? "Creating…" : "Create your first project"}
</button>
</div>
) : null}
</div>
</div>
)
}
function ProjectsGrid({
projects,
}: {
projects: SerializedCanvasSummary[]
}) {
if (!projects.length) {
return null
}
return (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<CanvasProjectCard key={project.canvas.id} project={project} />
))}
</div>
)
}
function CanvasProjectCard({
project,
}: {
project: SerializedCanvasSummary
}) {
const previewUrl = useMemo(() => {
const preview = project.previewImage
if (!preview) {
return null
}
if (preview.imageUrl) {
return preview.imageUrl
}
if (preview.imageData) {
const mime =
preview.metadata && typeof preview.metadata.mimeType === "string"
? (preview.metadata.mimeType as string)
: "image/png"
return `data:${mime};base64,${preview.imageData}`
}
return null
}, [project.previewImage])
const imageCountLabel = project.imageCount === 1 ? "image" : "images"
const updatedAt = useMemo(() => {
const date = new Date(project.canvas.updatedAt)
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})
}, [project.canvas.updatedAt])
return (
<Link
to="/canvas/$canvasId"
params={{ canvasId: project.canvas.id }}
className="group relative flex h-64 flex-col overflow-hidden rounded-3xl border border-white/10 bg-white/5 shadow-2xl ring-1 ring-white/5 transition hover:-translate-y-1 hover:border-white/30 hover:ring-white/20"
>
<div className="relative flex-1 bg-gradient-to-br from-slate-800 via-slate-900 to-black">
{previewUrl ? (
<div
className="absolute inset-0 bg-cover bg-center transition duration-500 group-hover:scale-105"
style={{ backgroundImage: `url(${previewUrl})` }}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-xs uppercase tracking-[0.3em] text-white/40">
No preview yet
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/40 to-transparent" />
</div>
<div className="relative z-10 space-y-1 px-4 pb-4 pt-3">
<div className="flex items-center justify-between text-xs uppercase tracking-[0.3em] text-white/60">
<span>{updatedAt}</span>
<span>
{project.imageCount} {imageCountLabel}
</span>
</div>
<p className="text-lg font-semibold text-white">
{project.canvas.name}
</p>
<p className="text-xs text-white/60">
Tap to open canvas
</p>
</div>
</Link>
)
}

View File

@@ -0,0 +1,10 @@
import { createFileRoute, Outlet } from "@tanstack/react-router"
export const Route = createFileRoute("/canvas")({
ssr: false,
component: CanvasLayout,
})
function CanvasLayout() {
return <Outlet />
}

View File

@@ -0,0 +1,25 @@
import { createFileRoute, redirect } from "@tanstack/react-router"
import { authClient } from "@/lib/auth-client"
import {
chatThreadsCollection,
chatMessagesCollection,
} from "@/lib/collections"
import { ChatPage } from "@/components/chat/ChatPage"
export const Route = createFileRoute("/chat")({
ssr: false,
beforeLoad: async () => {
const session = await authClient.getSession()
if (!session.data?.session) {
throw redirect({ to: "/login" })
}
},
loader: async () => {
await Promise.all([
chatThreadsCollection.preload(),
chatMessagesCollection.preload(),
])
return null
},
component: ChatPage,
})

View File

@@ -0,0 +1,10 @@
import { createFileRoute } from "@tanstack/react-router"
import { json } from "@tanstack/react-start"
export const Route = createFileRoute("/demo/api/names")({
server: {
handlers: {
GET: () => json(["Alice", "Bob", "Charlie"]),
},
},
})

View File

@@ -0,0 +1,44 @@
import { useEffect, useState } from "react"
import { createFileRoute } from "@tanstack/react-router"
function getNames() {
return fetch("/demo/api/names").then((res) => res.json() as Promise<string[]>)
}
export const Route = createFileRoute("/demo/start/api-request")({
component: Home,
})
function Home() {
const [names, setNames] = useState<Array<string>>([])
useEffect(() => {
getNames().then(setNames)
}, [])
return (
<div
className="flex items-center justify-center min-h-screen p-4 text-white"
style={{
backgroundColor: "#000",
backgroundImage:
"radial-gradient(ellipse 60% 60% at 0% 100%, #444 0%, #222 60%, #000 100%)",
}}
>
<div className="w-full max-w-2xl p-8 rounded-xl backdrop-blur-md bg-black/50 shadow-xl border-8 border-black/10">
<h1 className="text-2xl mb-4">Start API Request Demo - Names List</h1>
<ul className="mb-4 space-y-2">
{names.map((name) => (
<li
key={name}
className="bg-white/10 border border-white/20 rounded-lg p-3 backdrop-blur-sm shadow-md"
>
<span className="text-lg text-white">{name}</span>
</li>
))}
</ul>
</div>
</div>
)
}

View File

@@ -0,0 +1,41 @@
import { useState } from "react"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
const getCurrentServerTime = createServerFn({
method: "GET",
}).handler(async () => await new Date().toISOString())
export const Route = createFileRoute("/demo/start/server-funcs")({
component: Home,
loader: async () => await getCurrentServerTime(),
})
function Home() {
const originalTime = Route.useLoaderData()
const [time, setTime] = useState(originalTime)
return (
<div
className="flex items-center justify-center min-h-screen bg-gradient-to-br from-zinc-800 to-black p-4 text-white"
style={{
backgroundImage:
"radial-gradient(50% 50% at 20% 60%, #23272a 0%, #18181b 50%, #000000 100%)",
}}
>
<div className="w-full max-w-2xl p-8 rounded-xl backdrop-blur-md bg-black/50 shadow-xl border-8 border-black/10">
<h1 className="text-2xl mb-4">Start Server Functions - Server Time</h1>
<div className="flex flex-col gap-2">
<div className="text-xl">Starting Time: {originalTime}</div>
<div className="text-xl">Current Time: {time}</div>
<button
className="bg-blue-500 hover:bg-blue-600 disabled:bg-blue-500/50 disabled:cursor-not-allowed text-white font-bold py-3 px-4 rounded-lg transition-colors"
onClick={async () => setTime(await getCurrentServerTime())}
>
Refresh
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,41 @@
import { createFileRoute } from "@tanstack/react-router"
import { getPunkSongs } from "@/data/demo.punk-songs"
export const Route = createFileRoute("/demo/start/ssr/data-only")({
ssr: "data-only",
component: RouteComponent,
loader: async () => await getPunkSongs(),
})
function RouteComponent() {
const punkSongs = Route.useLoaderData()
return (
<div
className="flex items-center justify-center min-h-screen bg-gradient-to-br from-zinc-800 to-black p-4 text-white"
style={{
backgroundImage:
"radial-gradient(50% 50% at 20% 60%, #1a1a1a 0%, #0a0a0a 50%, #000000 100%)",
}}
>
<div className="w-full max-w-2xl p-8 rounded-xl backdrop-blur-md bg-black/50 shadow-xl border-8 border-black/10">
<h1 className="text-3xl font-bold mb-6 text-pink-400">
Data Only SSR - Punk Songs
</h1>
<ul className="space-y-3">
{punkSongs.map((song) => (
<li
key={song.id}
className="bg-white/10 border border-white/20 rounded-lg p-4 backdrop-blur-sm shadow-md"
>
<span className="text-lg text-white font-medium">
{song.name}
</span>
<span className="text-white/60"> - {song.artist}</span>
</li>
))}
</ul>
</div>
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { createFileRoute } from "@tanstack/react-router"
import { getPunkSongs } from "@/data/demo.punk-songs"
export const Route = createFileRoute("/demo/start/ssr/full-ssr")({
component: RouteComponent,
loader: async () => await getPunkSongs(),
})
function RouteComponent() {
const punkSongs = Route.useLoaderData()
return (
<div
className="flex items-center justify-center min-h-screen bg-gradient-to-br from-zinc-800 to-black p-4 text-white"
style={{
backgroundImage:
"radial-gradient(50% 50% at 20% 60%, #1a1a1a 0%, #0a0a0a 50%, #000000 100%)",
}}
>
<div className="w-full max-w-2xl p-8 rounded-xl backdrop-blur-md bg-black/50 shadow-xl border-8 border-black/10">
<h1 className="text-3xl font-bold mb-6 text-purple-400">
Full SSR - Punk Songs
</h1>
<ul className="space-y-3">
{punkSongs.map((song) => (
<li
key={song.id}
className="bg-white/10 border border-white/20 rounded-lg p-4 backdrop-blur-sm shadow-md"
>
<span className="text-lg text-white font-medium">
{song.name}
</span>
<span className="text-white/60"> - {song.artist}</span>
</li>
))}
</ul>
</div>
</div>
)
}

View File

@@ -0,0 +1,43 @@
import { createFileRoute, Link } from "@tanstack/react-router"
export const Route = createFileRoute("/demo/start/ssr/")({
component: RouteComponent,
})
function RouteComponent() {
return (
<div
className="flex items-center justify-center min-h-screen bg-gradient-to-br from-zinc-900 to-black p-4 text-white"
style={{
backgroundImage:
"radial-gradient(50% 50% at 20% 60%, #1a1a1a 0%, #0a0a0a 50%, #000000 100%)",
}}
>
<div className="w-full max-w-2xl p-8 rounded-xl backdrop-blur-md bg-black/50 shadow-xl border-8 border-black/10">
<h1 className="text-4xl font-bold mb-8 text-center bg-gradient-to-r from-pink-500 via-purple-500 to-green-400 bg-clip-text text-transparent">
SSR Demos
</h1>
<div className="flex flex-col gap-4">
<Link
to="/demo/start/ssr/spa-mode"
className="text-2xl font-bold py-6 px-8 rounded-lg bg-gradient-to-r from-pink-600 to-pink-500 hover:from-pink-700 hover:to-pink-600 text-white text-center shadow-lg transform transition-all hover:scale-105 hover:shadow-pink-500/50 border-2 border-pink-400"
>
SPA Mode
</Link>
<Link
to="/demo/start/ssr/full-ssr"
className="text-2xl font-bold py-6 px-8 rounded-lg bg-gradient-to-r from-purple-600 to-purple-500 hover:from-purple-700 hover:to-purple-600 text-white text-center shadow-lg transform transition-all hover:scale-105 hover:shadow-purple-500/50 border-2 border-purple-400"
>
Full SSR
</Link>
<Link
to="/demo/start/ssr/data-only"
className="text-2xl font-bold py-6 px-8 rounded-lg bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white text-center shadow-lg transform transition-all hover:scale-105 hover:shadow-green-500/50 border-2 border-green-400"
>
Data Only
</Link>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,47 @@
import { useEffect, useState } from "react"
import { createFileRoute } from "@tanstack/react-router"
import { getPunkSongs } from "@/data/demo.punk-songs"
export const Route = createFileRoute("/demo/start/ssr/spa-mode")({
ssr: false,
component: RouteComponent,
})
function RouteComponent() {
const [punkSongs, setPunkSongs] = useState<
Awaited<ReturnType<typeof getPunkSongs>>
>([])
useEffect(() => {
getPunkSongs().then(setPunkSongs)
}, [])
return (
<div
className="flex items-center justify-center min-h-screen bg-gradient-to-br from-zinc-800 to-black p-4 text-white"
style={{
backgroundImage:
"radial-gradient(50% 50% at 20% 60%, #1a1a1a 0%, #0a0a0a 50%, #000000 100%)",
}}
>
<div className="w-full max-w-2xl p-8 rounded-xl backdrop-blur-md bg-black/50 shadow-xl border-8 border-black/10">
<h1 className="text-3xl font-bold mb-6 text-green-400">
SPA Mode - Punk Songs
</h1>
<ul className="space-y-3">
{punkSongs.map((song) => (
<li
key={song.id}
className="bg-white/10 border border-white/20 rounded-lg p-4 backdrop-blur-sm shadow-md"
>
<span className="text-lg text-white font-medium">
{song.name}
</span>
<span className="text-white/60"> - {song.artist}</span>
</li>
))}
</ul>
</div>
</div>
)
}

View File

@@ -0,0 +1,10 @@
import { createFileRoute, redirect } from "@tanstack/react-router"
export const Route = createFileRoute("/i/1focus-demo")({
beforeLoad: () => {
throw redirect({
href: "https://pub-43de6862e2764ff2970a4b87f1fc7578.r2.dev/1f-demo.mp4",
})
},
component: () => null,
})

View File

@@ -0,0 +1,31 @@
import { createFileRoute } from "@tanstack/react-router"
import { ShaderBackground } from "@/components/ShaderBackground"
function LandingPage() {
return (
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-black text-white">
<ShaderBackground />
<div className="relative z-10 flex flex-col items-center">
<h1 className="text-6xl font-bold tracking-tight drop-shadow-2xl">
Linsa
</h1>
<p className="mt-4 text-xl text-white/80 drop-shadow-lg">
Save anything privately. Share it.
</p>
<p className="mt-8 text-sm text-white/50">Coming Soon</p>
<a
href="https://x.com/linsa_io"
target="_blank"
rel="noopener noreferrer"
className="mt-6 text-sm text-white/60 transition-colors hover:text-white"
>
@linsa_io
</a>
</div>
</div>
)
}
export const Route = createFileRoute("/")({
component: LandingPage,
})

View File

@@ -0,0 +1,215 @@
import { useState, useEffect, useRef } from "react"
import { createFileRoute } from "@tanstack/react-router"
import { authClient } from "@/lib/auth-client"
export const Route = createFileRoute("/login")({
component: AuthPage,
ssr: false,
})
type Step = "email" | "otp"
function AuthPage() {
const [step, setStep] = useState<Step>("email")
const emailInputRef = useRef<HTMLInputElement>(null)
const otpInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (step === "email") {
emailInputRef.current?.focus()
} else {
otpInputRef.current?.focus()
}
}, [step])
const [email, setEmail] = useState("")
const [otp, setOtp] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("")
const handleSendOTP = async (e: React.FormEvent) => {
e.preventDefault()
if (!email.trim()) return
setIsLoading(true)
setError("")
try {
const { error } = await authClient.emailOtp.sendVerificationOtp({
email,
type: "sign-in",
})
if (error) {
setError(error.message || "Failed to send code")
} else {
setStep("otp")
}
} catch (err) {
console.error("Send OTP error:", err)
setError("Failed to send verification code")
} finally {
setIsLoading(false)
}
}
const handleVerifyOTP = async (e: React.FormEvent) => {
e.preventDefault()
if (!otp.trim()) return
setIsLoading(true)
setError("")
try {
// Use signIn.emailOtp for sign-in type OTPs (not verifyEmail which is for email verification)
const { error } = await authClient.signIn.emailOtp({
email,
otp,
})
if (error) {
setError(error.message || "Invalid code")
} else {
window.location.href = "/"
}
} catch (err) {
console.error("Verify OTP error:", err)
setError("Failed to verify code")
} finally {
setIsLoading(false)
}
}
const handleResend = async () => {
setIsLoading(true)
setError("")
setOtp("")
try {
const { error } = await authClient.emailOtp.sendVerificationOtp({
email,
type: "sign-in",
})
if (error) {
setError(error.message || "Failed to resend code")
}
} catch (err) {
setError("Failed to resend code")
} finally {
setIsLoading(false)
}
}
const handleBack = () => {
setStep("email")
setOtp("")
setError("")
}
return (
<div className="min-h-screen flex items-center justify-center bg-[#050505] py-12 px-4">
<div className="max-w-sm w-full space-y-6">
<div className="text-center">
<h1 className="text-2xl font-bold text-white">
{step === "email" ? "Sign in" : "Enter code"}
</h1>
<p className="mt-2 text-sm text-neutral-400">
{step === "email"
? "Enter your email to receive a verification code"
: `We sent a 6-digit code to ${email}`}
</p>
</div>
{step === "email" ? (
<form onSubmit={handleSendOTP} className="space-y-4">
<div>
<label htmlFor="email" className="sr-only">
Email address
</label>
<input
ref={emailInputRef}
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 bg-[#18181b] border border-[#27272a] rounded-xl text-white placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
placeholder="you@example.com"
/>
</div>
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-xl">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={isLoading || !email.trim()}
className="w-full py-3 px-4 bg-teal-600 text-white font-medium rounded-xl hover:bg-teal-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-[#050505] focus:ring-teal-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? "Sending..." : "Continue"}
</button>
</form>
) : (
<form onSubmit={handleVerifyOTP} className="space-y-4">
<div>
<label htmlFor="otp" className="sr-only">
Verification code
</label>
<input
ref={otpInputRef}
id="otp"
name="otp"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
required
maxLength={6}
value={otp}
onChange={(e) => setOtp(e.target.value.replace(/\D/g, ""))}
className="w-full px-4 py-3 bg-[#18181b] border border-[#27272a] rounded-xl text-white text-center text-2xl tracking-widest font-mono placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
placeholder="000000"
/>
</div>
{error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-xl">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={isLoading || otp.length !== 6}
className="w-full py-3 px-4 bg-teal-600 text-white font-medium rounded-xl hover:bg-teal-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-[#050505] focus:ring-teal-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? "Verifying..." : "Sign in"}
</button>
<div className="flex items-center justify-between text-sm">
<button
type="button"
onClick={handleBack}
className="text-neutral-400 hover:text-white transition-colors"
>
Back
</button>
<button
type="button"
onClick={handleResend}
disabled={isLoading}
className="text-neutral-400 hover:text-white disabled:opacity-50 transition-colors"
>
Resend code
</button>
</div>
</form>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"
import MarketplacePage from "@/components/blocks/MarketplacePage"
export const Route = createFileRoute("/marketplace")({
ssr: false,
component: MarketplacePage,
})

View File

@@ -0,0 +1,390 @@
import { useState, useEffect, useCallback } from "react"
import { createFileRoute, Link } from "@tanstack/react-router"
import { authClient } from "@/lib/auth-client"
import {
Search,
Star,
ExternalLink,
ChevronDown,
ChevronRight,
Trash2,
Clock,
Globe,
} from "lucide-react"
import type { BrowserSession, BrowserSessionTab } from "@/db/schema"
export const Route = createFileRoute("/sessions")({
component: BrowserSessionsPage,
ssr: false,
})
interface SessionWithTabs extends BrowserSession {
tabs?: BrowserSessionTab[]
}
interface PaginationInfo {
page: number
limit: number
total: number
totalPages: number
}
function getDomain(url: string): string {
try {
return new URL(url).hostname.replace("www.", "")
} catch {
return url
}
}
function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})
}
function SessionCard({
session,
onToggleFavorite,
onDelete,
}: {
session: SessionWithTabs
onToggleFavorite: (id: string, isFavorite: boolean) => void
onDelete: (id: string) => void
}) {
const [expanded, setExpanded] = useState(false)
const [tabs, setTabs] = useState<BrowserSessionTab[]>([])
const [loading, setLoading] = useState(false)
const loadTabs = useCallback(async () => {
if (tabs.length > 0) return
setLoading(true)
try {
const res = await fetch(`/api/browser-sessions/${session.id}`)
if (res.ok) {
const data = await res.json()
setTabs(data.tabs || [])
}
} catch (error) {
console.error("Failed to load tabs:", error)
} finally {
setLoading(false)
}
}, [session.id, tabs.length])
const handleExpand = () => {
if (!expanded) {
loadTabs()
}
setExpanded(!expanded)
}
return (
<div className="bg-zinc-900/50 border border-zinc-800 rounded-xl overflow-hidden">
<div
className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-zinc-800/50 transition-colors"
onClick={handleExpand}
>
<button
type="button"
className="p-1 text-zinc-500 hover:text-zinc-300"
onClick={(e) => {
e.stopPropagation()
handleExpand()
}}
>
{expanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-zinc-200 truncate">
{session.name}
</p>
<div className="flex items-center gap-2 text-xs text-zinc-500 mt-0.5">
<Clock className="w-3 h-3" />
<span>{formatDate(session.captured_at)}</span>
<span className="text-zinc-600">|</span>
<Globe className="w-3 h-3" />
<span>{session.browser}</span>
<span className="text-zinc-600">|</span>
<span>{session.tab_count} tabs</span>
</div>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onToggleFavorite(session.id, !session.is_favorite)
}}
className={`p-1.5 rounded-lg transition-colors ${
session.is_favorite
? "text-yellow-400 hover:text-yellow-300"
: "text-zinc-600 hover:text-zinc-400"
}`}
title={session.is_favorite ? "Remove from favorites" : "Add to favorites"}
>
<Star className="w-4 h-4" fill={session.is_favorite ? "currentColor" : "none"} />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
if (confirm("Delete this session?")) {
onDelete(session.id)
}
}}
className="p-1.5 rounded-lg text-zinc-600 hover:text-red-400 transition-colors"
title="Delete session"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{expanded && (
<div className="border-t border-zinc-800 px-4 py-3">
{loading ? (
<p className="text-sm text-zinc-500">Loading tabs...</p>
) : tabs.length === 0 ? (
<p className="text-sm text-zinc-500">No tabs found</p>
) : (
<ul className="space-y-1">
{tabs.map((tab, idx) => (
<li key={tab.id} className="flex items-start gap-2">
<span className="text-xs text-zinc-600 font-mono w-5 text-right shrink-0 pt-1">
{idx + 1}
</span>
<a
href={tab.url}
target="_blank"
rel="noopener noreferrer"
className="flex-1 min-w-0 group py-1 px-2 -mx-2 rounded hover:bg-zinc-800/50 transition-colors"
>
<p className="text-sm text-zinc-300 truncate group-hover:text-white">
{tab.title || tab.url}
</p>
<p className="text-xs text-zinc-600 truncate">
{getDomain(tab.url)}
</p>
</a>
<a
href={tab.url}
target="_blank"
rel="noopener noreferrer"
className="p-1 text-zinc-600 hover:text-zinc-400 shrink-0"
>
<ExternalLink className="w-3 h-3" />
</a>
</li>
))}
</ul>
)}
</div>
)}
</div>
)
}
function BrowserSessionsPage() {
const { data: session, isPending: authPending } = authClient.useSession()
const [sessions, setSessions] = useState<SessionWithTabs[]>([])
const [pagination, setPagination] = useState<PaginationInfo>({
page: 1,
limit: 50,
total: 0,
totalPages: 0,
})
const [search, setSearch] = useState("")
const [loading, setLoading] = useState(true)
const fetchSessions = useCallback(
async (page = 1, searchQuery = "") => {
setLoading(true)
try {
const body = {
action: "list" as const,
page,
limit: pagination.limit,
search: searchQuery || undefined,
}
const res = await fetch("/api/browser-sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (res.ok) {
const data = await res.json()
setSessions(data.sessions || [])
setPagination(data.pagination)
}
} catch (error) {
console.error("Failed to fetch sessions:", error)
} finally {
setLoading(false)
}
},
[pagination.limit],
)
useEffect(() => {
if (session?.user) {
fetchSessions(1, search)
}
}, [session?.user, fetchSessions, search])
const handleToggleFavorite = async (id: string, isFavorite: boolean) => {
try {
const res = await fetch(`/api/browser-sessions/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ is_favorite: isFavorite }),
})
if (res.ok) {
setSessions((prev) =>
prev.map((s) => (s.id === id ? { ...s, is_favorite: isFavorite } : s)),
)
}
} catch (error) {
console.error("Failed to update favorite:", error)
}
}
const handleDelete = async (id: string) => {
try {
const res = await fetch(`/api/browser-sessions/${id}`, {
method: "DELETE",
})
if (res.ok) {
setSessions((prev) => prev.filter((s) => s.id !== id))
setPagination((prev) => ({ ...prev, total: prev.total - 1 }))
}
} catch (error) {
console.error("Failed to delete session:", error)
}
}
if (authPending) {
return (
<div className="min-h-screen bg-black text-white grid place-items-center">
<p className="text-zinc-500">Loading...</p>
</div>
)
}
if (!session?.user) {
return (
<div className="min-h-screen bg-black text-white grid place-items-center">
<div className="text-center">
<p className="text-zinc-400 mb-4">Sign in to view your browser sessions</p>
<Link
to="/login"
className="inline-block px-4 py-2 bg-white text-black rounded-lg font-medium hover:bg-zinc-200 transition-colors"
>
Sign in
</Link>
</div>
</div>
)
}
// Group sessions by date
const sessionsByDate = sessions.reduce(
(acc, s) => {
const date = formatDate(s.captured_at)
if (!acc[date]) acc[date] = []
acc[date].push(s)
return acc
},
{} as Record<string, SessionWithTabs[]>,
)
return (
<div className="min-h-screen bg-black text-white">
<div className="max-w-4xl mx-auto px-4 py-8">
<header className="mb-8">
<h1 className="text-2xl font-bold mb-2">Browser Sessions</h1>
<p className="text-zinc-500">
{pagination.total} sessions saved
</p>
</header>
{/* Search */}
<div className="relative mb-6">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search sessions..."
className="w-full pl-10 pr-4 py-2.5 bg-zinc-900 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-none focus:border-zinc-700 focus:ring-1 focus:ring-zinc-700"
/>
</div>
{/* Sessions list */}
{loading ? (
<div className="text-center py-12 text-zinc-500">Loading sessions...</div>
) : sessions.length === 0 ? (
<div className="text-center py-12 text-zinc-500">
{search ? `No sessions found for "${search}"` : "No sessions yet"}
</div>
) : (
<div className="space-y-6">
{Object.entries(sessionsByDate).map(([date, dateSessions]) => (
<section key={date}>
<h2 className="text-sm font-medium text-zinc-400 mb-3">{date}</h2>
<div className="space-y-2">
{dateSessions.map((s) => (
<SessionCard
key={s.id}
session={s}
onToggleFavorite={handleToggleFavorite}
onDelete={handleDelete}
/>
))}
</div>
</section>
))}
</div>
)}
{/* Pagination */}
{pagination.totalPages > 1 && (
<nav className="flex items-center justify-center gap-2 mt-8">
<button
type="button"
onClick={() => fetchSessions(pagination.page - 1, search)}
disabled={pagination.page <= 1}
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg text-sm transition-colors"
>
Previous
</button>
<span className="px-3 text-sm text-zinc-500">
Page {pagination.page} of {pagination.totalPages}
</span>
<button
type="button"
onClick={() => fetchSessions(pagination.page + 1, search)}
disabled={pagination.page >= pagination.totalPages}
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg text-sm transition-colors"
>
Next
</button>
</nav>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,766 @@
import { useMemo, useState, type FormEvent, type ReactNode } from "react"
import { createFileRoute } from "@tanstack/react-router"
import { authClient } from "@/lib/auth-client"
import SettingsPanel from "@/components/Settings-panel"
import {
BadgeDollarSign,
Check,
ChevronDown,
CreditCard,
Gem,
LogOut,
Shield,
Sparkles,
UserRoundPen,
Lock,
X,
} from "lucide-react"
import { BillingStatusNew } from "@/components/billing"
type SectionId = "preferences" | "profile" | "billing"
const PLAN_CARD_NOISE =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.18'/%3E%3C/svg%3E"
export const Route = createFileRoute("/settings")({
component: SettingsPage,
ssr: false,
})
const CHANGE_PLAN_BACKGROUND =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.18'/%3E%3C/svg%3E"
type Option = { value: string; label: string }
function InlineSelect({
value,
options,
onChange,
}: {
value: string
options: Option[]
onChange: (value: string) => void
}) {
return (
<div className="relative">
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="appearance-none bg-white/5 border border-white/10 text-white text-sm pl-3 pr-8 py-2 rounded-xl focus:outline-none focus:ring-2 focus:ring-teal-500"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
</div>
)
}
function ToggleSwitch({
checked,
onChange,
}: {
checked: boolean
onChange: (next: boolean) => void
}) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className={`w-11 h-6 rounded-full transition-colors duration-200 relative ${
checked ? "bg-teal-500" : "bg-white/10"
}`}
>
<span
className={`absolute top-[3px] left-[3px] h-5 w-5 rounded-full bg-white shadow transition-transform duration-200 ${
checked ? "translate-x-[22px]" : "translate-x-0"
}`}
/>
</button>
)
}
function SettingRow({
title,
description,
control,
}: {
title: string
description: string
control?: ReactNode
}) {
return (
<div className="flex items-center justify-between gap-4 py-3">
<div className="flex-1 flex flex-col gap-2 min-w-0">
<p className="text-sm font-medium text-white">{title}</p>
<p className="text-xs text-white/70 mt-0.5">{description}</p>
</div>
{control ? <div className="shrink-0">{control}</div> : null}
</div>
)
}
function SettingCard({
title,
children,
}: {
title: string
children: ReactNode
}) {
return (
<div className="bg-[#0c0f18] border border-white/5 rounded-2xl p-5 shadow-[0_12px_40px_-24px_rgba(0,0,0,0.7)]">
<h3 className="text-md font-semibold text-white mb-3">{title}</h3>
<div className="divide-y divide-white/5">{children}</div>
</div>
)
}
function Modal({
title,
description,
onClose,
children,
}: {
title: string
description?: string
onClose: () => void
children: ReactNode
}) {
return (
<div
className="fixed inset-0 z-50 grid place-items-center bg-black/30 backdrop-blur-sm"
onClick={onClose}
>
<div
className="relative p-8 bg-[#0c0f18] max-w-xl mx-auto flex flex-col gap-2 border border-white/10 rounded-2xl shadow-[0_16px_60px_rgba(0,0,0,0.6)] w-full animate-in fade-in-0 zoom-in-95 duration-300"
onClickCapture={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold text-white">{title}</h3>
{description ? (
<p className="text-md text-white/85 font-medium mt-1">
{description}
</p>
) : null}
<div className="mt-4 space-y-4">{children}</div>
</div>
</div>
)
}
function SectionHeader({
title,
description,
}: {
title: string
description?: string
}) {
return (
<div className="mb-4">
<h2 className="text-xl font-semibold text-white">{title}</h2>
{description ? (
<p className="text-sm text-white/70 mt-1">{description}</p>
) : null}
</div>
)
}
function PreferencesSection() {
const [homeView, setHomeView] = useState("Active issues")
const [displayFullNames, setDisplayFullNames] = useState(false)
const [firstDay, setFirstDay] = useState("Sunday")
const [convertEmojis, setConvertEmojis] = useState(true)
const [sidebar, setSidebar] = useState("Customize")
const [fontSize, setFontSize] = useState("Default")
const [pointerCursor, setPointerCursor] = useState(false)
const [theme, setTheme] = useState("System preference")
const [lightTheme, setLightTheme] = useState("Light")
const [darkTheme, setDarkTheme] = useState("Dark")
return (
<div id="preferences" className="scroll-mt-24">
<SectionHeader
title="Preferences"
description="Tune how your workspace looks and behaves."
/>
<div className="space-y-5">
<SettingCard title="General">
<SettingRow
title="Default home view"
description="Choose what opens first when you launch the app."
control={
<InlineSelect
value={homeView}
options={[
{ value: "Active issues", label: "Active issues" },
{ value: "All issues", label: "All issues" },
{ value: "My tasks", label: "My tasks" },
]}
onChange={setHomeView}
/>
}
/>
<SettingRow
title="Display full names"
description="Show full names instead of short handles."
control={
<ToggleSwitch
checked={displayFullNames}
onChange={setDisplayFullNames}
/>
}
/>
<SettingRow
title="First day of the week"
description="Used across date pickers."
control={
<InlineSelect
value={firstDay}
options={[
{ value: "Sunday", label: "Sunday" },
{ value: "Monday", label: "Monday" },
]}
onChange={setFirstDay}
/>
}
/>
<SettingRow
title="Convert emoticons to emoji"
description="Strings like :) will be rendered as emoji."
control={
<ToggleSwitch
checked={convertEmojis}
onChange={setConvertEmojis}
/>
}
/>
</SettingCard>
<SettingCard title="Appearance">
<SettingRow
title="Interface theme"
description="Select or customize your color scheme."
control={
<InlineSelect
value={theme}
options={[
{ value: "System preference", label: "System preference" },
{ value: "Light", label: "Light" },
{ value: "Dark", label: "Dark" },
]}
onChange={setTheme}
/>
}
/>
<SettingRow
title="Font size"
description="Adjust text size across the app."
control={
<InlineSelect
value={fontSize}
options={[
{ value: "Default", label: "Default" },
{ value: "Large", label: "Large" },
{ value: "Compact", label: "Compact" },
]}
onChange={setFontSize}
/>
}
/>
</SettingCard>
</div>
</div>
)
}
function ProfileSection({
profile,
onLogout,
onChangeEmail,
onChangePassword,
}: {
profile: { name?: string | null; email: string; username?: string | null } | null | undefined
onLogout: () => Promise<void>
onChangeEmail: () => void
onChangePassword: () => void
}) {
const [username, setUsername] = useState(profile?.username ?? "")
const [name, setName] = useState(profile?.name ?? "")
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
const initials = useMemo(() => {
if (!profile) return "G"
return (
profile.name?.slice(0, 1) ??
profile.email?.slice(0, 1)?.toUpperCase() ??
"G"
)
}, [profile])
const handleSaveProfile = async () => {
setSaving(true)
setError(null)
setSaved(false)
try {
const res = await fetch("/api/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name, username: username.toLowerCase() }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || "Failed to save")
} else {
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
} catch {
setError("Network error")
} finally {
setSaving(false)
}
}
const hasChanges = username !== (profile?.username ?? "") || name !== (profile?.name ?? "")
return (
<div id="profile" className="scroll-mt-24">
<SectionHeader
title="Profile"
description="Manage your account details and security."
/>
<div className="space-y-5">
<SettingCard title="Account">
<div className="flex items-center gap-4 py-2">
<div className="w-11 h-11 rounded-full bg-white/10 grid place-items-center text-lg font-semibold text-white">
{initials}
</div>
<div className="flex-1">
<p className="text-md text-white font-semibold">
{profile?.name ?? "Guest user"}
</p>
<p className="text-md text-white/85">{profile?.email ?? "-"}</p>
</div>
<button
type="button"
onClick={onChangeEmail}
className="inline-flex items-center gap-2 text-sm bg-white/5 hover:bg-white/10 text-white px-3 py-2 rounded-lg border border-white/10 transition-colors"
>
<UserRoundPen className="w-4 h-4" />
Change email
</button>
</div>
</SettingCard>
<SettingCard title="Public Profile">
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm text-white/70">Display Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your name"
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500"
/>
</div>
<div className="space-y-2">
<label className="text-sm text-white/70">Username</label>
<div className="flex items-center gap-2">
<span className="text-white/50">linsa.io/</span>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ""))}
placeholder="username"
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500"
/>
</div>
<p className="text-xs text-white/50">
This is your public stream URL. Only lowercase letters, numbers, hyphens, and underscores.
</p>
</div>
{error && <p className="text-sm text-rose-400">{error}</p>}
<div className="flex justify-end gap-2">
{saved && <span className="text-sm text-teal-400 flex items-center gap-1"><Check className="w-4 h-4" /> Saved</span>}
<button
type="button"
onClick={handleSaveProfile}
disabled={saving || !hasChanges}
className="px-4 py-2 rounded-lg text-sm font-semibold text-white bg-teal-600 hover:bg-teal-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{saving ? "Saving..." : "Save Profile"}
</button>
</div>
</div>
</SettingCard>
<SettingCard title="Password">
<SettingRow
title="Password"
description="Change your password."
control={
<button
type="button"
onClick={onChangePassword}
className="text-sm flex items-center gap-2 bg-white/5 hover:bg-white/10 text-white px-3 py-2 rounded-lg border border-white/10 transition-colors"
>
<Lock className="w-4 h-4" />
Change
</button>
}
/>
</SettingCard>
<SettingCard title="Workspace access">
<div className="flex items-start justify-between">
<div className="flex flex-col gap-2 text-sm text-white/70">
<p className="font-medium text-white">Sign out</p>
<p className="text-xs text-white/70">
Revoke access on this device.
</p>
</div>
<button
type="button"
onClick={onLogout}
className="inline-flex items-center gap-2 text-sm font-medium text-rose-600 hover:bg-rose-600/10 hover:text-rose-500 rounded-lg px-4 py-2 cursor-pointer transition-colors"
>
<LogOut className="w-4 h-4" />
Leave
</button>
</div>
</SettingCard>
</div>
</div>
)
}
function UsageBar({
icon,
label,
current,
total,
gradient,
}: {
icon: ReactNode
label: string
current: number
total: number
gradient: string
}) {
const pct = Math.min(100, Math.round((current / total) * 100))
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-slate-200">
<div className="text-lg">{icon}</div>
<span className="font-semibold text-white">{current}</span>
<span className="text-slate-400">
/ {total.toLocaleString()} {label}
</span>
</div>
<div className="h-3 bg-white/5 rounded-full overflow-hidden">
<div
className="h-full rounded-full"
style={{
width: `${pct}%`,
background: gradient,
}}
/>
</div>
</div>
)
}
function PlanCard({
name,
price,
cadence,
brand,
last4,
active,
badge,
gradient,
}: {
name: string
price: string
cadence: string
brand: string
last4: string
active?: boolean
badge?: string
gradient: string
}) {
const cardBackground = gradient
return (
<div
className={`rounded-3xl w-full p-5 border relative overflow-hidden ${
active ? "border-white/10" : "border-white/5"
}`}
style={{
backgroundImage: `url("${PLAN_CARD_NOISE}")`,
backgroundColor: cardBackground,
backgroundBlendMode: "overlay, normal",
backgroundSize: "280px 280px, cover",
backgroundRepeat: "repeat, no-repeat",
opacity: active ? 1 : 0.6,
}}
>
{badge ? (
<span className="absolute top-3 right-3 text-[11px] px-3 py-1 rounded-lg bg-white/10 text-white/80 border border-white/10">
{badge}
</span>
) : null}
<div className="flex items-center gap-2 text-2xl font-semibold text-white">
<Sparkles className="w-5 h-5 text-pink-200" />
<span>{name}</span>
</div>
<p className="text-slate-200 mt-1">{price}</p>
<div className="flex items-center gap-3 text-sm text-slate-100 mt-6">
<span>{cadence}</span>
<span className="text-white/40"></span>
<span className="inline-flex items-center gap-2">
<CreditCard className="w-4 h-4" />
{brand} {last4}
</span>
</div>
{!active ? (
<div className="absolute inset-0 bg-black/35 backdrop-blur-[1px]" />
) : null}
</div>
)
}
function BillingSection() {
return (
<div id="billing" className="scroll-mt-24 mx-auto">
<SectionHeader
title="Subscription"
description="Current plan, upcoming tiers, and credit usage."
/>
<div className="flex flex-row gap-8 w-full">
<div className="flex flex-col gap-4 w-full">
<PlanCard
name="Gen Pro"
price="$7.99 / month"
cadence="Monthly"
brand="Visa"
last4="1777"
active
badge="Current plan"
gradient="linear-gradient(135deg, #aa5ea4 0%, #2d254a 40%, #494281 90%)"
/>
<PlanCard
name="Gen Teams"
price="$19.99 / month"
cadence="Team plan — coming soon"
brand="—"
last4="0000"
gradient="linear-gradient(135deg, #000000 0%, #2d254a 40%, #494281 90%)"
badge="Coming soon"
/>
</div>
<div className="space-y-6 self-stretch w-full h-fit">
<UsageBar
icon={<BadgeDollarSign className="w-4 h-4 text-white/80" />}
label="standard credits"
current={875}
total={1000}
gradient="linear-gradient(90deg, #7da2ff, #a36bff, #d870ff)"
/>
<UsageBar
icon={<Gem className="w-4 h-4 text-white/80" />}
label="premium credits"
current={56}
total={100}
gradient="linear-gradient(90deg, #ff7dcf, #7df3ff, #4f5bff)"
/>
<div className="flex flex-row gap-2">
<button
type="button"
style={{
backgroundImage: `url("${CHANGE_PLAN_BACKGROUND}")`,
}}
className="w-full text-sm font-medium text-white bg-white/5 shadow-inner shadow-neutral-800/65 hover:bg-white/10 border border-white/10 transition-colors rounded-lg py-2 bg-cover bg-center bg-no-repeat"
>
Change plan
</button>
<button
type="button"
style={{
backgroundImage: `url("${CHANGE_PLAN_BACKGROUND}")`,
}}
className="w-full text-sm font-medium text-white bg-blue-200/15 hover:bg-blue-100/20 shadow-inner shadow-neutral-800/65 border border-white/5 transition-colors rounded-lg py-2 bg-cover bg-center bg-no-repeat"
>
Get more credits
</button>
</div>
</div>
</div>
<BillingStatusNew />
</div>
)
}
function SettingsPage() {
const { data: session, isPending } = authClient.useSession()
const [activeSection, setActiveSection] = useState<SectionId>("preferences")
const [showEmailModal, setShowEmailModal] = useState(false)
const [showPasswordModal, setShowPasswordModal] = useState(false)
const [emailInput, setEmailInput] = useState("")
const [currentPassword, setCurrentPassword] = useState("")
const [newPassword, setNewPassword] = useState("")
const handleLogout = async () => {
await authClient.signOut()
window.location.href = "/"
}
const openEmailModal = () => {
setEmailInput(session?.user?.email ?? "")
setShowEmailModal(true)
}
const openPasswordModal = () => {
setCurrentPassword("")
setNewPassword("")
setShowPasswordModal(true)
}
const handleEmailSubmit = (event: FormEvent) => {
event.preventDefault()
setShowEmailModal(false)
}
const handlePasswordSubmit = (event: FormEvent) => {
event.preventDefault()
setShowPasswordModal(false)
setCurrentPassword("")
setNewPassword("")
}
if (isPending) {
return (
<div className="min-h-screen text-white grid place-items-center">
<p className="text-slate-400">Loading settings</p>
</div>
)
}
return (
<>
<div className="min-h-screen max-w-5xl mx-auto text-white">
<div className="max-w-6xl mx-auto px-4 md:px-6 py-10 flex gap-6">
<SettingsPanel
activeSection={activeSection}
onSelect={setActiveSection}
profile={session?.user}
/>
<div className="flex-1 space-y-12 overflow-auto pr-1 pb-12">
{activeSection === "preferences" ? (
<PreferencesSection />
) : activeSection === "profile" ? (
<ProfileSection
profile={session?.user}
onLogout={handleLogout}
onChangeEmail={openEmailModal}
onChangePassword={openPasswordModal}
/>
) : activeSection === "billing" ? (
<BillingSection />
) : null}
</div>
</div>
</div>
{showEmailModal ? (
<Modal
title="Change email"
description="Enter the new email address you would like to use."
onClose={() => setShowEmailModal(false)}
>
<form onSubmit={handleEmailSubmit} className="space-y-4">
<input
type="email"
required
value={emailInput}
onChange={(event) => setEmailInput(event.target.value)}
className="w-full bg-white/2 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-teal-100/40 focus:border-transparent"
placeholder="email@example.com"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setShowEmailModal(false)}
className="px-4 py-2 rounded-lg text-sm text-slate-200 bg-white/5 hover:bg-white/10 border border-white/10 transition-colors"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 rounded-lg text-sm font-semibold text-white bg-teal-600 hover:bg-teal-500 transition-colors"
>
Save email
</button>
</div>
</form>
</Modal>
) : null}
{showPasswordModal ? (
<Modal
title="Change password"
description="Confirm your current password and set a new one."
onClose={() => setShowPasswordModal(false)}
>
<form onSubmit={handlePasswordSubmit} className="space-y-4">
<label className="block space-y-2">
<span className="text-sm text-slate-300">Current password</span>
<input
type="password"
required
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
placeholder="Enter your current password"
/>
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300">New password</span>
<input
type="password"
required
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
placeholder="Create a new password"
/>
</label>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setShowPasswordModal(false)}
className="px-4 py-2 rounded-lg text-sm text-slate-200 bg-white/5 hover:bg-white/10 border border-white/10 transition-colors"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 rounded-lg text-sm font-semibold text-white bg-teal-600 hover:bg-teal-500 transition-colors"
>
Save password
</button>
</div>
</form>
</Modal>
) : null}
</>
)
}

View File

@@ -0,0 +1,89 @@
import { useEffect } from "react"
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"
import { useLiveQuery } from "@tanstack/react-db"
import { authClient } from "@/lib/auth-client"
import { usersCollection } from "@/lib/collections"
export const Route = createFileRoute("/users")({
ssr: false,
beforeLoad: async () => {
const res = await authClient.getSession()
if (!res.data?.session) {
throw redirect({ to: "/login" })
}
},
loader: async () => {
await usersCollection.preload()
return null
},
component: UsersPage,
})
function UsersPage() {
const navigate = useNavigate()
const { data: session } = authClient.useSession()
const { data: users } = useLiveQuery((q) => q.from({ usersCollection }))
useEffect(() => {
if (!session?.session) {
navigate({ to: "/login" })
}
}, [navigate, session])
if (!session?.session) {
return null
}
return (
<div className="min-h-screen bg-slate-950 text-white px-4 py-10">
<div className="max-w-3xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<p className="text-sm text-slate-400">Signed in as</p>
<p className="text-lg font-semibold">
{session.user.email ?? session.user.id}
</p>
</div>
<button
className="rounded-lg bg-slate-800 border border-slate-700 px-3 py-2 text-sm hover:border-cyan-400 transition-colors"
onClick={async () => {
await authClient.signOut()
navigate({ to: "/login" })
}}
>
Sign out
</button>
</div>
<div className="rounded-xl border border-slate-800 bg-slate-900">
<div className="border-b border-slate-800 px-4 py-3 flex items-center justify-between">
<h2 className="text-lg font-semibold">Users (Electric)</h2>
<span className="text-xs text-slate-400">
Live-synced via Electric shape
</span>
</div>
<div className="divide-y divide-slate-800">
{users?.map((user) => (
<div
key={user.id}
className="px-4 py-3 flex items-center justify-between"
>
<div>
<p className="font-medium">{user.name || user.email}</p>
<p className="text-sm text-slate-400">{user.email}</p>
</div>
<span className="text-xs text-slate-500">{user.id}</span>
</div>
))}
{!users?.length ? (
<div className="px-4 py-6 text-center text-slate-400">
No users yet. Create an account from the login screen to seed
data.
</div>
) : null}
</div>
</div>
</div>
</div>
)
}