mirror of
https://github.com/linsa-io/linsa.git
synced 2026-04-27 02:38:45 +02:00
.
This commit is contained in:
137
packages/web/src/routes/$username.tsx
Normal file
137
packages/web/src/routes/$username.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
103
packages/web/src/routes/__root.tsx
Normal file
103
packages/web/src/routes/__root.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
packages/web/src/routes/api/auth/$.ts
Normal file
67
packages/web/src/routes/api/auth/$.ts
Normal 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" },
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
141
packages/web/src/routes/api/browser-sessions.$sessionId.ts
Normal file
141
packages/web/src/routes/api/browser-sessions.$sessionId.ts
Normal 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 })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
364
packages/web/src/routes/api/browser-sessions.ts
Normal file
364
packages/web/src/routes/api/browser-sessions.ts
Normal 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),
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
46
packages/web/src/routes/api/canvas.$canvasId.ts
Normal file
46
packages/web/src/routes/api/canvas.$canvasId.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
178
packages/web/src/routes/api/canvas.images.$imageId.generate.ts
Normal file
178
packages/web/src/routes/api/canvas.images.$imageId.generate.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
96
packages/web/src/routes/api/canvas.images.$imageId.ts
Normal file
96
packages/web/src/routes/api/canvas.images.$imageId.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
64
packages/web/src/routes/api/canvas.images.ts
Normal file
64
packages/web/src/routes/api/canvas.images.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
99
packages/web/src/routes/api/canvas.ts
Normal file
99
packages/web/src/routes/api/canvas.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
56
packages/web/src/routes/api/chat-messages.ts
Normal file
56
packages/web/src/routes/api/chat-messages.ts
Normal 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),
|
||||
},
|
||||
},
|
||||
})
|
||||
44
packages/web/src/routes/api/chat-threads.ts
Normal file
44
packages/web/src/routes/api/chat-threads.ts
Normal 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),
|
||||
},
|
||||
},
|
||||
})
|
||||
191
packages/web/src/routes/api/chat/ai.ts
Normal file
191
packages/web/src/routes/api/chat/ai.ts
Normal 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
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
106
packages/web/src/routes/api/chat/guest.ts
Normal file
106
packages/web/src/routes/api/chat/guest.ts
Normal 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
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
139
packages/web/src/routes/api/chat/mutations.ts
Normal file
139
packages/web/src/routes/api/chat/mutations.ts
Normal 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" },
|
||||
})
|
||||
349
packages/web/src/routes/api/context-items.ts
Normal file
349
packages/web/src/routes/api/context-items.ts
Normal 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))
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
71
packages/web/src/routes/api/flowglad/$.ts
Normal file
71
packages/web/src/routes/api/flowglad/$.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
172
packages/web/src/routes/api/profile.ts
Normal file
172
packages/web/src/routes/api/profile.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
130
packages/web/src/routes/api/stream.ts
Normal file
130
packages/web/src/routes/api/stream.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
101
packages/web/src/routes/api/streams.$username.ts
Normal file
101
packages/web/src/routes/api/streams.$username.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
136
packages/web/src/routes/api/usage-events.create.ts
Normal file
136
packages/web/src/routes/api/usage-events.create.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
119
packages/web/src/routes/api/usage-events.ts
Normal file
119
packages/web/src/routes/api/usage-events.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
33
packages/web/src/routes/api/users.ts
Normal file
33
packages/web/src/routes/api/users.ts
Normal 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),
|
||||
},
|
||||
},
|
||||
})
|
||||
291
packages/web/src/routes/auth.tsx
Normal file
291
packages/web/src/routes/auth.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
7
packages/web/src/routes/blocks.tsx
Normal file
7
packages/web/src/routes/blocks.tsx
Normal 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,
|
||||
})
|
||||
95
packages/web/src/routes/canvas.$canvasId.tsx
Normal file
95
packages/web/src/routes/canvas.$canvasId.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
238
packages/web/src/routes/canvas.index.tsx
Normal file
238
packages/web/src/routes/canvas.index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
10
packages/web/src/routes/canvas.tsx
Normal file
10
packages/web/src/routes/canvas.tsx
Normal 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 />
|
||||
}
|
||||
25
packages/web/src/routes/chat.tsx
Normal file
25
packages/web/src/routes/chat.tsx
Normal 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,
|
||||
})
|
||||
10
packages/web/src/routes/demo/api.names.ts
Normal file
10
packages/web/src/routes/demo/api.names.ts
Normal 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"]),
|
||||
},
|
||||
},
|
||||
})
|
||||
44
packages/web/src/routes/demo/start.api-request.tsx
Normal file
44
packages/web/src/routes/demo/start.api-request.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
packages/web/src/routes/demo/start.server-funcs.tsx
Normal file
41
packages/web/src/routes/demo/start.server-funcs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
packages/web/src/routes/demo/start.ssr.data-only.tsx
Normal file
41
packages/web/src/routes/demo/start.ssr.data-only.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
packages/web/src/routes/demo/start.ssr.full-ssr.tsx
Normal file
40
packages/web/src/routes/demo/start.ssr.full-ssr.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
packages/web/src/routes/demo/start.ssr.index.tsx
Normal file
43
packages/web/src/routes/demo/start.ssr.index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
packages/web/src/routes/demo/start.ssr.spa-mode.tsx
Normal file
47
packages/web/src/routes/demo/start.ssr.spa-mode.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
10
packages/web/src/routes/i.1focus-demo.tsx
Normal file
10
packages/web/src/routes/i.1focus-demo.tsx
Normal 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,
|
||||
})
|
||||
31
packages/web/src/routes/index.tsx
Normal file
31
packages/web/src/routes/index.tsx
Normal 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,
|
||||
})
|
||||
215
packages/web/src/routes/login.tsx
Normal file
215
packages/web/src/routes/login.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
7
packages/web/src/routes/marketplace.tsx
Normal file
7
packages/web/src/routes/marketplace.tsx
Normal 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,
|
||||
})
|
||||
390
packages/web/src/routes/sessions.tsx
Normal file
390
packages/web/src/routes/sessions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
766
packages/web/src/routes/settings.tsx
Normal file
766
packages/web/src/routes/settings.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
89
packages/web/src/routes/users.tsx
Normal file
89
packages/web/src/routes/users.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user