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

View File

@@ -0,0 +1,28 @@
import { FlowgladProvider } from "@flowglad/react"
import { authClient } from "@/lib/auth-client"
type BillingProviderProps = {
children: React.ReactNode
}
export function BillingProvider({ children }: BillingProviderProps) {
const flowgladEnabled = import.meta.env.VITE_FLOWGLAD_ENABLED === "true"
// Skip billing entirely when Flowglad isn't configured
if (!flowgladEnabled) {
return <>{children}</>
}
const { data: session, isPending } = authClient.useSession()
// Don't load billing until we know auth state
if (isPending) {
return <>{children}</>
}
return (
<FlowgladProvider loadBilling={!!session?.user} serverRoute="/api/flowglad">
{children}
</FlowgladProvider>
)
}

View File

@@ -0,0 +1,721 @@
import { useMemo, type ReactNode } from "react"
import { Link } from "@tanstack/react-router"
import { useState } from "react"
import {
ArrowRight,
ChevronRight,
FileText,
Globe,
MessageCircle,
Zap,
Loader2,
Link2,
ChevronDown,
Search,
ShieldCheck,
Sparkles,
Plus,
} from "lucide-react"
import ContextPanel from "./Context-panel"
type BlockLayoutProps = {
activeTab: "blocks" | "marketplace"
toolbar?: ReactNode
subnav?: ReactNode
children: ReactNode
}
type MarketplaceCard = {
title: string
author: string
price: string
tone: string
accent: string
badge?: string
}
export default function BlockLayout({
activeTab,
subnav,
children,
}: BlockLayoutProps) {
return (
<div className="min-h-screen bg-[#05070e] text-white grid grid-cols-1 lg:grid-cols-[1fr_3fr] max-w-[1700px] mx-auto">
<aside className="hidden lg:block h-screen overflow-y-auto">
<ContextPanel chats={[]} />
</aside>
<main className="relative h-screen overflow-hidden">
<div className="pointer-events-none absolute inset-0" />
<div className="relative h-screen overflow-y-auto">
<div className="flex flex-wrap items-center justify-between gap-4">
<BlockNav activeTab={activeTab} />
{activeTab === "blocks" ? <PublishButton /> : <MarketplaceSearch />}
</div>
{subnav ? <div className="mt-4">{subnav}</div> : null}
<div className="mt-6 space-y-6">{children}</div>
</div>
</main>
</div>
)
}
function BlockNav({ activeTab }: { activeTab: "blocks" | "marketplace" }) {
const tabs = [
{ id: "blocks", label: "My Blocks", to: "/blocks" },
{ id: "marketplace", label: "Marketplace", to: "/marketplace" },
] as const
return (
<div className="flex items-center gap-8">
{tabs.map((tab) => {
const isActive = activeTab === tab.id
return (
<Link
key={tab.id}
to={tab.to}
className="relative pb-0.2 text-2xl -tracking-normal"
activeOptions={{ exact: true }}
>
<span
className={`transition-colors duration-200 ${
isActive ? "text-white" : "text-white/50 hover:text-white/70"
}`}
>
{tab.label}
</span>
{isActive ? (
<span className="absolute inset-x-0 -bottom-0.5 flex h-[0.5px] items-center justify-center">
<span className="h-[0.5px] w-full rounded-xl bg-linear-to-r from-amber-200 via-amber-100 to-amber-100/80 blur-[0.2px]" />
<span className="absolute h-[14px] w-[120%] -z-10 bg-[radial-gradient(circle_at_center,rgba(255,179,71,0.35),transparent_65%)]" />
</span>
) : null}
</Link>
)
})}
</div>
)
}
function PublishButton() {
return (
<button
type="button"
className="relative overflow-hidden rounded-lg border border-amber-400/10 bg-linear-to-b from-[#412b26] to-[#44382a] px-4 py-1.5 text-sm text-white/70 hover:shadow-[0_2px_15px_rgba(68,56,42)] hover:text-white cursor-pointer"
>
Publish
</button>
)
}
export function MarketplaceSearch() {
return (
<div className="flex items-center gap-2 rounded-lg border border-white/5 bg-[#0f1117]/40 px-4 py-2 shadow-inner shadow-white/1">
<Search className="h-4 w-4 text-white/70" />
<input
placeholder="Search Marketplace"
className="flex-1 bg-[#0f1117]/40 text-white text-sm placeholder:text-white/70 focus:outline-none disabled:opacity-50"
/>
</div>
)
}
export function MyBlocksView() {
const owned: any[] = useMemo(
() => [
{ name: "Stripe Integration", badge: "Action" },
{ name: "Notion", badge: "Action" },
{ name: "X API", badge: "Action" },
],
[],
)
const custom: any[] = useMemo(
() => [
{ name: "Gmail", badge: "Action" },
{ name: "Documentation Builder", badge: "Action" },
{ name: "Electron Docs", badge: "Action" },
{ name: "Open Image Editor Ideas", badge: "Action" },
],
[],
)
return (
<BlockLayout activeTab="blocks">
<div
// className="grid gap-6 lg:grid-cols-[1fr_2fr]"
className="flex flex-row"
>
<div className="space-y-4">
<BlockListGroup title="Owned" items={owned} />
<BlockListGroup title="Custom" items={custom} />
<button className="group flex gap-2 w-full items-center cursor-pointer text-sm text-white/70 transition hover:text-white">
<Plus className="h-4 w-4" />
New block
</button>
</div>
<CreateBlockPanel />
</div>
</BlockLayout>
)
}
function BlockListGroup({ title, items }: { title: string; items: any[] }) {
return (
<div className="p-4">
<div className="flex items-center justify-between text-sm font-semibold text-white">
<span>{title}</span>
<ChevronRight className="h-4 w-4 text-slate-500" />
</div>
<div className="mt-3 space-y-2">
{items.map((item) => (
<div
key={item.name}
className="flex items-center justify-between text-sm text-slate-200"
>
<div className="flex items-center gap-2">
<div className="flex h-5 w-5 items-center justify-center"></div>
<span>{item.name}</span>
</div>
{item.badge ? (
<span className="rounded-lg bg-white/4 px-2 py-1 text-xs text-white/70">
{item.badge}
</span>
) : null}
</div>
))}
</div>
</div>
)
}
function CreateBlockPanel() {
const [blockType, setBlockType] = useState<
"text" | "web" | "thread" | "action"
>("web")
const [options, setOptions] = useState({
update: true,
deepScan: true,
summarise: false,
sections: true,
updateInterval: "1 hour",
deepScanLevel: "5 levels",
})
const blockTypes = [
{ id: "text", label: "Text", icon: FileText },
{ id: "web", label: "Web", icon: Globe },
{ id: "thread", label: "Thread", icon: MessageCircle },
{ id: "action", label: "Action", icon: Zap },
] as const
const scanning = [
{
name: "nikiv.dev",
tokens: "2,284",
children: [
{ name: "/intro", tokens: "508" },
{ name: "/code", tokens: "508" },
{ name: "/focus", tokens: "508" },
],
},
{
name: "Open Image Editor Ideas",
tokens: "5,582",
children: [
{ name: "/intro", tokens: "508" },
{ name: "/code", tokens: "508" },
{ name: "/focus", tokens: "508" },
],
},
]
const initialSelection = useMemo(() => {
const map: Record<string, boolean> = {}
scanning.forEach((item) => {
map[item.name] = true
item.children?.forEach((child) => {
map[`${item.name}/${child.name}`] = true
})
})
return map
}, [scanning])
const [selectedPaths, setSelectedPaths] = useState<Record<string, boolean>>(
() => initialSelection,
)
const togglePath = (path: string) =>
setSelectedPaths((prev) => ({ ...prev, [path]: !prev[path] }))
const [expandedPaths, setExpandedPaths] = useState<Record<string, boolean>>(
() => {
const map: Record<string, boolean> = {}
scanning.forEach((item) => {
if (item.children?.length) map[item.name] = true
})
return map
},
)
const toggleExpand = (path: string) =>
setExpandedPaths((prev) => ({ ...prev, [path]: !prev[path] }))
return (
<div className="rounded-2xl bg-[#181921d9]/50 p-6 space-y-6">
<div className="flex items-center justify-between">
<p className="text-2xl font-semibold text-white">Create block</p>
</div>
<div className="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-4">
{blockTypes.map((type) => {
const isActive = blockType === type.id
const Icon = type.icon
return (
<button
key={type.id}
type="button"
onClick={() => setBlockType(type.id)}
className={`group relative flex h-full flex-col cursor-pointer justify-center items-center gap-4 rounded-xl border px-3 py-3 text-sm font-medium transition ${
isActive
? " bg-linear-to-br border-white/15 shadow-[0_1px_1px_rgba(255, 255, 255, 0.8)] from-blue-300/10 via-blue-400/15 to-purple-400/30"
: "border-white/5 bg-white/3 hover:border-white/20 text-white/70 hover:bg-white/6"
}`}
>
<Icon className="h-6 w-6" />
<span>{type.label}</span>
</button>
)
})}
</div>
<div className="gap-2 flex flex-col">
<label className="text-sm pb-2 uppercase tracking-[0.2em] text-white">
URL
</label>
<div className="flex flex-col gap-3 rounded-lg bg-black/40 px-4 py-2 shadow-inner shadow-white/5 sm:flex-row sm:items-center">
<input
type="text"
placeholder="https://apple.com"
className="flex-1 bg-[#0f1117]/40 text-white text-sm placeholder:text-neutral-500 focus:outline-none disabled:opacity-50"
style={{ boxShadow: "1px 0.5px 10px 0 rgba(0,0,0,0.4) inset" }}
/>
</div>
</div>
<div className="mt-5 grid gap-3 lg:grid-cols-[1.1fr_1.1fr_auto] lg:items-stretch">
<OptionRow
label="Update every"
checked={options.update}
onChange={() =>
setOptions((prev) => ({ ...prev, update: !prev.update }))
}
select={{
value: options.updateInterval,
onChange: (value) =>
setOptions((prev) => ({ ...prev, updateInterval: value })),
options: ["30 min", "1 hour", "3 hours", "1 day"],
}}
/>
<OptionRow
label="Summarise pages"
checked={options.summarise}
onChange={() =>
setOptions((prev) => ({ ...prev, summarise: !prev.summarise }))
}
/>
<CreateCTA />
<OptionRow
label="Deep scan"
checked={options.deepScan}
onChange={() =>
setOptions((prev) => ({ ...prev, deepScan: !prev.deepScan }))
}
select={{
value: options.deepScanLevel,
onChange: (value) =>
setOptions((prev) => ({ ...prev, deepScanLevel: value })),
options: ["3 levels", "5 levels", "7 levels"],
}}
/>
<OptionRow
label="Create sections"
checked={options.sections}
onChange={() =>
setOptions((prev) => ({ ...prev, sections: !prev.sections }))
}
/>
</div>
<div>
<div className="flex flex-col text-sm gap-4 text-white/70">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Scanning...
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-sm text-white/70">
<span className="text-white font-semibold">40</span> pages
</p>
<p className="text-sm text-white/70">
<span className="text-white font-semibold">10</span> tokens
</p>
</div>
</div>
<div className="mt-3 space-y-2 py-3 text-sm text-slate-200">
{scanning.map((item) => (
<div key={item.name}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{item.children ? (
<button
type="button"
onClick={() => toggleExpand(item.name)}
className="flex h-6 w-6 items-center cursor-pointer justify-center text-white/70"
aria-expanded={expandedPaths[item.name]}
>
<ChevronRight
className={`h-5 w-5 transition ${
expandedPaths[item.name] ? "rotate-90" : ""
}`}
/>
</button>
) : (
<span className="h-6 w-6" />
)}
<button
type="button"
onClick={() => togglePath(item.name)}
className="flex items-center gap-3 text-left"
>
<GradientCheckbox checked={selectedPaths[item.name]} />
<span className="font-medium text-white">{item.name}</span>
</button>
</div>
<span className="text-xs text-white/70 p-2 rounded-md bg-white/4">
{item.tokens}
</span>
</div>
{item.children && expandedPaths[item.name] ? (
<div className="mt-2 space-y-1 pl-10 text-slate-400">
{item.children.map((child) => (
<div
key={child.name}
className="flex items-center justify-between rounded-md px-3 py-2 hover:bg-white/2"
>
<button
type="button"
onClick={() => togglePath(`${item.name}/${child.name}`)}
className="flex items-center gap-3 text-left"
>
<GradientCheckbox
checked={selectedPaths[`${item.name}/${child.name}`]}
/>
<Link2 className="h-4 w-4 text-white/70" />
<span className="text-white">{child.name}</span>
</button>
<span className="text-xs text-white/70 p-2 rounded-md bg-white/4">
{child.tokens}
</span>
</div>
))}
</div>
) : null}
</div>
))}
</div>
</div>
</div>
)
}
function OptionRow({
label,
checked,
onChange,
select,
}: {
label: string
checked: boolean
onChange: () => void
select?: {
value: string
onChange: (value: string) => void
options: string[]
}
}) {
const muted = !checked
return (
<div className="flex items-center gap-3">
<button
type="button"
onClick={onChange}
className="flex items-center gap-3 text-left"
aria-pressed={checked}
>
<GradientCheckbox checked={checked} />
<span
className={`text-lg font-semibold tracking-tight ${
muted ? "text-slate-400" : "text-white"
}`}
>
{label}
</span>
</button>
{select ? (
<SoftSelect
value={select.value}
onChange={select.onChange}
options={select.options}
disabled={muted}
/>
) : (
<div className="h-10 w-10 shrink-0" />
)}
</div>
)
}
function GradientCheckbox({ checked }: { checked: boolean }) {
return (
<span
className={`flex h-5 w-5 items-center cursor-pointer justify-center rounded-md border text-white shadow-[0_10px_24px_rgba(0,0,0,0.35)] transition ${
checked
? "border-amber-600/20 shadow-[1px_1px_3px_rgba(255,149,87,0.2)] bg-linear-to-b from-red-500/20 via-orange-400/20 to-amber-300/20"
: "border-white/10 bg-black/40"
}`}
>
{checked ? (
<svg
viewBox="0 0 20 20"
className="h-4 w-4"
fill="none"
stroke="#ff9557"
strokeWidth={3}
>
<path d="M5 11.5 8.5 15 15 6" />
</svg>
) : null}
</span>
)
}
function SoftSelect({
value,
onChange,
options,
disabled,
}: {
value: string
onChange: (value: string) => void
options: string[]
disabled?: boolean
}) {
return (
<div className="relative shrink-0">
<select
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
className={`appearance-none rounded-lg border px-6 py-1.5 border-none shadow-white/3 shadow-[1px_1px_0.5px_rgba(0,0,0,0.1)] pr-8 text-sm font-semibold transition focus:outline-none ${
disabled
? "bg-transparent text-slate-500 cursor-not-allowed"
: "bg-black/50 text-white"
}`}
>
{options.map((opt) => (
<option key={opt} value={opt} className="bg-[#0c0f18] text-white">
{opt}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
</div>
)
}
function CreateCTA() {
const disabled = false
return (
<div className="flex items-center justify-end lg:row-span-2">
<button
disabled={disabled}
type="button"
className={
disabled
? "opacity-50 cursor-not-allowed"
: "flex items-center justify-center gap-2 rounded-xl bg-linear-to-b from-[#e5634f] via-[#ed7246] to-[#c25c29] px-5 py-3 text-base font-semibold text-white shadow-[0_2px_3px_rgba(255,175,71,0.45)] cursor-pointer"
}
>
Create
</button>
</div>
)
}
export function MarketplaceView() {
const sections: { title: string; items: MarketplaceCard[] }[] = useMemo(
() => [
{
title: "Featured",
items: [
{
title: "Stripe Integration",
author: "Stripe",
price: "Free",
tone: "bg-gradient-to-r from-indigo-400 via-blue-500 to-purple-500",
accent: "border-indigo-300/40",
},
{
title: "X API",
author: "X",
price: "$19.99",
tone: "bg-gradient-to-r from-slate-900 via-neutral-800 to-slate-950",
accent: "border-slate-500/40",
},
{
title: "Notion",
author: "Notion",
price: "$11.99",
tone: "bg-gradient-to-r from-amber-200 via-amber-100 to-white",
accent: "border-amber-200/50",
},
],
},
{
title: "Trending",
items: [
{
title: "Dev Mode MCP",
author: "Figma",
price: "Free",
tone: "bg-gradient-to-r from-green-400 via-emerald-500 to-green-600",
accent: "border-emerald-200/50",
},
{
title: "Gmail API Tools",
author: "hunter2",
price: "$9.99",
tone: "bg-gradient-to-r from-red-400 via-orange-400 to-yellow-400",
accent: "border-orange-300/60",
},
{
title: "VS Code",
author: "nikiv",
price: "Free",
tone: "bg-gradient-to-r from-slate-800 via-slate-700 to-slate-900",
accent: "border-slate-500/30",
},
],
},
{
title: "Recently published",
items: [
{
title: "Spotify API",
author: "greg3",
price: "$6.99",
tone: "bg-gradient-to-r from-emerald-400 via-green-500 to-emerald-600",
accent: "border-emerald-200/50",
},
{
title: "VS Code",
author: "nikiv",
price: "Free",
tone: "bg-gradient-to-r from-slate-800 via-slate-700 to-slate-900",
accent: "border-slate-500/30",
},
{
title: "Dev Mode MCP",
author: "Figma",
price: "$4.99",
tone: "bg-gradient-to-r from-lime-400 via-green-500 to-emerald-600",
accent: "border-lime-200/50",
},
],
},
],
[],
)
return (
<BlockLayout activeTab="marketplace" subnav={<MarketplaceFilters />}>
<div className="grid gap-6">
{sections.map((section) => (
<div key={section.title} className="grid gap-2">
<div className="mb-3 flex items-center justify-between">
<h3 className="sm:text-lg md:text-2xl font-semibold">
{section.title}
</h3>
<button className="text-sm text-white/90 hover:text-white cursor-pointer">
Show all
</button>
</div>
<div className="grid gap-6 md:grid-cols-3">
{section.items.map((item) => (
<MarketplaceCardView key={item.title} card={item} />
))}
</div>
</div>
))}
</div>
</BlockLayout>
)
}
function MarketplaceFilters() {
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<FilterPill active={true} text="Discover" />
<FilterPill text="Featured" />
<FilterPill text="Trending" />
<FilterPill text="New" />
</div>
<div className="flex items-center gap-2">
<FilterPill text="Owned" />
<FilterPill text="Profile" />
</div>
</div>
)
}
function FilterPill({ text, active }: { text: string; active?: boolean }) {
return (
<button
type="button"
className={`rounded-lg px-4 py-2 cursor-pointer text-sm transition ${
active
? "border border-white/15 inset-shadow-2xl shadow-white rounded-lg bg-transparent text-white font-semibold"
: "bg-transparent text-white/70 hover:text-white"
}`}
>
{text}
</button>
)
}
function MarketplaceCardView({ card }: { card: MarketplaceCard }) {
return (
<div
className={`relative flex h-46 flex-col justify-between overflow-hidden rounded-2xl ${card.tone}`}
>
<div className="flex items-center h-[50%] mt-auto bg-linear-to-b from-[#252734] via-[#282a37] to-[#2c2d37] border border-t border-black/20 rounded-lg rounded-t-none p-4 justify-between">
<div>
<div className="text-md font-semibold drop-shadow-sm">
{card.title}
</div>
<div className="text-sm text-white/80">
by <span className="text-white font-semibold">{card.author}</span>
</div>
</div>
<span className="rounded-full bg-black/50 px-3 py-1 text-xs font-semibold text-white">
{card.price}
</span>
</div>
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_20%_20%,rgba(255,255,255,0.2),transparent_35%),radial-gradient(circle_at_80%_0%,rgba(255,255,255,0.15),transparent_40%)]" />
</div>
)
}

View File

@@ -0,0 +1,556 @@
import {
useState,
useRef,
useEffect,
useCallback,
type ReactNode,
type RefObject,
type MouseEvent as ReactMouseEvent,
} from "react"
// import { useMutation } from "@tanstack/react-db"
import {
Brain,
ChevronDown,
ChevronRight,
File,
Globe,
Ellipsis,
LogIn,
MessageCircle,
Plus,
Trash2,
type LucideIcon,
PanelRight,
Settings,
} from "lucide-react"
import type { ChatThread } from "@/db/schema"
interface UserProfile {
name?: string | null
email: string
image?: string | null
}
interface ContextPanelProps {
chats: ChatThread[]
activeChatId?: string | null
isAuthenticated?: boolean
profile?: UserProfile | null | undefined
}
interface CollapsiblePanelProps {
title: string
icon: LucideIcon
isOpen: boolean
onToggle: () => void
headerActions?: ReactNode
children: ReactNode
height?: string
isDragging?: boolean
}
function CollapsiblePanel({
title,
icon: Icon,
isOpen,
onToggle,
headerActions,
children,
height,
isDragging = false,
}: CollapsiblePanelProps) {
const isFlexHeight = height === "flex-1"
return (
<div
className={`border bg-inherit rounded-xl border-slate-500/15 flex flex-col ${
!isDragging ? "transition-all duration-300" : ""
} ${isFlexHeight && isOpen ? "flex-1" : ""}`}
style={!isFlexHeight && isOpen ? { height } : undefined}
>
<div
className={`flex items-center justify-between px-2 py-2.5 bg-[#0b0d15] w-full transition-all duration-300 ${
isOpen ? "border-b border-slate-500/15 rounded-t-xl" : "rounded-xl"
}`}
>
<div className="flex items-center gap-2">
<Icon
className="w-5 h-5 text-teal-500 transition-transform duration-300"
strokeWidth={2}
/>
<span className="text-white font-medium text-[13px]">{title}</span>
</div>
<div className="flex items-center gap-2.5">
{headerActions}
<div className="relative w-5 h-5 flex items-center justify-center">
<ChevronDown
onClick={onToggle}
className={`absolute cursor-pointer transition-all duration-200 text-neutral-400 group-hover:text-white w-3.5 h-3.5 ${
isOpen ? "opacity-100 rotate-0" : "opacity-0 rotate-90"
}`}
strokeWidth={1}
/>
<ChevronRight
onClick={onToggle}
className={`absolute cursor-pointer transition-all duration-200 text-neutral-400 group-hover:text-white w-3.5 h-3.5 ${
isOpen ? "opacity-0 -rotate-90" : "opacity-100 rotate-0"
}`}
strokeWidth={1}
/>
</div>
</div>
</div>
<div
className={`transition-all duration-300 ease-in-out overflow-hidden ${
isOpen
? "opacity-100 bg-[#181921d9]/50 text-neutral-500 font-semibold rounded-b-xl px-2 py-4 overflow-y-auto flex-1"
: "opacity-0 max-h-0 py-0"
}`}
>
{children}
</div>
</div>
)
}
interface AddWebsiteModalProps {
isOpen: boolean
onClose: () => void
buttonRef: RefObject<HTMLButtonElement | null>
}
function AddWebsiteModal({ isOpen, onClose, buttonRef }: AddWebsiteModalProps) {
const [url, setUrl] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [position, setPosition] = useState({ top: 0, left: 0 })
useEffect(() => {
if (isOpen && buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect()
setPosition({
top: rect.top - 30,
left: rect.right + 12,
})
}
}, [isOpen, buttonRef])
const handleAdd = async () => {
if (!url.trim()) return
setIsLoading(true)
setError(null)
try {
// Normalize URL - add https:// if no protocol
let normalizedUrl = url.trim()
if (
!normalizedUrl.startsWith("http://") &&
!normalizedUrl.startsWith("https://")
) {
normalizedUrl = `https://${normalizedUrl}`
}
const response = await fetch("/api/context-items", {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
action: "addUrl",
url: normalizedUrl,
}),
})
if (!response.ok) {
const data = (await response.json()) as { error?: string }
throw new Error(data.error || "Failed to add URL")
}
setUrl("")
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to add URL")
} finally {
setIsLoading(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isLoading) {
handleAdd()
}
}
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50" onClick={onClose}>
<div
className="absolute bg-[#1e202d]/60 backdrop-blur-md flex flex-col gap-3 rounded-2xl p-5 w-full max-w-[400px] shadow-xl border border-slate-200/5 box-shadow-[1px_0.5px_10px_0_rgba(0,0,0,0.4)_inset]"
style={{ top: `${position.top}px`, left: `${position.left}px` }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between">
<h2 className="text-white text-sm">Add website</h2>
</div>
<div className="flex gap-3">
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="example.com"
disabled={isLoading}
className="flex-1 bg-[#0f1117]/40 rounded-lg px-4 py-2 text-white text-sm placeholder:text-neutral-500 focus:outline-none disabled:opacity-50"
style={{ boxShadow: "1px 0.5px 10px 0 rgba(0,0,0,0.4) inset" }}
/>
<button
onClick={handleAdd}
disabled={isLoading || !url.trim()}
className="px-4 cursor-pointer py-1 w-fit bg-teal-600 hover:bg-teal-700 disabled:bg-teal-600/50 disabled:cursor-not-allowed text-white rounded-lg text-xs font-medium transition-colors"
>
{isLoading ? "Adding..." : "Add"}
</button>
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
<p className="text-neutral-500 text-xs">
URL content will be fetched and made available as context for your
chats.
</p>
</div>
</div>
)
}
export default function ContextPanel({
chats,
activeChatId = null,
isAuthenticated = false,
profile = null,
}: ContextPanelProps) {
// const { remove } = useMutation()
const [openSections, setOpenSections] = useState({
files: false,
web: false,
})
const [isContextOpen, setIsContextOpen] = useState(true)
const [isThreadsOpen, setIsThreadsOpen] = useState(true)
const [threadsHeight, setThreadsHeight] = useState(350)
const [isDragging, setIsDragging] = useState(false)
const [deletingChatId, setDeletingChatId] = useState<string | null>(null)
const [isAddWebsiteModalOpen, setIsAddWebsiteModalOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const addLinkButtonRef = useRef<HTMLButtonElement>(null)
// For authenticated users, show email initial or first letter of name
// For guests, show "G"
const profileInitial = profile?.name?.slice(0, 1) ?? profile?.email?.slice(0, 1)?.toUpperCase() ?? "G"
const profileImage = profile?.image ?? null
const contextItems = [
{
id: "files",
label: "Files",
icon: File,
count: 0,
hasChevron: true,
},
{
id: "web",
label: "Web",
icon: Globe,
count: 0,
hasChevron: true,
},
]
const toggleSection = (id: string) => {
setOpenSections((prev) => ({
...prev,
[id]: !prev[id as keyof typeof prev],
}))
}
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!containerRef.current) return
const container = containerRef.current
const containerRect = container.getBoundingClientRect()
const newHeight = e.clientY - containerRect.top - 50
const collapseThreshold = 80
const minHeight = 150
const maxHeight = containerRect.height - 250
if (newHeight < collapseThreshold) {
setIsThreadsOpen(false)
} else if (newHeight >= minHeight && newHeight <= maxHeight) {
if (!isThreadsOpen) {
setIsThreadsOpen(true)
}
setThreadsHeight(newHeight)
} else if (newHeight >= collapseThreshold && newHeight < minHeight) {
if (!isThreadsOpen) {
setIsThreadsOpen(true)
}
setThreadsHeight(minHeight)
}
},
[isThreadsOpen],
)
const handleMouseUp = useCallback(() => {
setIsDragging(false)
}, [])
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault()
setIsDragging(true)
}
useEffect(() => {
if (isDragging) {
window.addEventListener("mousemove", handleMouseMove)
window.addEventListener("mouseup", handleMouseUp)
}
return () => {
window.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp)
}
}, [isDragging, handleMouseMove, handleMouseUp])
const handleDeleteChat = async (
event: ReactMouseEvent<HTMLButtonElement>,
chatId: string,
) => {
event.preventDefault()
event.stopPropagation()
if (deletingChatId) return
try {
setDeletingChatId(chatId)
// await remove.chat.with({ id: chatId })
} catch (error) {
console.error("[contextPanel] failed to delete chat", { chatId, error })
} finally {
setDeletingChatId(null)
}
}
// Profile display (commented out for now)
// const profileUsername = profile?.name ?? null
// const profileInitial = profileUsername?.[0]?.toUpperCase() ?? "?"
const toggleAllPanels = () => {
const shouldOpen = !isThreadsOpen && !isContextOpen
setIsThreadsOpen(shouldOpen)
setIsContextOpen(shouldOpen)
}
return (
<div
ref={containerRef}
className="h-[calc(100vh-1em)] flex flex-col gap-2 w-full max-w-[300px]"
>
<div className="flex items-center justify-between px-2">
<div className="flex items-center gap-3">
{isAuthenticated ? (
<a
href="/settings"
className="flex items-center justify-center w-7 h-7 rounded-full bg-teal-600 hover:bg-teal-500 transition-colors duration-200 overflow-hidden"
aria-label="Profile settings"
>
{profileImage ? (
<img
src={profileImage}
alt="Profile"
className="w-full h-full object-cover"
/>
) : (
<span className="text-white text-xs font-medium">
{profileInitial}
</span>
)}
</a>
) : (
<a
href="/login"
className="flex items-center gap-2 text-neutral-300 hover:text-white transition-colors duration-200"
>
<LogIn className="w-4 h-4" strokeWidth={2} />
<span className="text-[13px]">Login</span>
</a>
)}
</div>
<div className="flex items-center gap-1">
<a
href="/settings"
className=" text-neutral-400 hover:text-white hover:bg-white/5 transition-colors duration-200"
aria-label="Settings"
>
<Settings className="w-4 h-4" strokeWidth={2} />
</a>
<button
type="button"
onClick={toggleAllPanels}
className="p-2 rounded-lg text-neutral-400 hover:text-white hover:bg-white/5 transition-colors duration-200"
aria-label="Toggle panels"
>
<PanelRight className="w-4 h-4 cursor-pointer" strokeWidth={2} />
</button>
</div>
</div>
<div style={isDragging ? { transition: "none" } : undefined}>
<CollapsiblePanel
title="Threads"
icon={MessageCircle}
isOpen={isThreadsOpen}
onToggle={() => setIsThreadsOpen(!isThreadsOpen)}
height={`${threadsHeight}px`}
headerActions={
<a
href="/"
className="pr-2 text-neutral-200 hover:text-white rounded-lg text-[11px] cursor-pointer flex items-center gap-1.5 transition-colors duration-200"
>
<Plus className="w-4 h-4" strokeWidth={2} />
<span>New</span>
</a>
}
>
<p className="text-xs text-neutral-500 font-semibold">RECENT</p>
{chats.length === 0 ? (
<p className="px-2 pt-2 text-xs text-neutral-600">
Start a conversation to see it here.
</p>
) : (
<div className="flex flex-col gap-0.5">
{chats.map((chat) => {
const isActive = chat.id.toString() === activeChatId
const displayTitle = chat.title?.trim() ?? "Untitled chat"
const isDeleting = deletingChatId === chat.id.toString()
return (
<div key={chat.id} className="group relative">
<a
href={`/c/${chat.id}`}
className={`flex items-center text-[13px] gap-2 py-2 px-2 pr-8 transition-colors duration-200 rounded-lg ${
isActive
? "bg-white/5 text-white"
: "text-neutral-300 hover:text-white hover:bg-white/5"
} ${isDeleting ? "opacity-50" : ""}`}
>
<MessageCircle
className={`w-3.5 h-3.5 f ${
isActive ? "text-teal-400" : "text-teal-400/50"
}`}
strokeWidth={2}
/>
<span className="truncate">{displayTitle}</span>
</a>
<button
type="button"
aria-label="Delete chat"
disabled={isDeleting}
onClick={(event) =>
handleDeleteChat(event, chat.id.toString())
}
className={`absolute right-1.5 top-1/2 -translate-y-1/2 rounded-md p-1 text-neutral-400 transition-all duration-200 opacity-0 invisible group-hover:visible group-hover:opacity-100 focus-visible:visible focus-visible:opacity-100 bg-transparent ${
isDeleting
? "cursor-wait"
: "hover:text-white focus-visible:outline-1 focus-visible:outline-white/50"
}`}
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={2} />
</button>
</div>
)
})}
</div>
)}
</CollapsiblePanel>
</div>
{(isThreadsOpen || isContextOpen) && (
<div
onMouseDown={handleMouseDown}
className="flex items-center justify-center cursor-row-resize group transition-all duration-300 -my-1.5 animate-in fade-in zoom-in-95"
>
<Ellipsis className="w-6 h-4 text-neutral-600 group-hover:text-neutral-400 transition-all duration-300" />
</div>
)}
<CollapsiblePanel
title="Context"
icon={Brain}
isOpen={isContextOpen}
onToggle={() => setIsContextOpen(!isContextOpen)}
height="flex-1"
>
<div className="flex justify-between text-sm mb-4 px-2">
<span className="text-neutral-400">0 tokens</span>
<span className="text-neutral-400">1M</span>
</div>
<div className="flex flex-col gap-0.5">
{contextItems.map((item) => {
const Icon = item.icon
const isOpen = openSections[item.id as keyof typeof openSections]
return (
<button
key={item.id}
type="button"
onClick={() => toggleSection(item.id)}
className="flex items-center justify-between group py-2 px-2 cursor-pointer transition-colors duration-200"
>
<div className="flex items-center gap-2">
{item.hasChevron &&
(isOpen ? (
<ChevronDown
className="w-4 h-4 text-neutral-400 group-hover:text-white"
strokeWidth={2}
/>
) : (
<ChevronRight
className="w-4 h-4 text-neutral-400 group-hover:text-white"
strokeWidth={2}
/>
))}
<Icon className="w-4 h-4 text-white" strokeWidth={2} />
<span className="text-[13px] text-neutral-300 group-hover:text-white">
{item.label}
</span>
</div>
<span className="text-xs text-neutral-500 group-hover:text-neutral-400 transition-colors duration-300">
{item.count}
</span>
</button>
)
})}
<button
type="button"
ref={addLinkButtonRef}
onClick={() => setIsAddWebsiteModalOpen(true)}
className="flex items-center gap-2 py-2 pr-4 hover:bg-white/4 box-shadow-[1px_0.5px_10px_0_rgba(0,0,0,0.4)_inset] w-fit rounded-lg cursor-pointer transition-colors duration-200"
>
<Plus className="w-4 h-4 text-neutral-400" strokeWidth={2} />
<span className="text-[13px] text-neutral-200">Add link...</span>
</button>
</div>
</CollapsiblePanel>
<AddWebsiteModal
isOpen={isAddWebsiteModalOpen}
onClose={() => setIsAddWebsiteModalOpen(false)}
buttonRef={addLinkButtonRef}
/>
</div>
)
}

View File

@@ -0,0 +1,283 @@
import { Link } from "@tanstack/react-router"
import { useState } from "react"
import {
ChevronDown,
ChevronRight,
Home,
LogIn,
LogOut,
Menu,
Network,
Palette,
SquareFunction,
StickyNote,
User,
X,
} from "lucide-react"
import { authClient } from "@/lib/auth-client"
export default function Header() {
const [isOpen, setIsOpen] = useState(false)
const [groupedExpanded, setGroupedExpanded] = useState<
Record<string, boolean>
>({})
const { data: session } = authClient.useSession()
const handleSignOut = async () => {
await authClient.signOut()
window.location.href = "/"
}
return (
<>
<header className="p-4 flex items-center justify-between bg-gray-800 text-white shadow-lg">
<div className="flex items-center">
<button
onClick={() => setIsOpen(true)}
className="p-2 hover:bg-gray-700 rounded-lg transition-colors"
aria-label="Open menu"
>
<Menu size={24} />
</button>
<h1 className="ml-4 text-xl font-semibold">
<Link to="/">
<img
src="/tanstack-word-logo-white.svg"
alt="TanStack Logo"
className="h-10"
/>
</Link>
</h1>
</div>
{session?.user ? (
<button
onClick={handleSignOut}
className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-gray-700 rounded-lg transition-colors"
aria-label="Sign out"
>
<div className="w-8 h-8 rounded-full bg-cyan-600 flex items-center justify-center">
<span className="text-sm font-medium">
{session.user.email?.charAt(0).toUpperCase() || <User size={16} />}
</span>
</div>
</button>
) : (
<Link
to="/auth"
className="flex items-center gap-2 px-3 py-2 text-sm bg-white text-black font-medium rounded-lg hover:bg-white/90 transition-colors"
>
<LogIn size={18} />
<span>Sign in</span>
</Link>
)}
</header>
<aside
className={`fixed top-0 left-0 h-full w-80 bg-gray-900 text-white shadow-2xl z-50 transform transition-transform duration-300 ease-in-out flex flex-col ${
isOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="flex items-center justify-between p-4 border-b border-gray-700">
<h2 className="text-xl font-bold">Navigation</h2>
<button
onClick={() => setIsOpen(false)}
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
aria-label="Close menu"
>
<X size={24} />
</button>
</div>
<nav className="flex-1 p-4 overflow-y-auto">
<Link
to="/"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
activeProps={{
className:
"flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2",
}}
>
<Home size={20} />
<span className="font-medium">Home</span>
</Link>
<Link
to="/chat"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
activeProps={{
className:
"flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2",
}}
>
<Network size={20} />
<span className="font-medium">Chat</span>
</Link>
<Link
to="/canvas"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
activeProps={{
className:
"flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2",
}}
>
<Palette size={20} />
<span className="font-medium">Canvas</span>
</Link>
<Link
to="/users"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
activeProps={{
className:
"flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2",
}}
>
<Network size={20} />
<span className="font-medium">Users (Electric)</span>
</Link>
{session?.user ? (
<div className="border-t border-gray-700 pt-4 mt-4 mb-4">
<div className="flex items-center gap-3 p-3 mb-2">
<div className="w-8 h-8 rounded-full bg-cyan-600 flex items-center justify-center flex-shrink-0">
<span className="text-sm font-medium">
{session.user.email?.charAt(0).toUpperCase()}
</span>
</div>
<span className="font-medium text-sm truncate">{session.user.email}</span>
</div>
<button
onClick={() => {
setIsOpen(false)
handleSignOut()
}}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors w-full text-left text-red-400 hover:text-red-300"
>
<LogOut size={20} />
<span className="font-medium">Sign out</span>
</button>
</div>
) : (
<Link
to="/auth"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 p-3 rounded-lg bg-white text-black hover:bg-white/90 transition-colors mb-4"
>
<LogIn size={20} />
<span className="font-medium">Sign in</span>
</Link>
)}
{/* Demo Links Start */}
<Link
to="/demo/start/server-funcs"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
activeProps={{
className:
"flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2",
}}
>
<SquareFunction size={20} />
<span className="font-medium">Start - Server Functions</span>
</Link>
<Link
to="/demo/start/api-request"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
activeProps={{
className:
"flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2",
}}
>
<Network size={20} />
<span className="font-medium">Start - API Request</span>
</Link>
<div className="flex flex-row justify-between">
<Link
to="/demo/start/ssr"
onClick={() => setIsOpen(false)}
className="flex-1 flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
activeProps={{
className:
"flex-1 flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2",
}}
>
<StickyNote size={20} />
<span className="font-medium">Start - SSR Demos</span>
</Link>
<button
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
onClick={() =>
setGroupedExpanded((prev) => ({
...prev,
StartSSRDemo: !prev.StartSSRDemo,
}))
}
>
{groupedExpanded.StartSSRDemo ? (
<ChevronDown size={20} />
) : (
<ChevronRight size={20} />
)}
</button>
</div>
{groupedExpanded.StartSSRDemo && (
<div className="flex flex-col ml-4">
<Link
to="/demo/start/ssr/spa-mode"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
activeProps={{
className:
"flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2",
}}
>
<StickyNote size={20} />
<span className="font-medium">SPA Mode</span>
</Link>
<Link
to="/demo/start/ssr/full-ssr"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
activeProps={{
className:
"flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2",
}}
>
<StickyNote size={20} />
<span className="font-medium">Full SSR</span>
</Link>
<Link
to="/demo/start/ssr/data-only"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2"
activeProps={{
className:
"flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2",
}}
>
<StickyNote size={20} />
<span className="font-medium">Data Only</span>
</Link>
</div>
)}
{/* Demo Links End */}
</nav>
</aside>
</>
)
}

View File

@@ -0,0 +1,112 @@
import { useMemo } from "react"
import {
ArrowLeft,
SlidersHorizontal,
UserRound,
type LucideIcon,
CreditCard,
} from "lucide-react"
type SettingsSection = "preferences" | "profile" | "billing"
interface UserProfile {
name?: string | null
email: string
image?: string | null
}
interface SettingsPanelProps {
activeSection: SettingsSection
onSelect: (section: SettingsSection) => void
profile?: UserProfile | null | undefined
}
type NavItem = {
id: SettingsSection
label: string
icon: LucideIcon
}
const navItems: NavItem[] = [
{ id: "preferences", label: "Preferences", icon: SlidersHorizontal },
{ id: "profile", label: "Profile", icon: UserRound },
{ id: "billing", label: "Manage Billing", icon: CreditCard },
]
function Avatar({ profile }: { profile?: UserProfile | null }) {
const initial = useMemo(() => {
if (!profile) return "G"
return (
profile.name?.slice(0, 1) ??
profile.email?.slice(0, 1)?.toUpperCase() ??
"G"
)
}, [profile])
if (profile?.image) {
return (
<img
src={profile.image}
alt={profile.name ?? profile.email}
className="w-9 h-9 rounded-full object-cover"
/>
)
}
return (
<div className="w-9 h-9 rounded-full bg-teal-600 text-white text-sm font-semibold grid place-items-center">
{initial}
</div>
)
}
export default function SettingsPanel({
activeSection,
onSelect,
profile,
}: SettingsPanelProps) {
return (
<aside className="shrink-0 bg-transparent border border-white/5 rounded-2xl h-[calc(100vh-6em)] sticky top-6 px-2 py-4 items-start flex flex-col gap-6">
<div className="flex flex-col gap-2 items-start w-full">
<div className="space-y-2">
<a
href="/"
className="inline-flex items-start gap-2 px-6 py-2.5 text-white/80 hover:text-white text-sm transition-colors w-full justify-start"
>
<ArrowLeft className="w-4 h-4" />
<span>Back to app</span>
</a>
{navItems.map(({ id, label, icon: Icon }) => {
const isActive = activeSection === id
return (
<button
key={id}
type="button"
onClick={() => onSelect(id)}
className={`w-full justify-start hover:cursor-pointer flex items-center gap-2 px-6 py-2.5 rounded-xl text-sm transition-colors ${
isActive
? "bg-white/4 text-white"
: "text-white/80 hover:bg-white/2 hover:text-white"
}`}
>
<Icon className="w-4 h-4" strokeWidth={1.8} />
<span>{label}</span>
</button>
)
})}
</div>
</div>
{!profile ? (
<div className="mt-auto space-y-3">
<a
href={profile ? "/settings" : "/login"}
className="block w-full text-center text-sm font-medium text-white bg-teal-600 hover:bg-teal-500 transition-colors rounded-lg py-2"
>
Sign in
</a>
</div>
) : null}
</aside>
)
}

View File

@@ -0,0 +1,493 @@
import { useEffect, useRef, useState } from "react"
const BLIT_SHADER = `
@group(0) @binding(0) var inputTex: texture_2d<f32>;
@group(0) @binding(1) var inputSampler: sampler;
struct VertexOutput {
@builtin(position) position: vec4f,
@location(0) uv: vec2f,
}
@vertex
fn vs(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
var pos = array<vec2f, 6>(
vec2f(-1.0, -1.0),
vec2f(1.0, -1.0),
vec2f(-1.0, 1.0),
vec2f(-1.0, 1.0),
vec2f(1.0, -1.0),
vec2f(1.0, 1.0),
);
var uv = array<vec2f, 6>(
vec2f(0.0, 1.0),
vec2f(1.0, 1.0),
vec2f(0.0, 0.0),
vec2f(0.0, 0.0),
vec2f(1.0, 1.0),
vec2f(1.0, 0.0),
);
var output: VertexOutput;
output.position = vec4f(pos[vertexIndex], 0.0, 1.0);
output.uv = uv[vertexIndex];
return output;
}
@fragment
fn fs(input: VertexOutput) -> @location(0) vec4f {
return textureSample(inputTex, inputSampler, input.uv);
}
`
const SHADER_CODE = `
struct Time {
elapsed: f32,
delta: f32,
frame: u32,
_pad: u32,
}
struct Custom {
twist: f32,
viz: f32,
}
@group(0) @binding(0) var<uniform> time: Time;
@group(0) @binding(1) var<uniform> custom: Custom;
@group(0) @binding(2) var screen: texture_storage_2d<rgba8unorm, write>;
fn w(T: f32) -> vec3f {
let Q = vec3f(0.5, 0.5, 0.5);
let P = vec3f(0.5, 0.5, 0.5);
let J = vec3f(1.0, 1.0, 1.0);
let H = vec3f(0.263, 0.416, 0.557);
return Q + P * cos(6.28318 * (J * T + H));
}
fn v(z: vec3f) -> vec3f {
var x = z + vec3f(12.34, 56.78, 90.12);
var a = fract(x * vec3f(0.1031, 0.1030, 0.0973));
a = a + dot(a, a.yzx + 19.19);
return fract(vec3f(a.x + a.y, a.y + a.z, a.z + a.x) * a.zxy);
}
fn m(s: f32) -> mat2x2<f32> {
let n: f32 = sin(s);
let r: f32 = cos(s);
return mat2x2(r, -n, n, r);
}
fn t(U: vec3<f32>, S: f32) -> f32 {
return length(U) - S;
}
fn u(R: vec3<f32>) -> f32 {
var d = R;
let G = custom.twist * 0.1;
d = vec3f(d.xy * m(d.z * 0.05 * sin(G * 0.5)), d.z);
let l = 8.0;
let k = vec3<i32>(floor(d / l));
let i = v(vec3f(f32(k.x), f32(k.y), f32(k.z)) + 1337.0);
let K = 1.0;
if (i.x >= K) {
return 0.9;
}
var h = (d / l);
h = fract(h) - 0.5;
let A = (pow(sin(4.0 * time.elapsed), 4.0) + 1.0) / 2.0;
let B = custom.viz * 0.4;
let C = (i.yzx - vec3f(0.5)) * mix(0.1, 0.3 + B, A);
let D = (vec3f(h) + C);
let E = mix(0.05, 0.12, i.z) + (custom.viz * 0.15);
let F = t(D, E);
return F * l;
}
@compute @workgroup_size(16, 16)
fn main(@builtin(global_invocation_id) e: vec3u) {
let c = textureDimensions(screen);
if (e.x >= c.x || e.y >= c.y) {
return;
}
let I = vec2f(f32(e.x) + .5, f32(c.y - e.y) - .5);
var f = (I * 2.0 - vec2f(f32(c.x), f32(c.y))) / f32(c.y);
let y = custom.twist;
f = f * m(y * 0.1);
let L = 8.0;
let M = 0.6 - (custom.viz * 0.2);
let N = vec3f(0, 0, -3 + time.elapsed * L);
let O = normalize(vec3f(f * M, 1.0));
var g = 0.0;
var b = vec3<f32>(0);
for (var q: i32 = 0; q < 80; q++) {
var p = N + O * g;
var j = u(p);
let o = w(p.z * 0.04 + time.elapsed * 0.2);
let V = 0.008 + (custom.viz * 0.01);
let W = 8.0;
b += o * V * exp(-j * W);
if (j < 0.001) {
b += o * 2.0;
break;
}
g += j * 0.7 * (1.0 - custom.viz);
if (g > 150.0) {
break;
}
}
b = b / (b + 1.0);
b = pow(b, vec3f(1.0 / 2.2));
let X = length(f);
b *= 1.0 - X * 0.5;
textureStore(screen, e.xy, vec4f(b, 1.));
}
`
type WebGPUState = {
device: GPUDevice
context: GPUCanvasContext
format: GPUTextureFormat
computePipeline: GPUComputePipeline
computeBindGroup: GPUBindGroup
blitPipeline: GPURenderPipeline
blitBindGroup: GPUBindGroup
timeBuffer: GPUBuffer
customBuffer: GPUBuffer
screenTexture: GPUTexture
width: number
height: number
}
export function ShaderBackground() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const stateRef = useRef<WebGPUState | null>(null)
const frameRef = useRef<number>(0)
const startTimeRef = useRef<number>(0)
const [supported, setSupported] = useState(true)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
let animationId: number
let disposed = false
const init = async () => {
// Check WebGPU support
if (!navigator.gpu) {
setSupported(false)
return
}
const adapter = await navigator.gpu.requestAdapter()
if (!adapter) {
setSupported(false)
return
}
const device = await adapter.requestDevice()
if (disposed) return
const context = canvas.getContext("webgpu")
if (!context) {
setSupported(false)
return
}
const format = navigator.gpu.getPreferredCanvasFormat()
const dpr = Math.min(window.devicePixelRatio, 2)
const width = Math.floor(canvas.clientWidth * dpr)
const height = Math.floor(canvas.clientHeight * dpr)
canvas.width = width
canvas.height = height
context.configure({
device,
format,
alphaMode: "premultiplied",
})
// Create shader modules
const computeModule = device.createShaderModule({
code: SHADER_CODE,
})
const blitModule = device.createShaderModule({
code: BLIT_SHADER,
})
// Create buffers
const timeBuffer = device.createBuffer({
size: 16,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
})
const customBuffer = device.createBuffer({
size: 8,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
})
// Create screen texture (for compute output)
const screenTexture = device.createTexture({
size: [width, height],
format: "rgba8unorm",
usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING,
})
// Create compute bind group layout and pipeline
const computeBindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.COMPUTE,
buffer: { type: "uniform" },
},
{
binding: 1,
visibility: GPUShaderStage.COMPUTE,
buffer: { type: "uniform" },
},
{
binding: 2,
visibility: GPUShaderStage.COMPUTE,
storageTexture: { access: "write-only", format: "rgba8unorm" },
},
],
})
const computePipeline = device.createComputePipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [computeBindGroupLayout],
}),
compute: {
module: computeModule,
entryPoint: "main",
},
})
const computeBindGroup = device.createBindGroup({
layout: computeBindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: timeBuffer } },
{ binding: 1, resource: { buffer: customBuffer } },
{ binding: 2, resource: screenTexture.createView() },
],
})
// Create blit pipeline for rendering to canvas
const sampler = device.createSampler({
magFilter: "linear",
minFilter: "linear",
})
const blitBindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.FRAGMENT,
texture: { sampleType: "float" },
},
{
binding: 1,
visibility: GPUShaderStage.FRAGMENT,
sampler: { type: "filtering" },
},
],
})
const blitPipeline = device.createRenderPipeline({
layout: device.createPipelineLayout({
bindGroupLayouts: [blitBindGroupLayout],
}),
vertex: {
module: blitModule,
entryPoint: "vs",
},
fragment: {
module: blitModule,
entryPoint: "fs",
targets: [{ format }],
},
primitive: {
topology: "triangle-list",
},
})
const blitBindGroup = device.createBindGroup({
layout: blitBindGroupLayout,
entries: [
{ binding: 0, resource: screenTexture.createView() },
{ binding: 1, resource: sampler },
],
})
stateRef.current = {
device,
context,
format,
computePipeline,
computeBindGroup,
blitPipeline,
blitBindGroup,
timeBuffer,
customBuffer,
screenTexture,
width,
height,
}
startTimeRef.current = performance.now()
// Start render loop
const render = () => {
if (disposed || !stateRef.current) return
const state = stateRef.current
const elapsed = (performance.now() - startTimeRef.current) / 1000
frameRef.current++
// Update time uniform
const timeData = new ArrayBuffer(16)
const timeView = new DataView(timeData)
timeView.setFloat32(0, elapsed, true)
timeView.setFloat32(4, 0.016, true)
timeView.setUint32(8, frameRef.current, true)
timeView.setUint32(12, 0, true)
state.device.queue.writeBuffer(state.timeBuffer, 0, timeData)
// Update custom uniform (animated values)
const twist = Math.sin(elapsed * 0.3) * 2
const viz = 0.3 + Math.sin(elapsed * 0.5) * 0.2
const customData = new Float32Array([twist, viz])
state.device.queue.writeBuffer(state.customBuffer, 0, customData)
// Create command encoder
const encoder = state.device.createCommandEncoder()
// Run compute shader
const computePass = encoder.beginComputePass()
computePass.setPipeline(state.computePipeline)
computePass.setBindGroup(0, state.computeBindGroup)
computePass.dispatchWorkgroups(
Math.ceil(state.width / 16),
Math.ceil(state.height / 16)
)
computePass.end()
// Blit to canvas using render pass
const canvasTexture = state.context.getCurrentTexture()
const renderPass = encoder.beginRenderPass({
colorAttachments: [
{
view: canvasTexture.createView(),
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: "clear",
storeOp: "store",
},
],
})
renderPass.setPipeline(state.blitPipeline)
renderPass.setBindGroup(0, state.blitBindGroup)
renderPass.draw(6)
renderPass.end()
state.device.queue.submit([encoder.finish()])
animationId = requestAnimationFrame(render)
}
render()
}
init().catch((err) => {
console.error("WebGPU init error:", err)
setSupported(false)
})
// Handle resize
const handleResize = () => {
if (!stateRef.current || !canvas) return
const state = stateRef.current
const dpr = Math.min(window.devicePixelRatio, 2)
const width = Math.floor(canvas.clientWidth * dpr)
const height = Math.floor(canvas.clientHeight * dpr)
if (width === state.width && height === state.height) return
if (width === 0 || height === 0) return
canvas.width = width
canvas.height = height
// Recreate screen texture
state.screenTexture.destroy()
const screenTexture = state.device.createTexture({
size: [width, height],
format: "rgba8unorm",
usage: GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.TEXTURE_BINDING,
})
// Recreate compute bind group
const computeBindGroupLayout = state.computePipeline.getBindGroupLayout(0)
const computeBindGroup = state.device.createBindGroup({
layout: computeBindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: state.timeBuffer } },
{ binding: 1, resource: { buffer: state.customBuffer } },
{ binding: 2, resource: screenTexture.createView() },
],
})
// Recreate blit bind group
const sampler = state.device.createSampler({
magFilter: "linear",
minFilter: "linear",
})
const blitBindGroupLayout = state.blitPipeline.getBindGroupLayout(0)
const blitBindGroup = state.device.createBindGroup({
layout: blitBindGroupLayout,
entries: [
{ binding: 0, resource: screenTexture.createView() },
{ binding: 1, resource: sampler },
],
})
stateRef.current = {
...state,
screenTexture,
computeBindGroup,
blitBindGroup,
width,
height,
}
}
window.addEventListener("resize", handleResize)
return () => {
disposed = true
if (animationId) cancelAnimationFrame(animationId)
window.removeEventListener("resize", handleResize)
if (stateRef.current) {
stateRef.current.screenTexture.destroy()
stateRef.current.timeBuffer.destroy()
stateRef.current.customBuffer.destroy()
}
}
}, [])
if (!supported) {
// Fallback gradient background
return (
<div className="absolute inset-0 bg-gradient-to-br from-indigo-950 via-black to-purple-950" />
)
}
return (
<canvas
ref={canvasRef}
className="absolute inset-0 h-full w-full"
style={{ background: "black" }}
/>
)
}

View File

@@ -0,0 +1,259 @@
import { useEffect, useRef, useState } from "react"
import Hls from "hls.js"
interface VideoPlayerProps {
src: string
autoPlay?: boolean
muted?: boolean
}
export function VideoPlayer({
src,
autoPlay = true,
muted = false,
}: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null)
const hlsRef = useRef<Hls | null>(null)
const [isPlaying, setIsPlaying] = useState(autoPlay)
const [isMuted, setIsMuted] = useState(muted)
const [volume, setVolume] = useState(1)
const [isFullscreen, setIsFullscreen] = useState(false)
const [showControls, setShowControls] = useState(true)
const [error, setError] = useState<string | null>(null)
const hideControlsTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
const video = videoRef.current
if (!video || !src) return
// Check if native HLS is supported (Safari)
if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = src
if (autoPlay) video.play().catch(() => setIsPlaying(false))
return
}
// Use HLS.js for other browsers
if (Hls.isSupported()) {
const hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 6,
})
hls.loadSource(src)
hls.attachMedia(video)
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (autoPlay) video.play().catch(() => setIsPlaying(false))
})
hls.on(Hls.Events.ERROR, (_, data) => {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
setError("Network error - retrying...")
hls.startLoad()
break
case Hls.ErrorTypes.MEDIA_ERROR:
setError("Media error - recovering...")
hls.recoverMediaError()
break
default:
setError("Stream error")
hls.destroy()
break
}
}
})
hlsRef.current = hls
return () => {
hls.destroy()
hlsRef.current = null
}
} else {
setError("HLS playback not supported in this browser")
}
}, [src, autoPlay])
const handlePlayPause = () => {
const video = videoRef.current
if (!video) return
if (video.paused) {
video.play().then(() => setIsPlaying(true))
} else {
video.pause()
setIsPlaying(false)
}
}
const handleMute = () => {
const video = videoRef.current
if (!video) return
video.muted = !video.muted
setIsMuted(video.muted)
}
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const video = videoRef.current
if (!video) return
const newVolume = parseFloat(e.target.value)
video.volume = newVolume
setVolume(newVolume)
if (newVolume === 0) {
setIsMuted(true)
video.muted = true
} else if (isMuted) {
setIsMuted(false)
video.muted = false
}
}
const handleFullscreen = async () => {
const video = videoRef.current
if (!video) return
if (document.fullscreenElement) {
await document.exitFullscreen()
setIsFullscreen(false)
} else {
await video.requestFullscreen()
setIsFullscreen(true)
}
}
const handleMouseMove = () => {
setShowControls(true)
if (hideControlsTimeoutRef.current) {
clearTimeout(hideControlsTimeoutRef.current)
}
hideControlsTimeoutRef.current = setTimeout(() => {
if (isPlaying) setShowControls(false)
}, 3000)
}
if (error) {
return (
<div className="flex h-full w-full items-center justify-center bg-neutral-900 text-neutral-400">
<p>{error}</p>
</div>
)
}
return (
<div
className="group relative h-full w-full bg-black"
onMouseMove={handleMouseMove}
onMouseLeave={() => isPlaying && setShowControls(false)}
>
<video
ref={videoRef}
className="h-full w-full object-contain"
playsInline
muted={isMuted}
onClick={handlePlayPause}
/>
{/* Controls overlay */}
<div
className={`absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300 ${
showControls ? "opacity-100" : "opacity-0"
}`}
>
<div className="flex items-center gap-4">
{/* Play/Pause */}
<button
onClick={handlePlayPause}
className="text-white transition-transform hover:scale-110"
>
{isPlaying ? (
<svg className="h-8 w-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
) : (
<svg className="h-8 w-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
{/* Live indicator */}
<span className="rounded bg-red-600 px-2 py-0.5 text-xs font-bold uppercase text-white">
Live
</span>
<div className="flex-1" />
{/* Volume */}
<div className="flex items-center gap-2">
<button
onClick={handleMute}
className="text-white transition-transform hover:scale-110"
>
{isMuted || volume === 0 ? (
<svg
className="h-6 w-6"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
</svg>
) : (
<svg
className="h-6 w-6"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" />
</svg>
)}
</button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={volume}
onChange={handleVolumeChange}
className="w-20 accent-white"
/>
</div>
{/* Fullscreen */}
<button
onClick={handleFullscreen}
className="text-white transition-transform hover:scale-110"
>
{isFullscreen ? (
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" />
</svg>
) : (
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
</svg>
)}
</button>
</div>
</div>
{/* Big play button when paused */}
{!isPlaying && (
<button
onClick={handlePlayPause}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white/20 p-4 backdrop-blur-sm transition-transform hover:scale-110"
>
<svg className="h-16 w-16 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,47 @@
import { useBilling } from "@flowglad/react"
import { UsageDisplay } from "./UsageDisplay"
import { UpgradeButton } from "./UpgradeButton"
export function BillingStatus() {
const billing = useBilling()
if (!billing.loaded) {
return (
<div className="p-4 bg-zinc-900 rounded-lg">
<div className="animate-pulse h-4 bg-zinc-800 rounded w-24" />
</div>
)
}
const hasSubscription = billing.currentSubscriptions && billing.currentSubscriptions.length > 0
return (
<div className="p-4 bg-zinc-900 rounded-lg space-y-3">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-white">
{hasSubscription ? "Pro Plan" : "Free Plan"}
</h3>
<p className="text-sm text-zinc-400">
{hasSubscription ? "$7.99/month" : "Limited requests"}
</p>
</div>
{!hasSubscription && <UpgradeButton />}
</div>
{hasSubscription && (
<>
<UsageDisplay />
{billing.billingPortalUrl && (
<a
href={billing.billingPortalUrl}
className="text-sm text-zinc-400 hover:text-white transition-colors"
>
Manage subscription
</a>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,26 @@
import { useBilling } from "@flowglad/react"
import { UsageDisplayNew } from "./UsageDisplayNew"
import { UpgradeButtonNew } from "./UpgradeButtonNew"
import { UsageSubmissionForm } from "./UsageSubmissionForm"
export function BillingStatusNew() {
console.log("BillingStatusNew")
const billing = useBilling()
console.log(billing)
console.log("Has currentSubscription:", "currentSubscription" in billing)
if (!billing.loaded) {
return (
<div className="p-4 bg-zinc-900 rounded-lg">
<div className="animate-pulse h-4 bg-zinc-800 rounded w-24" />
</div>
)
}
return (
<div className="p-4 bg-zinc-900 rounded-lg space-y-3">
<UpgradeButtonNew />
<UsageSubmissionForm />
<UsageDisplayNew />
</div>
)
}

View File

@@ -0,0 +1,51 @@
import { useBilling } from "@flowglad/react"
const PRO_PLAN_PRICE_SLUG = "pro_monthly"
type UpgradeButtonProps = {
className?: string
children?: React.ReactNode
}
export function UpgradeButton({ className, children }: UpgradeButtonProps) {
const billing = useBilling()
const handleUpgrade = async () => {
if (!billing.createCheckoutSession) {
console.error("[billing] createCheckoutSession not available")
return
}
try {
await billing.createCheckoutSession({
priceSlug: PRO_PLAN_PRICE_SLUG,
successUrl: `${window.location.origin}/settings?billing=success`,
cancelUrl: `${window.location.origin}/settings?billing=cancelled`,
quantity: 1,
autoRedirect: true,
})
} catch (error) {
console.error("[billing] Checkout error:", error)
}
}
const hasSubscription =
billing.currentSubscriptions && billing.currentSubscriptions.length > 0
if (hasSubscription) {
return null
}
return (
<button
onClick={handleUpgrade}
disabled={!billing.loaded}
className={
className ??
"px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
}
>
{children ?? "Upgrade to Pro"}
</button>
)
}

View File

@@ -0,0 +1,46 @@
import { useBilling } from "@flowglad/react"
const PRO_PLAN_PRICE_SLUG = "single_8_payment"
type UpgradeButtonProps = {
className?: string
children?: React.ReactNode
}
export function UpgradeButtonNew({ className, children }: UpgradeButtonProps) {
const billing = useBilling()
console.log(billing)
const handleUpgrade = async () => {
if (!billing.createCheckoutSession) {
console.error("[billing] createCheckoutSession not available")
return
}
try {
await billing.createCheckoutSession({
priceSlug: PRO_PLAN_PRICE_SLUG,
successUrl: `${window.location.origin}/settings?billing=success`,
cancelUrl: `${window.location.origin}/settings?billing=cancelled`,
quantity: 1,
autoRedirect: true,
})
} catch (error) {
console.error("[billing] Checkout error:", error)
}
}
return (
<button
type="button"
onClick={handleUpgrade}
disabled={!billing.loaded}
className={
className ??
"px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
}
>
{children ?? "Buy 500 requests"}
</button>
)
}

View File

@@ -0,0 +1,39 @@
import { useBilling } from "@flowglad/react"
const AI_REQUESTS_METER = "ai_requests"
export function UsageDisplay() {
const billing = useBilling()
if (!billing.loaded) {
return null
}
const hasSubscription = billing.currentSubscriptions && billing.currentSubscriptions.length > 0
const usage = billing.checkUsageBalance?.(AI_REQUESTS_METER)
if (!hasSubscription) {
return (
<div className="text-xs text-zinc-500">
Free tier: 20 requests/day
</div>
)
}
const remaining = usage?.availableBalance ?? 0
const percentage = Math.min(100, (remaining / 1000) * 100)
return (
<div className="flex items-center gap-2 text-xs">
<div className="flex-1 h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500 transition-all"
style={{ width: `${percentage}%` }}
/>
</div>
<span className="text-zinc-400 tabular-nums">
{remaining} left
</span>
</div>
)
}

View File

@@ -0,0 +1,48 @@
import { useBilling } from "@flowglad/react"
const FREE_METER = "free_requests"
const PAID_METER = "premium_requests"
export function UsageDisplayNew() {
const billing = useBilling()
console.log(billing)
if (!billing.loaded) {
return null
}
const freeUsage = billing.checkUsageBalance?.(FREE_METER)
const paidUsage = billing.checkUsageBalance?.(PAID_METER)
console.log(freeUsage)
console.log(paidUsage)
const freeRemaining = freeUsage?.availableBalance ?? 0
const freePercentage = Math.min(100, (freeRemaining / 1000) * 100)
const remaining = paidUsage?.availableBalance ?? 0
const percentage = Math.min(100, (remaining / 1000) * 100)
return (
<>
<div className="flex items-center gap-2 text-xs">
Free requests
<div className="flex-1 h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500 transition-all"
style={{ width: `${freePercentage}%` }}
/>
</div>
<span className="text-zinc-400 tabular-nums">{freeRemaining} left</span>
</div>
<div className="flex items-center gap-2 text-xs">
Paid requests
<div className="flex-1 h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500 transition-all"
style={{ width: `${percentage}%` }}
/>
</div>
<span className="text-zinc-400 tabular-nums">{remaining} left</span>
</div>
</>
)
}

View File

@@ -0,0 +1,175 @@
import { useState } from "react"
import { useBilling } from "@flowglad/react"
const FREE_METER = "free_requests"
const PAID_METER = "premium_requests"
type MeterSlug = typeof FREE_METER | typeof PAID_METER
export function UsageSubmissionForm() {
const billing = useBilling()
const [freeAmount, setFreeAmount] = useState(1)
const [paidAmount, setPaidAmount] = useState(1)
const [freeError, setFreeError] = useState("")
const [paidError, setPaidError] = useState("")
const [freeSuccess, setFreeSuccess] = useState("")
const [paidSuccess, setPaidSuccess] = useState("")
const [freeSubmitting, setFreeSubmitting] = useState(false)
const [paidSubmitting, setPaidSubmitting] = useState(false)
if (!billing.loaded) {
return null
}
const freeBalance = billing.checkUsageBalance?.(FREE_METER)
const paidBalance = billing.checkUsageBalance?.(PAID_METER)
const freeRemaining = freeBalance?.availableBalance ?? 0
const paidRemaining = paidBalance?.availableBalance ?? 0
const handleSubmit = async (meterSlug: MeterSlug, amount: number) => {
const isFree = meterSlug === FREE_METER
const setError = isFree ? setFreeError : setPaidError
const setSuccess = isFree ? setFreeSuccess : setPaidSuccess
const setSubmitting = isFree ? setFreeSubmitting : setPaidSubmitting
const currentBalance = isFree ? freeRemaining : paidRemaining
// Clear previous messages
setError("")
setSuccess("")
// Client-side validation
if (amount <= 0) {
setError("Amount must be greater than 0")
return
}
if (currentBalance < amount) {
setError(`Maximum usage exceeded. Your balance is ${currentBalance}.`)
return
}
setSubmitting(true)
try {
const response = await fetch("/api/usage-events/create", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ meterSlug, amount }),
})
const data = (await response.json()) as {
error?: string
success?: boolean
currentBalance?: number
}
if (!response.ok || data.error) {
setError(data.error || "Failed to submit usage")
return
}
// Success!
setSuccess(
`Successfully recorded ${amount} ${meterSlug.replace("_", " ")}`,
)
// Reset input to default
if (isFree) {
setFreeAmount(1)
} else {
setPaidAmount(1)
}
// Reload billing data to update balances
if (billing.reload) {
await billing.reload()
}
// Clear success message after 3 seconds
setTimeout(() => setSuccess(""), 3000)
} catch (error) {
console.error("[UsageSubmissionForm] Error:", error)
setError(
error instanceof Error ? error.message : "Network error occurred",
)
} finally {
setSubmitting(false)
}
}
return (
<div className="space-y-4 border-t border-white/10 pt-4">
<h4 className="text-sm font-medium text-white">Submit Usage</h4>
{/* Free Requests Section */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs text-zinc-400">Free Requests</label>
<span className="text-xs text-zinc-500">
Balance: {freeRemaining}
</span>
</div>
<div className="flex gap-2">
<input
type="number"
min="1"
value={freeAmount}
onChange={(e) => {
setFreeAmount(parseInt(e.target.value) || 1)
setFreeError("")
}}
disabled={freeSubmitting}
className="flex-1 px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 disabled:opacity-50"
/>
<button
onClick={() => handleSubmit(FREE_METER, freeAmount)}
disabled={freeSubmitting || !billing.loaded}
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{freeSubmitting ? "Submitting..." : "Submit"}
</button>
</div>
{freeError && <p className="text-xs text-red-400">{freeError}</p>}
{freeSuccess && (
<p className="text-xs text-emerald-400">{freeSuccess}</p>
)}
</div>
{/* Premium Requests Section */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs text-zinc-400">Premium Requests</label>
<span className="text-xs text-zinc-500">
Balance: {paidRemaining}
</span>
</div>
<div className="flex gap-2">
<input
type="number"
min="1"
value={paidAmount}
onChange={(e) => {
setPaidAmount(parseInt(e.target.value) || 1)
setPaidError("")
}}
disabled={paidSubmitting}
className="flex-1 px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 disabled:opacity-50"
/>
<button
onClick={() => handleSubmit(PAID_METER, paidAmount)}
disabled={paidSubmitting || !billing.loaded}
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{paidSubmitting ? "Submitting..." : "Submit"}
</button>
</div>
{paidError && <p className="text-xs text-red-400">{paidError}</p>}
{paidSuccess && (
<p className="text-xs text-emerald-400">{paidSuccess}</p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,7 @@
export { BillingStatus } from "./BillingStatus"
export { BillingStatusNew } from "./BillingStatusNew"
export { UpgradeButton } from "./UpgradeButton"
export { UpgradeButtonNew } from "./UpgradeButtonNew"
export { UsageDisplay } from "./UsageDisplay"
export { UsageDisplayNew } from "./UsageDisplayNew"
export { UsageSubmissionForm } from "./UsageSubmissionForm"

View File

@@ -0,0 +1,5 @@
import { MyBlocksView } from "../BlockLayout"
export default function BlockPage() {
return <MyBlocksView />
}

View File

@@ -0,0 +1,5 @@
import { MarketplaceView } from "../BlockLayout"
export default function MarketplacePage() {
return <MarketplaceView />
}

View File

@@ -0,0 +1,279 @@
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"
import type { CanvasPoint, CanvasSize } from "@/lib/canvas/types"
import type { CanvasBox } from "./types"
const MIN_SIZE = 160
const HANDLE_OFFSETS = ["top-left", "top-right", "bottom-left", "bottom-right"] as const
type DragMode = "move" | "resize"
type DragState = {
id: string
mode: DragMode
handle?: (typeof HANDLE_OFFSETS)[number]
origin: CanvasPoint
startPosition: CanvasPoint
startSize: CanvasSize
latestRect?: {
position: CanvasPoint
size: CanvasSize
}
}
export type CanvasBoardProps = {
boxes: CanvasBox[]
selectedBoxId: string | null
onSelect: (id: string | null) => void
onRectChange: (id: string, rect: { position: CanvasPoint; size: CanvasSize }) => void
onRectCommit: (id: string, rect: { position: CanvasPoint; size: CanvasSize }) => void
className?: string
}
export const CanvasBoard = ({
boxes,
selectedBoxId,
onSelect,
onRectChange,
onRectCommit,
className,
}: CanvasBoardProps) => {
const [dragState, setDragState] = useState<DragState | null>(null)
const dragStateRef = useRef<DragState | null>(null)
const startDrag = useCallback(
(
box: CanvasBox,
mode: DragMode,
origin: CanvasPoint,
handle?: DragState["handle"],
) => {
const state: DragState = {
id: box.id,
mode,
handle,
origin,
startPosition: box.position,
startSize: { width: box.width, height: box.height },
}
dragStateRef.current = state
setDragState(state)
},
[],
)
useEffect(() => {
if (!dragState) return
const handlePointerMove = (event: PointerEvent) => {
const state = dragStateRef.current
if (!state) return
event.preventDefault()
const dx = event.clientX - state.origin.x
const dy = event.clientY - state.origin.y
let nextPosition = state.startPosition
let nextSize = state.startSize
if (state.mode === "move") {
nextPosition = {
x: Math.max(0, Math.round(state.startPosition.x + dx)),
y: Math.max(0, Math.round(state.startPosition.y + dy)),
}
} else if (state.mode === "resize" && state.handle) {
const { size, position } = calculateResize(state, dx, dy)
nextSize = size
nextPosition = position
}
const rect = { position: nextPosition, size: nextSize }
dragStateRef.current = { ...state, latestRect: rect }
onRectChange(state.id, rect)
}
const handlePointerUp = () => {
const state = dragStateRef.current
if (state?.latestRect) {
onRectCommit(state.id, state.latestRect)
}
dragStateRef.current = null
setDragState(null)
}
window.addEventListener("pointermove", handlePointerMove)
window.addEventListener("pointerup", handlePointerUp)
return () => {
window.removeEventListener("pointermove", handlePointerMove)
window.removeEventListener("pointerup", handlePointerUp)
}
}, [dragState, onRectChange, onRectCommit])
const boardSize = useMemo(() => {
const maxX = Math.max(...boxes.map((box) => box.position.x + box.width), 1600)
const maxY = Math.max(...boxes.map((box) => box.position.y + box.height), 900)
return { width: maxX + 480, height: maxY + 480 }
}, [boxes])
return (
<div
className={`relative flex-1 overflow-auto rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_1px_1px,_rgba(255,255,255,0.05)_1px,_transparent_0)] ${className ?? ""}`}
onClick={() => onSelect(null)}
style={{ backgroundSize: "32px 32px" }}
>
<div
className="relative"
style={{ width: Math.max(boardSize.width, 1400), height: Math.max(boardSize.height, 1000) }}
>
{boxes.map((box) => {
const selected = box.id === selectedBoxId
return (
<div
key={box.id}
className={`absolute rounded-[32px] border border-white/10 bg-white/5 p-3 text-white shadow-[0_30px_60px_rgba(0,0,0,0.4)] transition-shadow backdrop-blur ${selected ? "ring-2 ring-white/70" : ""}`}
onClick={(event) => {
event.stopPropagation()
onSelect(box.id)
}}
onPointerDown={(event) => {
if (event.button !== 0) return
event.preventDefault()
event.stopPropagation()
startDrag(box, "move", { x: event.clientX, y: event.clientY })
}}
style={{
transform: `translate(${box.position.x}px, ${box.position.y}px)`,
width: box.width,
height: box.height,
userSelect: "none",
cursor: dragState?.id === box.id ? "grabbing" : "grab",
}}
>
<div className="mb-2 flex items-center justify-between text-xs text-white/70">
<span className="font-medium text-white">{box.name}</span>
<span>
{box.width}×{box.height}
</span>
</div>
<div className="relative flex h-[calc(100%-24px)] items-center justify-center overflow-hidden rounded-2xl bg-black/30">
{box.imageData ? (
<img
alt={box.name}
className="h-full w-full object-cover"
src={`data:image/png;base64,${box.imageData}`}
/>
) : (
<div className="px-4 text-center text-sm text-white/60">
{box.prompt ? box.prompt : "Add a prompt and generate to see your artwork here."}
</div>
)}
{box.isGenerating && (
<div className="absolute inset-0 flex items-center justify-center rounded-2xl bg-black/70 text-sm font-semibold">
Generating
</div>
)}
</div>
{selected && (
<>
{HANDLE_OFFSETS.map((handle) => (
<ResizeHandle
key={handle}
handle={handle}
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
startDrag(box, "resize", { x: event.clientX, y: event.clientY }, handle)
}}
/>
))}
</>
)}
</div>
)
})}
</div>
</div>
)
}
type ResizeHandleProps = {
handle: (typeof HANDLE_OFFSETS)[number]
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void
}
const ResizeHandle = ({ handle, onPointerDown }: ResizeHandleProps) => {
const positions: Record<typeof handle, string> = {
"top-left": "top-0 left-0 -translate-x-1/2 -translate-y-1/2",
"top-right": "top-0 right-0 translate-x-1/2 -translate-y-1/2",
"bottom-left": "bottom-0 left-0 -translate-x-1/2 translate-y-1/2",
"bottom-right": "bottom-0 right-0 translate-x-1/2 translate-y-1/2",
}
const cursors: Record<typeof handle, string> = {
"top-left": "nwse-resize",
"top-right": "nesw-resize",
"bottom-left": "nesw-resize",
"bottom-right": "nwse-resize",
}
return (
<div
className={`absolute h-4 w-4 rounded-full border-2 border-black bg-white ${positions[handle]}`}
style={{ cursor: cursors[handle] }}
onPointerDown={onPointerDown}
/>
)
}
function calculateResize(
state: DragState,
dx: number,
dy: number,
): { position: CanvasPoint; size: CanvasSize } {
let { width, height } = state.startSize
let { x, y } = state.startPosition
const applyWidth = (next: number, anchorRight: boolean) => {
const clamped = Math.max(MIN_SIZE, Math.round(next))
if (anchorRight) {
x = state.startPosition.x + (state.startSize.width - clamped)
}
width = clamped
}
const applyHeight = (next: number, anchorBottom: boolean) => {
const clamped = Math.max(MIN_SIZE, Math.round(next))
if (anchorBottom) {
y = state.startPosition.y + (state.startSize.height - clamped)
}
height = clamped
}
switch (state.handle) {
case "top-left":
applyWidth(state.startSize.width - dx, true)
applyHeight(state.startSize.height - dy, true)
break
case "top-right":
applyWidth(state.startSize.width + dx, false)
applyHeight(state.startSize.height - dy, true)
break
case "bottom-left":
applyWidth(state.startSize.width - dx, true)
applyHeight(state.startSize.height + dy, false)
break
case "bottom-right":
applyWidth(state.startSize.width + dx, false)
applyHeight(state.startSize.height + dy, false)
break
default:
break
}
return {
position: { x: Math.max(0, x), y: Math.max(0, y) },
size: { width, height },
}
}

View File

@@ -0,0 +1,286 @@
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"
import type { SerializedCanvasImage, SerializedCanvasRecord } from "@/lib/canvas/types"
import {
createCanvasBox,
deleteCanvasBox,
generateCanvasBoxImage,
updateCanvasBox,
} from "@/lib/canvas/client"
import type { CanvasBox } from "./types"
import { CanvasBoard } from "./CanvasBoard"
import { CanvasToolbar } from "./CanvasToolbar"
import { PromptPanel } from "./PromptPanel"
export type CanvasExperienceProps = {
initialCanvas: SerializedCanvasRecord
initialImages: SerializedCanvasImage[]
}
const toBox = (image: SerializedCanvasImage): CanvasBox => ({
...image,
isGenerating: false,
})
export const CanvasExperience = ({ initialCanvas, initialImages }: CanvasExperienceProps) => {
const [canvas] = useState(initialCanvas)
const [boxes, setBoxes] = useState<CanvasBox[]>(() => initialImages.map(toBox))
const [selectedBoxId, setSelectedBoxId] = useState<string | null>(
initialImages[0]?.id ?? null,
)
const [isPending, startTransition] = useTransition()
const promptSaveRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const selectedBox = useMemo(
() => boxes.find((box) => box.id === selectedBoxId) ?? null,
[boxes, selectedBoxId],
)
useEffect(() => {
if (!selectedBoxId && boxes[0]) {
setSelectedBoxId(boxes[0].id)
}
}, [boxes, selectedBoxId])
useEffect(() => {
return () => {
if (promptSaveRef.current) {
clearTimeout(promptSaveRef.current)
}
}
}, [])
const updateBoxState = useCallback(
(id: string, updater: (box: CanvasBox) => CanvasBox) => {
setBoxes((prev) => prev.map((box) => (box.id === id ? updater(box) : box)))
},
[],
)
const handleRectChange = useCallback(
(id: string, rect: { position: CanvasBox["position"]; size: { width: number; height: number } }) => {
updateBoxState(id, (box) => ({
...box,
position: rect.position,
width: rect.size.width,
height: rect.size.height,
}))
},
[updateBoxState],
)
const handleRectCommit = useCallback(
(id: string, rect: { position: CanvasBox["position"]; size: { width: number; height: number } }) => {
startTransition(async () => {
try {
const image = await updateCanvasBox(id, {
position: rect.position,
size: rect.size,
})
updateBoxState(id, () => toBox(image))
} catch (error) {
setBanner("Failed to save position")
}
})
},
[startTransition, updateBoxState],
)
const handleAddBox = useCallback(() => {
const reference = boxes[boxes.length - 1]
const fallbackPosition = reference
? { x: reference.position.x + reference.width + 48, y: reference.position.y }
: { x: 0, y: 0 }
startTransition(async () => {
try {
const image = await createCanvasBox({
canvasId: canvas.id,
position: fallbackPosition,
})
const newBox = toBox(image)
setBoxes((prev) => [...prev, newBox])
setSelectedBoxId(newBox.id)
} catch (error) {
setBanner("Failed to add box")
}
})
}, [boxes, canvas.id, startTransition])
const handleDuplicateBox = useCallback(() => {
if (!selectedBox) return
const position = {
x: selectedBox.position.x + 40,
y: selectedBox.position.y + 40,
}
startTransition(async () => {
try {
const image = await createCanvasBox({
canvasId: canvas.id,
name: `${selectedBox.name} Copy`,
prompt: selectedBox.prompt,
position,
size: { width: selectedBox.width, height: selectedBox.height },
modelId: selectedBox.modelId,
styleId: selectedBox.styleId,
})
const newBox = toBox(image)
setBoxes((prev) => [...prev, newBox])
setSelectedBoxId(newBox.id)
} catch (error) {
setBanner("Failed to duplicate box")
}
})
}, [canvas.id, selectedBox, startTransition])
const handleDeleteBox = useCallback(() => {
if (!selectedBoxId) return
if (boxes.length === 1) {
setBanner("Keep at least one box on the canvas.")
return
}
startTransition(async () => {
try {
await deleteCanvasBox(selectedBoxId)
setBoxes((prev) => {
const filtered = prev.filter((box) => box.id !== selectedBoxId)
setSelectedBoxId(filtered[0]?.id ?? null)
return filtered
})
} catch (error) {
setBanner("Failed to delete box")
}
})
}, [boxes.length, selectedBoxId, startTransition])
const schedulePromptSave = useCallback(
(id: string, prompt: string) => {
if (promptSaveRef.current) {
clearTimeout(promptSaveRef.current)
}
promptSaveRef.current = setTimeout(() => {
startTransition(async () => {
try {
const image = await updateCanvasBox(id, { prompt })
updateBoxState(id, () => toBox(image))
} catch (error) {
setBanner("Failed to save prompt")
}
})
}, 600)
},
[startTransition, updateBoxState],
)
const handlePromptChange = useCallback(
(prompt: string) => {
if (!selectedBoxId) return
updateBoxState(selectedBoxId, (box) => ({ ...box, prompt }))
schedulePromptSave(selectedBoxId, prompt)
},
[selectedBoxId, schedulePromptSave, updateBoxState],
)
const handleModelChange = useCallback(
(modelId: string) => {
if (!selectedBoxId) return
updateBoxState(selectedBoxId, (box) => ({ ...box, modelId }))
startTransition(async () => {
try {
await updateCanvasBox(selectedBoxId, { modelId })
} catch (error) {
setBanner("Failed to update model")
}
})
},
[selectedBoxId, startTransition, updateBoxState],
)
const handleStyleChange = useCallback(
(styleId: string) => {
if (!selectedBoxId) return
updateBoxState(selectedBoxId, (box) => ({ ...box, styleId }))
startTransition(async () => {
try {
await updateCanvasBox(selectedBoxId, { styleId })
} catch (error) {
setBanner("Failed to update style")
}
})
},
[selectedBoxId, startTransition, updateBoxState],
)
const handleGenerate = useCallback(() => {
if (!selectedBoxId) {
setBanner("Select a box before generating.")
return
}
const target = boxes.find((box) => box.id === selectedBoxId)
if (!target) return
if (!target.prompt.trim()) {
setBanner("Add a prompt first.")
return
}
updateBoxState(selectedBoxId, (box) => ({ ...box, isGenerating: true }))
startTransition(async () => {
try {
const image = await generateCanvasBoxImage({
imageId: selectedBoxId,
prompt: target.prompt,
modelId: target.modelId,
})
setBoxes((prev) =>
prev.map((box) =>
box.id === selectedBoxId ? { ...toBox(image), isGenerating: false } : box,
),
)
} catch (error) {
updateBoxState(selectedBoxId, (box) => ({ ...box, isGenerating: false }))
setBanner("Image generation failed")
}
})
}, [boxes, selectedBoxId, startTransition, updateBoxState])
const toolbarDisabled = isPending
return (
<div className="flex h-full flex-col gap-4 p-4">
<div className="relative flex-1">
<CanvasBoard
boxes={boxes}
className="h-full"
onRectChange={handleRectChange}
onRectCommit={handleRectCommit}
onSelect={setSelectedBoxId}
selectedBoxId={selectedBoxId}
/>
<div className="pointer-events-none absolute left-6 top-6">
<CanvasToolbar
canDelete={boxes.length > 1 && Boolean(selectedBoxId)}
canDuplicate={Boolean(selectedBoxId)}
disabled={toolbarDisabled}
onAdd={handleAddBox}
onDelete={handleDeleteBox}
onDuplicate={handleDuplicateBox}
/>
</div>
</div>
<PromptPanel
box={selectedBox}
defaultModel={canvas.defaultModel}
isGenerating={selectedBox?.isGenerating}
onGenerate={handleGenerate}
onModelChange={handleModelChange}
onPromptChange={handlePromptChange}
onStyleChange={handleStyleChange}
/>
</div>
)
}

View File

@@ -0,0 +1,54 @@
import { Copy, Plus, Trash } from "lucide-react"
type CanvasToolbarProps = {
disabled?: boolean
canDuplicate?: boolean
canDelete?: boolean
onAdd: () => void
onDuplicate: () => void
onDelete: () => void
}
export const CanvasToolbar = ({
disabled,
canDuplicate = true,
canDelete = true,
onAdd,
onDuplicate,
onDelete,
}: CanvasToolbarProps) => {
const buttonClass =
"h-10 w-10 rounded-2xl border border-white/10 bg-white/10 text-white flex items-center justify-center transition hover:border-white hover:text-white"
return (
<div className="pointer-events-auto flex flex-col gap-3 rounded-[28px] border border-white/10 bg-black/30 p-3 shadow-[0_20px_50px_rgba(0,0,0,0.6)] backdrop-blur">
<button
type="button"
aria-label="Add artboard"
className={buttonClass}
disabled={disabled}
onClick={onAdd}
>
<Plus size={16} />
</button>
<button
type="button"
aria-label="Duplicate artboard"
className={buttonClass}
disabled={disabled || !canDuplicate}
onClick={onDuplicate}
>
<Copy size={16} />
</button>
<button
type="button"
aria-label="Delete artboard"
className={buttonClass}
disabled={disabled || !canDelete}
onClick={onDelete}
>
<Trash size={16} />
</button>
</div>
)
}

View File

@@ -0,0 +1,108 @@
import { useEffect, useState } from "react"
import type { CanvasBox } from "./types"
const STYLE_OPTIONS = [
{ id: "default", label: "Default" },
{ id: "cinematic", label: "Cinematic" },
{ id: "illustration", label: "Illustration" },
]
type PromptPanelProps = {
box: CanvasBox | null
defaultModel: string
isGenerating?: boolean
onPromptChange: (prompt: string) => void
onModelChange: (modelId: string) => void
onStyleChange: (styleId: string) => void
onGenerate: () => void
}
export const PromptPanel = ({
box,
defaultModel,
isGenerating,
onPromptChange,
onModelChange,
onStyleChange,
onGenerate,
}: PromptPanelProps) => {
const [localPrompt, setLocalPrompt] = useState(box?.prompt ?? "")
useEffect(() => {
setLocalPrompt(box?.prompt ?? "")
}, [box?.id, box?.prompt])
if (!box) {
return (
<div className="rounded-3xl border border-dashed border-white/20 bg-black/40 p-6 text-center text-sm text-white/60 backdrop-blur">
Select a canvas box to edit its prompt and generate an image.
</div>
)
}
const handlePromptChange = (value: string) => {
setLocalPrompt(value)
onPromptChange(value)
}
return (
<div className="grid gap-4 rounded-3xl border border-white/10 bg-black/40 p-4 text-white shadow-[0_20px_60px_rgba(0,0,0,0.55)] backdrop-blur">
<div className="space-y-2">
<label className="text-xs uppercase tracking-[0.3em] text-white/50" htmlFor="prompt">
Prompt
</label>
<textarea
id="prompt"
className="min-h-[120px] w-full rounded-2xl border border-white/10 bg-black/60 p-3 text-sm text-white focus:border-white/60 focus:outline-none"
placeholder="Describe what you want Gemini to draw..."
value={localPrompt}
onChange={(event) => handlePromptChange(event.target.value)}
/>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<label className="text-xs uppercase tracking-[0.3em] text-white/50" htmlFor="model">
Model
</label>
<select
id="model"
className="w-full rounded-2xl border border-white/10 bg-black/60 p-3 text-sm text-white focus:border-white/60 focus:outline-none"
value={box.modelId || defaultModel}
onChange={(event) => onModelChange(event.target.value)}
>
<option value="gemini-2.0-flash-exp-image-generation">
Gemini 2.0 Flash (Image)
</option>
</select>
</div>
<div className="space-y-2">
<label className="text-xs uppercase tracking-[0.3em] text-white/50" htmlFor="style">
Style
</label>
<select
id="style"
className="w-full rounded-2xl border border-white/10 bg-black/60 p-3 text-sm text-white focus:border-white/60 focus:outline-none"
value={box.styleId ?? "default"}
onChange={(event) => onStyleChange(event.target.value)}
>
{STYLE_OPTIONS.map((style) => (
<option key={style.id} value={style.id}>
{style.label}
</option>
))}
</select>
</div>
<div className="flex flex-col justify-end">
<button
type="button"
className="h-12 rounded-full bg-white text-xs font-semibold uppercase tracking-[0.3em] text-black shadow-[0_15px_40px_rgba(255,255,255,0.35)] transition hover:bg-white/90 disabled:cursor-not-allowed disabled:bg-white/30"
disabled={isGenerating || !localPrompt.trim()}
onClick={onGenerate}
>
{isGenerating ? "Generating…" : "Generate"}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,10 @@
import type {
SerializedCanvasImage,
SerializedCanvasRecord,
} from "@/lib/canvas/types"
export type CanvasBox = SerializedCanvasImage & {
isGenerating?: boolean
}
export type CanvasSnapshot = SerializedCanvasRecord

View File

@@ -0,0 +1,282 @@
import { useState, useRef, useEffect, useMemo } from "react"
import { ChevronDown } from "lucide-react"
const AVAILABLE_MODELS = [
{
id: "deepseek/deepseek-chat-v3-0324",
name: "DeepSeek V3",
provider: "DeepSeek",
},
{
id: "google/gemini-2.0-flash-001",
name: "Gemini 2.0 Flash",
provider: "Google",
},
{
id: "anthropic/claude-sonnet-4",
name: "Claude Sonnet 4",
provider: "Anthropic",
},
{ id: "openai/gpt-4o", name: "GPT-4o", provider: "OpenAI" },
] as const
export type ModelId = (typeof AVAILABLE_MODELS)[number]["id"]
function ModelSparkle() {
return (
<svg
className="w-4 h-4"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_1212_3035)">
<path
d="M16 8.016C13.9242 8.14339 11.9666 9.02545 10.496 10.496C9.02545 11.9666 8.14339 13.9242 8.016 16H7.984C7.85682 13.9241 6.97483 11.9664 5.5042 10.4958C4.03358 9.02518 2.07588 8.14318 0 8.016L0 7.984C2.07588 7.85682 4.03358 6.97483 5.5042 5.5042C6.97483 4.03358 7.85682 2.07588 7.984 0L8.016 0C8.14339 2.07581 9.02545 4.03339 10.496 5.50397C11.9666 6.97455 13.9242 7.85661 16 7.984V8.016Z"
fill="url(#paint0_radial_1212_3035)"
style={{ stopColor: "#9168C0", stopOpacity: 1 }}
/>
</g>
<defs>
<radialGradient
id="paint0_radial_1212_3035"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(1.588 6.503) rotate(18.6832) scale(17.03 136.421)"
>
<stop
offset="0.067"
stopColor="#9168C0"
style={{ stopColor: "#9168C0", stopOpacity: 1 }}
/>
<stop
offset="0.343"
stopColor="#5684D1"
style={{ stopColor: "#5684D1", stopOpacity: 1 }}
/>
<stop
offset="0.672"
stopColor="#1BA1E3"
style={{ stopColor: "#1BA1E3", stopOpacity: 1 }}
/>
</radialGradient>
<clipPath id="clip0_1212_3035">
<rect
width="16"
height="16"
fill="white"
style={{ fill: "white", fillOpacity: 1 }}
/>
</clipPath>
</defs>
</svg>
)
}
interface ModelSelectProps {
selectedModel: ModelId
onChange: (model: ModelId) => void
}
function ModelSelect({ selectedModel, onChange }: ModelSelectProps) {
const [open, setOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const selected = useMemo(
() => AVAILABLE_MODELS.find((m) => m.id === selectedModel),
[selectedModel],
)
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setOpen(false)
}
}
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false)
}
window.addEventListener("mousedown", handleClickOutside)
window.addEventListener("keydown", handleEscape)
return () => {
window.removeEventListener("mousedown", handleClickOutside)
window.removeEventListener("keydown", handleEscape)
}
}, [])
return (
<div ref={containerRef} className="relative select-none">
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className="flex items-center gap-2 rounded-xl border border-white/8 bg-linear-to-b from-[#2d2e39] via-[#1e1f28] to-[#1a1b24] px-3 py-1.5 text-left shadow-inner shadow-black/40 hover:border-white/14 transition-colors min-w-[170px]"
>
<ModelSparkle />
<div className="flex flex-col leading-tight">
<span className="text-sm font-semibold text-white">
{selected?.name ?? "Choose model"}
</span>
</div>
<ChevronDown
className={`ml-auto h-4 w-4 text-neutral-500 transition-transform ${open ? "rotate-180 text-neutral-300" : ""}`}
strokeWidth={2}
/>
</button>
{open && (
<div className="absolute left-0 bottom-full z-20 my-1 max-w-52 overflow-hidden rounded-2xl border border-white/5 bg-[#0b0c11]/95 backdrop-blur-lg box-shadow-xl">
<div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-neutral-500">
Models
</div>
<div className="flex flex-col py-1">
{AVAILABLE_MODELS.map((model) => {
const isActive = model.id === selectedModel
return (
<button
key={model.id}
type="button"
onClick={() => {
onChange(model.id)
setOpen(false)
}}
className={`flex items-center hover:bg-white/2 rounded-lg gap-3 px-3 py-1 text-sm transition-colors ${isActive ? "text-white" : "text-white/65 hover:text-white"}`}
>
<ModelSparkle />
<div className="flex flex-col cursor-pointer items-start">
<span className="text-[13px] font-semibold leading-tight">
{model.name}
</span>
</div>
</button>
)
})}
</div>
</div>
)}
</div>
)
}
interface ChatInputProps {
onSubmit: (message: string) => void
isLoading: boolean
selectedModel: ModelId
onModelChange: (model: ModelId) => void
limitReached?: boolean
remainingRequests?: number
}
export function ChatInput({
onSubmit,
isLoading,
selectedModel,
onModelChange,
limitReached = false,
remainingRequests,
}: ChatInputProps) {
const [message, setMessage] = useState("")
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (message.trim() && !isLoading && !limitReached) {
onSubmit(message)
setMessage("")
textareaRef.current?.focus()
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSubmit(e)
}
}
const isDisabled = isLoading || limitReached
const sendDisabled = !message.trim() || isDisabled
return (
<div className="px-4 pb-4">
{limitReached && (
<div className="max-w-4xl mx-auto mb-3 overflow-hidden rounded-[26px] border border-white/8 bg-linear-to-b from-[#0c1c27] via-[#0a1923] to-[#08141d] shadow-[0_18px_60px_rgba(0,0,0,0.4)]">
<div className="flex items-center gap-4 px-6 py-4">
<div className="flex-1">
<div className="text-lg font-semibold text-white">
Sign in to continue chatting
</div>
<div className="text-sm text-neutral-300">
Get more requests with a free account
</div>
</div>
<a
href="/login"
className="shrink-0 rounded-2xl px-4 py-2.5 text-sm font-semibold text-white bg-linear-to-b from-[#1ab8b0] via-[#0a8f8b] to-[#0ba58a] shadow-[0_16px_48px_rgba(0,0,0,0.45)] transition hover:brightness-110"
>
Sign in
</a>
</div>
</div>
)}
<form onSubmit={handleSubmit}>
<div
className={`relative min-h-[6.5em] max-w-4xl mx-auto rounded-2xl border border-neutral-700/30 bg-[#181921d9]/90 px-3 p-4 backdrop-blur-lg transition-all hover:border-neutral-600/40 ${limitReached ? "opacity-80" : ""}`}
>
<textarea
ref={textareaRef}
autoFocus
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask anything..."
className="w-full max-h-32 min-h-[24px] resize-none overflow-y-auto bg-transparent text-[15px] text-neutral-100 placeholder-neutral-500 focus:outline-none disabled:opacity-60 scrollbar-hide [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"
rows={3}
disabled={isDisabled}
/>
{limitReached && (
<div className="pointer-events-none absolute inset-0 rounded-2xl bg-linear-to-b from-black/25 via-transparent to-black/30" />
)}
<div className="absolute bottom-0 left-0 p-2 w-full flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<ModelSelect
selectedModel={selectedModel}
onChange={onModelChange}
/>
</div>
<div className="flex items-center gap-3 text-sm text-neutral-400">
{typeof remainingRequests === "number" && (
<span className="text-white/70">
{remainingRequests} requests remaining
</span>
)}
<button
type="submit"
disabled={sendDisabled}
className="flex py-2 px-3 cursor-pointer items-center justify-center text-white rounded-[10px] bg-linear-to-b from-[#5b9fbf] via-[#0d817f] to-[#069d7f] transition-colors duration-300 hover:bg-cyan-700 hover:text-neutral-100 disabled:opacity-40 disabled:cursor-not-allowed shadow-lg box-shadow-xl"
aria-label="Send message"
>
<svg
className="h-3.5 w-3.5"
viewBox="0 0 16 16"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0.184054 0.112806C0.258701 0.0518157 0.349387 0.0137035 0.445193 0.00305845C0.540999 -0.00758662 0.637839 0.00968929 0.724054 0.0528059L15.7241 7.55281C15.8073 7.59427 15.8773 7.65812 15.9262 7.73717C15.9751 7.81622 16.001 7.90734 16.001 8.00031C16.001 8.09327 15.9751 8.18439 15.9262 8.26344C15.8773 8.34249 15.8073 8.40634 15.7241 8.44781L0.724054 15.9478C0.637926 15.9909 0.541171 16.0083 0.445423 15.9977C0.349675 15.9872 0.25901 15.9492 0.184331 15.8884C0.109651 15.8275 0.0541361 15.7464 0.0244608 15.6548C-0.00521444 15.5631 -0.0077866 15.4649 0.0170539 15.3718L1.98305 8.00081L0.0170539 0.629806C-0.00790602 0.536702 -0.0054222 0.438369 0.0242064 0.346644C0.053835 0.25492 0.109345 0.173715 0.184054 0.112806ZM2.88405 8.50081L1.27005 14.5568L14.3821 8.00081L1.26905 1.44481L2.88405 7.50081H9.50005C9.63266 7.50081 9.75984 7.55348 9.85361 7.64725C9.94738 7.74102 10.0001 7.8682 10.0001 8.00081C10.0001 8.13341 9.94738 8.26059 9.85361 8.35436C9.75984 8.44813 9.63266 8.50081 9.50005 8.50081H2.88405Z" />
</svg>
</button>
</div>
</div>
</div>
</form>
</div>
)
}
export { AVAILABLE_MODELS }

View File

@@ -0,0 +1,45 @@
import { Sparkles } from "lucide-react"
import {
MessageBubble,
TypingIndicator,
StreamingMessage,
type Message,
} from "./MessageBubble"
export function EmptyChatState() {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center">
<div className="w-16 h-16 rounded-3xl bg-gradient-to-br from-teal-500/20 to-teal-500/5 flex items-center justify-center mb-6 shadow-lg">
<Sparkles className="w-8 h-8 text-teal-400" />
</div>
<h2 className="text-2xl font-semibold mb-3 text-white">How can I help?</h2>
<p className="text-neutral-400 text-sm max-w-sm">
Start a conversation below.
</p>
</div>
)
}
interface MessageListProps {
messages: Message[]
streamingContent: string
isStreaming: boolean
}
export function MessageList({
messages,
streamingContent,
isStreaming,
}: MessageListProps) {
return (
<div className="space-y-6 pb-4">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{isStreaming && streamingContent && (
<StreamingMessage content={streamingContent} />
)}
{isStreaming && !streamingContent && <TypingIndicator />}
</div>
)
}

View File

@@ -0,0 +1,618 @@
import { useEffect, useMemo, useState, useRef } from "react"
import { Link } from "@tanstack/react-router"
import { useLiveQuery, eq } from "@tanstack/react-db"
import { LogIn, Menu, X, LogOut } from "lucide-react"
import { authClient } from "@/lib/auth-client"
import {
getChatThreadsCollection,
getChatMessagesCollection,
} from "@/lib/collections"
import ContextPanel from "@/components/Context-panel"
import { ChatInput, AVAILABLE_MODELS, type ModelId } from "./ChatInput"
import { EmptyChatState, MessageList } from "./ChatMessages"
import type { Message } from "./MessageBubble"
const MODEL_STORAGE_KEY = "gen_chat_model"
const FREE_REQUEST_LIMIT = 2
function getStoredModel(): ModelId {
if (typeof window === "undefined") return AVAILABLE_MODELS[0].id
const stored = localStorage.getItem(MODEL_STORAGE_KEY)
if (stored && AVAILABLE_MODELS.some((m) => m.id === stored)) {
return stored as ModelId
}
return AVAILABLE_MODELS[0].id
}
function setStoredModel(model: ModelId) {
localStorage.setItem(MODEL_STORAGE_KEY, model)
}
async function createThread(title = "New chat") {
const res = await fetch("/api/chat/mutations", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "include",
body: JSON.stringify({ action: "createThread", title }),
})
if (!res.ok) throw new Error("Failed to create chat")
const json = (await res.json()) as {
thread: { id: number; title: string; created_at?: string }
}
return {
...json.thread,
created_at: json.thread.created_at
? new Date(json.thread.created_at)
: new Date(),
}
}
async function addMessage({
threadId,
role,
content,
}: {
threadId: number
role: "user" | "assistant"
content: string
}) {
const res = await fetch("/api/chat/mutations", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "include",
body: JSON.stringify({
action: "addMessage",
threadId,
role,
content,
}),
})
if (!res.ok) throw new Error("Failed to add message")
const json = (await res.json()) as {
message: { id: number; thread_id: number; role: string; content: string; created_at?: string }
}
return {
...json.message,
created_at: json.message.created_at
? new Date(json.message.created_at)
: new Date(),
}
}
type DBMessage = {
id: number
thread_id: number
role: string
content: string
created_at: Date
}
type GuestMessage = {
id: number
role: "user" | "assistant"
content: string
}
// Guest chat component - saves to database with null user_id
function GuestChat() {
const [isStreaming, setIsStreaming] = useState(false)
const [streamingContent, setStreamingContent] = useState("")
const [guestMessages, setGuestMessages] = useState<GuestMessage[]>([])
const [pendingUserMessage, setPendingUserMessage] = useState<string | null>(null)
const [selectedModel, setSelectedModel] = useState<ModelId>(AVAILABLE_MODELS[0].id)
const [threadId, setThreadId] = useState<number | null>(null)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
useEffect(() => {
setSelectedModel(getStoredModel())
}, [])
const messages: Message[] = useMemo(() => {
const msgs = guestMessages.map((m) => ({
id: m.id,
role: m.role,
content: m.content,
}))
// Add pending user message if streaming and not yet in guestMessages
if (pendingUserMessage && !msgs.some((m) => m.role === "user" && m.content === pendingUserMessage)) {
msgs.push({
id: Date.now(),
role: "user",
content: pendingUserMessage,
})
}
return msgs
}, [guestMessages, pendingUserMessage])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages, streamingContent])
const handleModelChange = (model: ModelId) => {
setSelectedModel(model)
setStoredModel(model)
}
const userMessagesSent = guestMessages.filter((m) => m.role === "user").length
const limitReached = userMessagesSent >= FREE_REQUEST_LIMIT
const handleSubmit = async (userContent: string) => {
if (!userContent.trim() || isStreaming || limitReached) return
// Set pending message immediately so it shows while streaming
setPendingUserMessage(userContent)
setIsStreaming(true)
setStreamingContent("")
try {
const newUserMsg: GuestMessage = {
id: Date.now(),
role: "user",
content: userContent,
}
setGuestMessages((prev) => [...prev, newUserMsg])
setPendingUserMessage(null) // Clear pending once added to guestMessages
const apiMessages = [
...guestMessages.map((m) => ({ role: m.role, content: m.content })),
{ role: "user" as const, content: userContent },
]
const res = await fetch("/api/chat/guest", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ messages: apiMessages, model: selectedModel, threadId }),
})
if (!res.ok) {
throw new Error(`AI request failed: ${res.status}`)
}
// Get thread ID from response header
const responseThreadId = res.headers.get("X-Thread-Id")
if (responseThreadId && !threadId) {
setThreadId(Number(responseThreadId))
}
const reader = res.body?.getReader()
if (!reader) {
throw new Error("No response body")
}
const decoder = new TextDecoder()
let accumulated = ""
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
accumulated += chunk
setStreamingContent(accumulated)
}
const newAssistantMsg: GuestMessage = {
id: Date.now() + 1,
role: "assistant",
content: accumulated,
}
setGuestMessages((prev) => [...prev, newAssistantMsg])
setStreamingContent("")
} catch (error) {
console.error("Chat error:", error)
setStreamingContent("")
setPendingUserMessage(null)
} finally {
setIsStreaming(false)
}
}
const remainingRequests = Math.max(0, FREE_REQUEST_LIMIT - userMessagesSent)
return (
<>
{/* Mobile header - only visible on small screens */}
<header className="md:hidden fixed top-0 left-0 right-0 z-40 flex items-center justify-between px-4 py-3 bg-[#07080f]/95 backdrop-blur-sm border-b border-white/5">
<button
onClick={() => setMobileMenuOpen(true)}
className="p-2 -ml-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
aria-label="Open menu"
>
<Menu size={22} />
</button>
<Link
to="/auth"
className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium bg-white text-black rounded-lg hover:bg-white/90 transition-colors"
>
<LogIn size={16} />
<span>Sign in</span>
</Link>
</header>
{/* Mobile slide-out menu */}
{mobileMenuOpen && (
<div className="md:hidden fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/60"
onClick={() => setMobileMenuOpen(false)}
/>
<aside className="absolute top-0 left-0 h-full w-72 bg-[#0a0b10] border-r border-white/5 flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-white/5">
<span className="text-white font-medium">Menu</span>
<button
onClick={() => setMobileMenuOpen(false)}
className="p-2 -mr-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
aria-label="Close menu"
>
<X size={20} />
</button>
</div>
<div className="flex-1 p-4">
<ContextPanel
chats={[]}
activeChatId={null}
isAuthenticated={false}
profile={null}
/>
</div>
<div className="p-4 border-t border-white/5">
<Link
to="/auth"
onClick={() => setMobileMenuOpen(false)}
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 text-sm font-medium bg-white text-black rounded-lg hover:bg-white/90 transition-colors"
>
<LogIn size={18} />
<span>Sign in</span>
</Link>
</div>
</aside>
</div>
)}
<div className="min-h-screen max-w-[1700px] mx-auto md:grid md:grid-cols-[280px_1fr] bg-inherit">
<aside className="hidden md:flex border-r flex-col w-full h-screen border-none">
<ContextPanel
chats={[]}
activeChatId={null}
isAuthenticated={false}
profile={null}
/>
</aside>
<main className="flex flex-col h-screen bg-[#07080f] pt-14 md:pt-0">
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto py-8 px-4 sm:px-6">
{messages.length === 0 && !isStreaming ? (
<EmptyChatState />
) : (
<>
<MessageList
messages={messages}
streamingContent={streamingContent}
isStreaming={isStreaming}
/>
<div ref={messagesEndRef} />
</>
)}
</div>
</div>
<div className="shrink-0">
<ChatInput
onSubmit={handleSubmit}
isLoading={isStreaming}
selectedModel={selectedModel}
onModelChange={handleModelChange}
remainingRequests={remainingRequests}
limitReached={limitReached}
/>
</div>
</main>
</div>
</>
)
}
// Authenticated chat component - uses Electric SQL
function AuthenticatedChat({ user }: { user: { name?: string | null; email: string; image?: string | null } }) {
const [isStreaming, setIsStreaming] = useState(false)
const [streamingContent, setStreamingContent] = useState("")
const [activeThreadId, setActiveThreadId] = useState<number | null>(null)
const [pendingMessages, setPendingMessages] = useState<Message[]>([])
const [selectedModel, setSelectedModel] = useState<ModelId>(AVAILABLE_MODELS[0].id)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const handleSignOut = async () => {
await authClient.signOut()
window.location.href = "/"
}
const chatThreadsCollection = getChatThreadsCollection()
const chatMessagesCollection = getChatMessagesCollection()
const { data: threads = [] } = useLiveQuery((q) =>
q
.from({ chatThreads: chatThreadsCollection })
.orderBy(({ chatThreads }) => chatThreads.created_at),
)
const sortedThreads = useMemo(
() => [...threads].sort((a, b) => b.id - a.id),
[threads],
)
useEffect(() => {
if (activeThreadId === null && sortedThreads.length > 0) {
setActiveThreadId(sortedThreads[0].id)
}
}, [sortedThreads, activeThreadId])
const { data: dbMessages = [] } = useLiveQuery((q) => {
const base = q
.from({ chatMessages: chatMessagesCollection })
.orderBy(({ chatMessages }) => chatMessages.created_at)
if (activeThreadId === null) {
return base.where(({ chatMessages }) => eq(chatMessages.thread_id, -1))
}
return base.where(({ chatMessages }) =>
eq(chatMessages.thread_id, activeThreadId),
)
})
useEffect(() => {
if (pendingMessages.length === 0) return
const stillPending = pendingMessages.filter((pending) => {
const isSynced = dbMessages.some(
(m: DBMessage) =>
m.role === pending.role &&
m.content === pending.content,
)
return !isSynced
})
if (stillPending.length !== pendingMessages.length) {
setPendingMessages(stillPending)
}
}, [dbMessages, pendingMessages])
useEffect(() => {
setSelectedModel(getStoredModel())
}, [])
const messages: Message[] = useMemo(() => {
const baseMessages: Message[] = dbMessages.map((m: DBMessage) => ({
id: m.id,
role: m.role as "user" | "assistant",
content: m.content,
createdAt: m.created_at,
}))
const msgs = [...baseMessages]
for (const pending of pendingMessages) {
const alreadyExists = msgs.some(
(m) => m.role === pending.role && m.content === pending.content,
)
if (!alreadyExists) {
msgs.push(pending)
}
}
return msgs
}, [dbMessages, pendingMessages])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages, streamingContent])
const handleModelChange = (model: ModelId) => {
setSelectedModel(model)
setStoredModel(model)
}
const handleSubmit = async (userContent: string) => {
if (!userContent.trim() || isStreaming) return
setIsStreaming(true)
setStreamingContent("")
try {
let threadId = activeThreadId
if (!threadId) {
const thread = await createThread(userContent.slice(0, 40) || "New chat")
threadId = thread.id
setActiveThreadId(thread.id)
}
const pendingUserMsg: Message = {
id: Date.now(),
role: "user",
content: userContent,
createdAt: new Date(),
}
setPendingMessages((prev) => [...prev, pendingUserMsg])
await addMessage({ threadId, role: "user", content: userContent })
const threadMessages = dbMessages.filter((m: DBMessage) => m.thread_id === threadId)
const apiMessages = [
...threadMessages.map((m: DBMessage) => ({
role: m.role as "user" | "assistant",
content: m.content,
})),
{ role: "user" as const, content: userContent },
]
const res = await fetch("/api/chat/ai", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "include",
body: JSON.stringify({ threadId, messages: apiMessages, model: selectedModel }),
})
if (!res.ok) {
throw new Error(`AI request failed: ${res.status}`)
}
const reader = res.body?.getReader()
if (!reader) {
throw new Error("No response body")
}
const decoder = new TextDecoder()
let accumulated = ""
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
accumulated += chunk
setStreamingContent(accumulated)
}
if (accumulated) {
const pendingAssistantMsg: Message = {
id: Date.now() + 1,
role: "assistant",
content: accumulated,
createdAt: new Date(),
}
setPendingMessages((prev) => [...prev, pendingAssistantMsg])
}
setStreamingContent("")
} catch (error) {
console.error("Chat error:", error)
setStreamingContent("")
} finally {
setIsStreaming(false)
}
}
return (
<>
{/* Mobile header - only visible on small screens */}
<header className="md:hidden fixed top-0 left-0 right-0 z-40 flex items-center justify-between px-4 py-3 bg-[#07080f]/95 backdrop-blur-sm border-b border-white/5">
<button
onClick={() => setMobileMenuOpen(true)}
className="p-2 -ml-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
aria-label="Open menu"
>
<Menu size={22} />
</button>
<button
onClick={handleSignOut}
className="flex items-center gap-2 p-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
aria-label="Sign out"
>
<div className="w-7 h-7 rounded-full bg-cyan-600 flex items-center justify-center">
<span className="text-xs font-medium text-white">
{user.email?.charAt(0).toUpperCase()}
</span>
</div>
</button>
</header>
{/* Mobile slide-out menu */}
{mobileMenuOpen && (
<div className="md:hidden fixed inset-0 z-50">
<div
className="absolute inset-0 bg-black/60"
onClick={() => setMobileMenuOpen(false)}
/>
<aside className="absolute top-0 left-0 h-full w-72 bg-[#0a0b10] border-r border-white/5 flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-white/5">
<span className="text-white font-medium">Menu</span>
<button
onClick={() => setMobileMenuOpen(false)}
className="p-2 -mr-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
aria-label="Close menu"
>
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto">
<ContextPanel
chats={sortedThreads}
activeChatId={activeThreadId ? activeThreadId.toString() : null}
isAuthenticated={true}
profile={user}
/>
</div>
<div className="p-4 border-t border-white/5">
<div className="flex items-center gap-3 mb-3 px-1">
<div className="w-8 h-8 rounded-full bg-cyan-600 flex items-center justify-center flex-shrink-0">
<span className="text-sm font-medium text-white">
{user.email?.charAt(0).toUpperCase()}
</span>
</div>
<span className="text-white/70 text-sm truncate">{user.email}</span>
</div>
<button
onClick={() => {
setMobileMenuOpen(false)
handleSignOut()
}}
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 text-sm font-medium text-red-400 hover:text-red-300 hover:bg-white/5 rounded-lg transition-colors"
>
<LogOut size={18} />
<span>Sign out</span>
</button>
</div>
</aside>
</div>
)}
<div className="min-h-screen max-w-[1700px] mx-auto md:grid md:grid-cols-[280px_1fr] bg-inherit">
<aside className="hidden md:flex border-r flex-col w-full h-screen border-none">
<ContextPanel
chats={sortedThreads}
activeChatId={activeThreadId ? activeThreadId.toString() : null}
isAuthenticated={true}
profile={user}
/>
</aside>
<main className="flex flex-col h-screen bg-[#07080f] pt-14 md:pt-0">
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto py-8 px-4 sm:px-6">
{messages.length === 0 && !isStreaming ? (
<EmptyChatState />
) : (
<>
<MessageList
messages={messages}
streamingContent={streamingContent}
isStreaming={isStreaming}
/>
<div ref={messagesEndRef} />
</>
)}
</div>
</div>
<div className="shrink-0">
<ChatInput
onSubmit={handleSubmit}
isLoading={isStreaming}
selectedModel={selectedModel}
onModelChange={handleModelChange}
/>
</div>
</main>
</div>
</>
)
}
export function ChatPage() {
const { data: session, isPending } = authClient.useSession()
const isAuthenticated = !!session?.user
if (isPending) {
return null
}
// Render different components based on auth state
// This prevents Electric SQL collections from being initialized for guests
return isAuthenticated ? <AuthenticatedChat user={session.user} /> : <GuestChat />
}

View File

@@ -0,0 +1,215 @@
import ReactMarkdown from "react-markdown"
import remarkGfm from "remark-gfm"
export type Message = {
id: string | number
role: "user" | "assistant"
content: string
createdAt?: Date
}
export function MessageBubble({ message }: { message: Message }) {
const isUser = message.role === "user"
if (isUser) {
return (
<div className="flex justify-end">
<div className="w-fit max-w-2xl rounded-xl px-4 py-2 bg-[#16171f] inner-shadow-xl outline-1 outline-neutral-100/12 text-white">
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
</div>
</div>
)
}
// Assistant message with Markdown rendering
return (
<div className="max-w-3xl">
<div className="prose prose-sm prose-invert max-w-none text-white">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ ...props }) => (
<h1
className="text-2xl font-bold mt-6 mb-4 text-white"
{...props}
/>
),
h2: ({ ...props }) => (
<h2
className="text-xl font-bold mt-5 mb-3 text-white"
{...props}
/>
),
h3: ({ ...props }) => (
<h3
className="text-lg font-semibold mt-4 mb-2 text-white"
{...props}
/>
),
p: ({ ...props }) => (
<p className="mb-4 leading-relaxed text-neutral-200" {...props} />
),
ul: ({ ...props }) => (
<ul
className="list-disc list-inside mb-4 space-y-1 text-neutral-200"
{...props}
/>
),
ol: ({ ...props }) => (
<ol
className="list-decimal list-inside mb-4 space-y-1 text-neutral-200"
{...props}
/>
),
li: ({ ...props }) => (
<li className="ml-2 text-neutral-200" {...props} />
),
code: ({ className, children, ...props }: any) => {
const isInline = !className
return isInline ? (
<code
className="bg-[#1e1f28] px-1.5 py-0.5 rounded text-sm font-mono text-teal-300"
{...props}
>
{children}
</code>
) : (
<code
className="block bg-[#1e1f28] p-3 rounded-lg overflow-x-auto text-sm font-mono my-4 text-neutral-200"
{...props}
>
{children}
</code>
)
},
pre: ({ ...props }) => <pre className="my-4" {...props} />,
blockquote: ({ ...props }) => (
<blockquote
className="border-l-4 border-teal-500/50 pl-4 italic my-4 text-neutral-400"
{...props}
/>
),
a: ({ ...props }) => (
<a
className="text-teal-400 hover:text-teal-300 underline"
target="_blank"
rel="noopener noreferrer"
{...props}
/>
),
strong: ({ ...props }) => (
<strong className="font-semibold text-white" {...props} />
),
}}
>
{message.content}
</ReactMarkdown>
</div>
</div>
)
}
export function TypingIndicator() {
return (
<div className="flex items-center gap-1.5 py-2">
<div className="w-2 h-2 bg-teal-500 rounded-full animate-pulse" />
<div
className="w-2 h-2 bg-teal-500 rounded-full animate-pulse"
style={{ animationDelay: "0.2s" }}
/>
<div
className="w-2 h-2 bg-teal-500 rounded-full animate-pulse"
style={{ animationDelay: "0.4s" }}
/>
</div>
)
}
export function StreamingMessage({ content }: { content: string }) {
return (
<div className="max-w-3xl">
<div className="prose prose-sm prose-invert max-w-none text-white">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ ...props }) => (
<h1
className="text-2xl font-bold mt-6 mb-4 text-white"
{...props}
/>
),
h2: ({ ...props }) => (
<h2
className="text-xl font-bold mt-5 mb-3 text-white"
{...props}
/>
),
h3: ({ ...props }) => (
<h3
className="text-lg font-semibold mt-4 mb-2 text-white"
{...props}
/>
),
p: ({ ...props }) => (
<p className="mb-4 leading-relaxed text-neutral-200" {...props} />
),
ul: ({ ...props }) => (
<ul
className="list-disc list-inside mb-4 space-y-1 text-neutral-200"
{...props}
/>
),
ol: ({ ...props }) => (
<ol
className="list-decimal list-inside mb-4 space-y-1 text-neutral-200"
{...props}
/>
),
li: ({ ...props }) => (
<li className="ml-2 text-neutral-200" {...props} />
),
code: ({ className, children, ...props }: any) => {
const isInline = !className
return isInline ? (
<code
className="bg-[#1e1f28] px-1.5 py-0.5 rounded text-sm font-mono text-teal-300"
{...props}
>
{children}
</code>
) : (
<code
className="block bg-[#1e1f28] p-3 rounded-lg overflow-x-auto text-sm font-mono my-4 text-neutral-200"
{...props}
>
{children}
</code>
)
},
pre: ({ ...props }) => <pre className="my-4" {...props} />,
blockquote: ({ ...props }) => (
<blockquote
className="border-l-4 border-teal-500/50 pl-4 italic my-4 text-neutral-400"
{...props}
/>
),
a: ({ ...props }) => (
<a
className="text-teal-400 hover:text-teal-300 underline"
target="_blank"
rel="noopener noreferrer"
{...props}
/>
),
strong: ({ ...props }) => (
<strong className="font-semibold text-white" {...props} />
),
}}
>
{content}
</ReactMarkdown>
<span className="inline-block w-1.5 h-4 ml-0.5 bg-teal-500/70 animate-pulse" />
</div>
</div>
)
}

View File

@@ -0,0 +1,29 @@
import { useBilling } from "@flowglad/react"
export function RegularPlanButton() {
const { createCheckoutSession, loaded, errors } = useBilling()
if (!loaded || !createCheckoutSession) {
return (
<button type="button" disabled>
Loading checkout
</button>
)
}
if (errors) {
return <p>Unable to load checkout right now.</p>
}
const handlePurchase = async () => {
await createCheckoutSession({
priceSlug: "",
quantity: 1,
successUrl: `${window.location.origin}/billing/success`,
cancelUrl: `${window.location.origin}/billing/cancel`,
autoRedirect: true,
})
}
return <button onClick={handlePurchase}>Buy now</button>
}

View File

@@ -0,0 +1,13 @@
import { createServerFn } from "@tanstack/react-start"
export const getPunkSongs = createServerFn({
method: "GET",
}).handler(async () => [
{ id: 1, name: "Teenage Dirtbag", artist: "Wheatus" },
{ id: 2, name: "Smells Like Teen Spirit", artist: "Nirvana" },
{ id: 3, name: "The Middle", artist: "Jimmy Eat World" },
{ id: 4, name: "My Own Worst Enemy", artist: "Lit" },
{ id: 5, name: "Fat Lip", artist: "Sum 41" },
{ id: 6, name: "All the Small Things", artist: "blink-182" },
{ id: 7, name: "Beverly Hills", artist: "Weezer" },
])

View File

@@ -0,0 +1,75 @@
import postgres from "postgres"
import { drizzle } from "drizzle-orm/postgres-js"
import * as schema from "./schema"
type Hyperdrive = {
connectionString: string
}
type CloudflareEnv = {
DATABASE_URL?: string
HYPERDRIVE?: Hyperdrive
}
// Note: NO caching - Cloudflare Workers don't allow sharing I/O objects across requests
// Get the database connection string, preferring DATABASE_URL over Hyperdrive
const getConnectionString = (env?: CloudflareEnv): string => {
// Prefer DATABASE_URL if set (direct connection, bypasses Hyperdrive)
if (env?.DATABASE_URL) {
return env.DATABASE_URL
}
// Fall back to Hyperdrive if available
if (env?.HYPERDRIVE?.connectionString) {
return env.HYPERDRIVE.connectionString
}
// Fall back to process.env (local dev)
if (process.env.DATABASE_URL) {
return process.env.DATABASE_URL
}
throw new Error("No database connection available. Set DATABASE_URL or configure Hyperdrive.")
}
// Helper to get Cloudflare env from server context
const getCloudflareEnv = (): CloudflareEnv | undefined => {
try {
const { getServerContext } = require("@tanstack/react-start/server") as {
getServerContext: () => { cloudflare?: { env?: CloudflareEnv } } | null
}
return getServerContext()?.cloudflare?.env
} catch {
return undefined
}
}
// Convenience function to get db using server context
export const db = () => getDb(getConnectionString(getCloudflareEnv()))
export const authDb = () => getAuthDb(getConnectionString(getCloudflareEnv()))
// Main db with snake_case casing for app tables (chat_threads, chat_messages)
export const getDb = (databaseUrlOrHyperdrive: string | Hyperdrive) => {
const connectionString =
typeof databaseUrlOrHyperdrive === "string"
? databaseUrlOrHyperdrive
: databaseUrlOrHyperdrive.connectionString
// Create fresh connection per request for Cloudflare Workers compatibility
const sql = postgres(connectionString, { prepare: false })
return drizzle(sql, { schema, casing: "snake_case" })
}
// Auth db WITHOUT casing transform for better-auth tables (users, sessions, etc.)
// better-auth uses camelCase columns and manages its own naming
export const getAuthDb = (databaseUrlOrHyperdrive: string | Hyperdrive) => {
const connectionString =
typeof databaseUrlOrHyperdrive === "string"
? databaseUrlOrHyperdrive
: databaseUrlOrHyperdrive.connectionString
// Create fresh connection per request for Cloudflare Workers compatibility
const sql = postgres(connectionString, { prepare: false })
return drizzle(sql, { schema })
}

View File

@@ -0,0 +1,283 @@
import {
boolean,
doublePrecision,
foreignKey,
integer,
jsonb,
pgTable,
text,
timestamp,
uuid,
varchar,
} from "drizzle-orm/pg-core"
import { createSchemaFactory } from "drizzle-zod"
import { z } from "zod"
const { createSelectSchema } = createSchemaFactory({ zodInstance: z })
// Better-auth tables (using camelCase as better-auth expects)
export const users = pgTable("users", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
username: text("username").unique(), // unique username for stream URLs (linsa.io/username)
emailVerified: boolean("emailVerified")
.$defaultFn(() => false)
.notNull(),
image: text("image"),
createdAt: timestamp("createdAt")
.$defaultFn(() => new Date())
.notNull(),
updatedAt: timestamp("updatedAt")
.$defaultFn(() => new Date())
.notNull(),
})
export const sessions = pgTable("sessions", {
id: text("id").primaryKey(),
expiresAt: timestamp("expiresAt").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("createdAt").notNull(),
updatedAt: timestamp("updatedAt").notNull(),
ipAddress: text("ipAddress"),
userAgent: text("userAgent"),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
})
export const accounts = pgTable("accounts", {
id: text("id").primaryKey(),
accountId: text("accountId").notNull(),
providerId: text("providerId").notNull(),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
accessToken: text("accessToken"),
refreshToken: text("refreshToken"),
idToken: text("idToken"),
accessTokenExpiresAt: timestamp("accessTokenExpiresAt"),
refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("createdAt").notNull(),
updatedAt: timestamp("updatedAt").notNull(),
})
export const verifications = pgTable("verifications", {
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expiresAt").notNull(),
createdAt: timestamp("createdAt").$defaultFn(() => new Date()),
updatedAt: timestamp("updatedAt").$defaultFn(() => new Date()),
})
// App tables (using snake_case for Electric sync compatibility)
export const chat_threads = pgTable("chat_threads", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
title: text("title").notNull(),
user_id: text("user_id"), // nullable for guest users
created_at: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
})
export const chat_messages = pgTable("chat_messages", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
thread_id: integer("thread_id")
.notNull()
.references(() => chat_threads.id, { onDelete: "cascade" }),
role: varchar("role", { length: 32 }).notNull(),
content: text("content").notNull(),
created_at: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
})
export const canvas = pgTable("canvas", {
id: uuid("id").primaryKey().defaultRandom(),
owner_id: text("owner_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull().default("Untitled Canvas"),
width: integer("width").notNull().default(1024),
height: integer("height").notNull().default(1024),
default_model: text("default_model")
.notNull()
.default("gemini-2.5-flash-image-preview"),
default_style: text("default_style").notNull().default("default"),
background_prompt: text("background_prompt"),
created_at: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updated_at: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
})
export const canvas_images = pgTable(
"canvas_images",
{
id: uuid("id").primaryKey().defaultRandom(),
canvas_id: uuid("canvas_id")
.notNull()
.references(() => canvas.id, { onDelete: "cascade" }),
name: text("name").notNull().default("Untitled Image"),
prompt: text("prompt").notNull().default(""),
model_id: text("model_id")
.notNull()
.default("gemini-2.0-flash-exp-image-generation"),
model_used: text("model_used"),
style_id: text("style_id").notNull().default("default"),
width: integer("width").notNull().default(512),
height: integer("height").notNull().default(512),
position: jsonb("position")
.$type<{ x: number; y: number }>()
.$defaultFn(() => ({ x: 0, y: 0 }))
.notNull(),
rotation: doublePrecision("rotation").notNull().default(0),
content_base64: text("content_base64"),
image_url: text("image_url"),
metadata: jsonb("metadata"),
branch_parent_id: uuid("branch_parent_id"),
created_at: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updated_at: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => ({
branchParentFk: foreignKey({
columns: [table.branch_parent_id],
foreignColumns: [table.id],
name: "canvas_images_branch_parent_id_canvas_images_id_fk",
}).onDelete("set null"),
}),
)
// Context items for website/file content injection into chat
export const context_items = pgTable("context_items", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
user_id: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: varchar("type", { length: 32 }).notNull(), // 'url' or 'file'
url: text("url"), // URL for web content
name: text("name").notNull(), // Display name (domain/path or filename)
content: text("content"), // Fetched markdown content
refreshing: boolean("refreshing").notNull().default(false),
parent_id: integer("parent_id"), // For hierarchical URL structure
created_at: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updated_at: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
})
// Junction table for active context items per thread
export const thread_context_items = pgTable("thread_context_items", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
thread_id: integer("thread_id")
.notNull()
.references(() => chat_threads.id, { onDelete: "cascade" }),
context_item_id: integer("context_item_id")
.notNull()
.references(() => context_items.id, { onDelete: "cascade" }),
created_at: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
})
export const blocks = pgTable("blocks", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
name: text("name").notNull(),
created_at: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
})
// Browser sessions - for saving browser tabs (Safari, Chrome, etc.)
export const browser_sessions = pgTable("browser_sessions", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(), // e.g., "2024-01-23-safari-tabs-1"
browser: varchar("browser", { length: 32 }).notNull().default("safari"), // safari, chrome, firefox, arc, etc.
tab_count: integer("tab_count").notNull().default(0),
is_favorite: boolean("is_favorite").notNull().default(false),
captured_at: timestamp("captured_at", { withTimezone: true })
.defaultNow()
.notNull(), // when the session was captured
created_at: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
})
export const browser_session_tabs = pgTable("browser_session_tabs", {
id: uuid("id").primaryKey().defaultRandom(),
session_id: uuid("session_id")
.notNull()
.references(() => browser_sessions.id, { onDelete: "cascade" }),
title: text("title").notNull().default(""),
url: text("url").notNull(),
position: integer("position").notNull().default(0), // order within session
favicon_url: text("favicon_url"), // optional favicon
created_at: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
})
// =============================================================================
// Streams (Twitch-like live streaming)
// =============================================================================
export const streams = pgTable("streams", {
id: uuid("id").primaryKey().defaultRandom(),
user_id: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
title: text("title").notNull().default("Live Stream"),
description: text("description"),
is_live: boolean("is_live").notNull().default(false),
viewer_count: integer("viewer_count").notNull().default(0),
stream_key: text("stream_key").notNull().unique(), // secret key for streaming
// Stream endpoints (set by Linux server)
hls_url: text("hls_url"), // HLS playback URL
thumbnail_url: text("thumbnail_url"),
started_at: timestamp("started_at", { withTimezone: true }),
ended_at: timestamp("ended_at", { withTimezone: true }),
created_at: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updated_at: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
})
export const selectStreamsSchema = createSelectSchema(streams)
export type Stream = z.infer<typeof selectStreamsSchema>
export const selectUsersSchema = createSelectSchema(users)
export const selectChatThreadSchema = createSelectSchema(chat_threads)
export const selectChatMessageSchema = createSelectSchema(chat_messages)
export const selectCanvasSchema = createSelectSchema(canvas)
export const selectCanvasImageSchema = createSelectSchema(canvas_images)
export const selectContextItemSchema = createSelectSchema(context_items)
export const selectThreadContextItemSchema =
createSelectSchema(thread_context_items)
export const selectBrowserSessionSchema = createSelectSchema(browser_sessions)
export const selectBrowserSessionTabSchema =
createSelectSchema(browser_session_tabs)
export type User = z.infer<typeof selectUsersSchema>
export type ChatThread = z.infer<typeof selectChatThreadSchema>
export type ChatMessage = z.infer<typeof selectChatMessageSchema>
export type CanvasRecord = z.infer<typeof selectCanvasSchema>
export type CanvasImage = z.infer<typeof selectCanvasImageSchema>
export type ContextItem = z.infer<typeof selectContextItemSchema>
export type ThreadContextItem = z.infer<typeof selectThreadContextItemSchema>
export type BrowserSession = z.infer<typeof selectBrowserSessionSchema>
export type BrowserSessionTab = z.infer<typeof selectBrowserSessionTabSchema>

29
packages/web/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,29 @@
declare namespace Cloudflare {
interface Env {
DATABASE_URL: string
HYPERDRIVE: Hyperdrive
ELECTRIC_URL: string
ELECTRIC_SOURCE_ID?: string
ELECTRIC_SOURCE_SECRET?: string
BETTER_AUTH_SECRET: string
APP_BASE_URL?: string
RESEND_API_KEY?: string
RESEND_FROM_EMAIL?: string
OPENROUTER_API_KEY?: string
OPENROUTER_MODEL?: string
GEMINI_API_KEY?: string
FLOWGLAD_SECRET_KEY?: string
}
}
interface Hyperdrive {
connectionString: string
}
interface ImportMetaEnv {
readonly VITE_FLOWGLAD_ENABLED?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -0,0 +1,612 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import Canvas from "./components/Canvas"
import Overlay from "./components/Overlay"
import Onboarding from "./components/Onboarding"
import {
CanvasProvider,
useCanvasStore,
type CanvasBox,
} from "./store/canvasStore"
import type {
SerializedCanvasImage,
SerializedCanvasRecord,
} from "@/lib/canvas/types"
import {
createCanvasBox,
deleteCanvasBox,
generateCanvasBoxImage,
updateCanvasBox,
} from "@/lib/canvas/client"
import type { CanvasRect } from "./config"
import { motion } from "framer-motion"
const TOKEN_COST = 1
const DEFAULT_TOKEN_BALANCE = { tokens: 999, premiumTokens: 999 }
type BladeCanvasExperienceProps = {
initialCanvas: SerializedCanvasRecord
initialImages: SerializedCanvasImage[]
}
const getImageDataUrl = (image: SerializedCanvasImage) => {
if (image.imageUrl) {
return image.imageUrl
}
if (image.imageData) {
const mime =
typeof image.metadata?.mimeType === "string" ? image.metadata.mimeType : "image/png"
return `data:${mime};base64,${image.imageData}`
}
return undefined
}
const uiModelFromProvider = (
modelId: string | null | undefined,
): CanvasBox["model"] => {
if (!modelId) {
return "gemini"
}
if (modelId.includes("gpt-image") || modelId.includes("dall")) {
return "dall-e-3"
}
if (modelId.includes("nano-banana")) {
return "nano-banana"
}
return "gemini"
}
const GEMINI_MODEL = "gemini-2.5-flash-image-preview"
const providerModelFromUi = (model: CanvasBox["model"]) => {
switch (model) {
case "dall-e-3":
return "gpt-image-1"
case "nano-banana":
return "nano-banana"
default:
return GEMINI_MODEL
}
}
const mapImageToBoxInput = (image: SerializedCanvasImage): CanvasBox => ({
id: image.id,
name: image.name,
prompt: image.prompt ?? "",
rect: {
x: image.position?.x ?? 0,
y: image.position?.y ?? 0,
width: image.width,
height: image.height,
},
imageUrl: getImageDataUrl(image),
description:
typeof image.metadata?.description === "string" ? image.metadata.description : undefined,
model: uiModelFromProvider(image.modelId),
styleId: image.styleId ?? "default",
branchParentId: image.branchParentId ?? null,
})
const rectToPosition = (rect: CanvasRect) => ({ x: rect.x, y: rect.y })
const rectToSize = (rect: CanvasRect) => ({ width: rect.width, height: rect.height })
export function BladeCanvasExperience({
initialCanvas,
initialImages,
}: BladeCanvasExperienceProps) {
return (
<CanvasProvider>
<BladeCanvasExperienceContent
initialCanvas={initialCanvas}
initialImages={initialImages}
/>
</CanvasProvider>
)
}
function BladeCanvasExperienceContent({
initialCanvas,
initialImages,
}: BladeCanvasExperienceProps) {
const canvasId = initialCanvas.id
const {
boxes,
addBox,
updateBoxData,
deleteBox,
setSelectedBoxId,
selectedBoxId,
reset,
startOnboarding,
} = useCanvasStore()
const [promptValue, setPromptValue] = useState("")
const [error, setError] = useState<string | null>(null)
const [generatingBoxIds, setGeneratingBoxIds] = useState<string[]>([])
const [promptContextLabel, setPromptContextLabel] = useState<string | null>(null)
const [editingBoxId, setEditingBoxId] = useState<string | null>(null)
const promptSaveRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const initializedRef = useRef(false)
const activeBox = useMemo(
() => boxes.find((box) => box.id === selectedBoxId) ?? null,
[boxes, selectedBoxId],
)
const editingBox = useMemo(
() => (editingBoxId ? boxes.find((box) => box.id === editingBoxId) ?? null : null),
[boxes, editingBoxId],
)
useEffect(() => {
if (initializedRef.current) {
return
}
reset(initialImages.map(mapImageToBoxInput))
initializedRef.current = true
}, [initialImages, reset])
useEffect(() => {
if (!activeBox) {
setPromptValue("")
return
}
setPromptValue((prev) => (prev === activeBox.prompt ? prev : activeBox.prompt))
}, [activeBox?.id, activeBox?.prompt])
useEffect(() => {
if (boxes.length === 0) {
startOnboarding()
}
}, [boxes.length, startOnboarding])
useEffect(() => {
if (!activeBox) {
setPromptContextLabel(null)
return
}
if (activeBox.branchParentId) {
const boxIndex = boxes.findIndex((box) => box.id === activeBox.id) + 1
const parentIndex =
boxes.findIndex((box) => box.id === activeBox.branchParentId) + 1
setPromptContextLabel(
`Box ${boxIndex} Branch of Box ${parentIndex > 0 ? parentIndex : "?"}`,
)
} else if (activeBox.name) {
setPromptContextLabel(activeBox.name)
} else {
const boxIndex = boxes.findIndex((box) => box.id === activeBox.id) + 1
setPromptContextLabel(boxIndex ? `Box ${boxIndex}` : null)
}
}, [activeBox, boxes])
useEffect(() => {
return () => {
if (promptSaveRef.current) {
clearTimeout(promptSaveRef.current)
}
}
}, [])
const schedulePromptSave = useCallback((boxId: string, prompt: string) => {
if (promptSaveRef.current) {
clearTimeout(promptSaveRef.current)
}
promptSaveRef.current = setTimeout(() => {
updateCanvasBox(boxId, { prompt }).catch((err) => {
console.error("[canvas] failed to persist prompt", err)
setError("Failed to save prompt")
})
}, 600)
}, [])
const syncBoxWithImage = useCallback(
(localId: string, image: SerializedCanvasImage) => {
const mapped = mapImageToBoxInput(image)
updateBoxData(localId, () => mapped as CanvasBox)
if (localId !== mapped.id) {
setSelectedBoxId((prev) => (prev === localId ? mapped.id : prev))
setGeneratingBoxIds((prev) => prev.map((id) => (id === localId ? mapped.id : id)))
}
return mapped.id
},
[setSelectedBoxId, updateBoxData],
)
const persistNewBox = useCallback(
async (box: CanvasBox) => {
const image = await createCanvasBox({
canvasId,
name: box.name,
prompt: box.prompt,
position: rectToPosition(box.rect),
size: rectToSize(box.rect),
modelId: providerModelFromUi(box.model),
styleId: box.styleId ?? "default",
branchParentId: box.branchParentId ?? null,
})
return syncBoxWithImage(box.id, image)
},
[canvasId, syncBoxWithImage],
)
const handlePromptValueChange = useCallback(
(value: string) => {
setPromptValue(value)
if (!selectedBoxId) {
return
}
updateBoxData(selectedBoxId, (box) => ({
...box,
prompt: value,
}))
schedulePromptSave(selectedBoxId, value)
},
[schedulePromptSave, selectedBoxId, updateBoxData],
)
const handleSelectStyle = useCallback(
async (styleId: string) => {
if (!selectedBoxId) return
updateBoxData(selectedBoxId, (box) => ({ ...box, styleId }))
try {
await updateCanvasBox(selectedBoxId, { styleId })
} catch (err) {
console.error("[canvas] failed to update style", err)
setError("Failed to update style")
}
},
[selectedBoxId, updateBoxData],
)
const handleModelChange = useCallback(
async (modelId: CanvasBox["model"], boxId?: string) => {
const targetId = boxId ?? selectedBoxId
if (!targetId) return
updateBoxData(targetId, (box) => ({ ...box, model: modelId }))
try {
await updateCanvasBox(targetId, {
modelId: providerModelFromUi(modelId),
})
} catch (err) {
console.error("[canvas] failed to update model", err)
setError("Failed to update model")
}
},
[selectedBoxId, updateBoxData],
)
const handleRectCommit = useCallback(
(boxId: string, rect: CanvasRect) => {
updateCanvasBox(boxId, {
position: rectToPosition(rect),
size: rectToSize(rect),
}).catch((err) => {
console.error("[canvas] failed to persist rect", err)
setError("Failed to save box position")
})
},
[],
)
const handleEditPromptChange = useCallback(
(boxId: string, value: string) => {
updateBoxData(boxId, (box) => ({
...box,
prompt: value,
}))
schedulePromptSave(boxId, value)
},
[schedulePromptSave, updateBoxData],
)
const handleEditSizeChange = useCallback(
(boxId: string, dimension: "width" | "height", value: number) => {
if (!Number.isFinite(value) || value <= 0) {
return
}
const target = boxes.find((box) => box.id === boxId)
const width = dimension === "width" ? value : target?.rect.width ?? value
const height = dimension === "height" ? value : target?.rect.height ?? value
updateBoxData(boxId, (box) => ({
...box,
rect: {
...box.rect,
width,
height,
},
}))
updateCanvasBox(boxId, {
size: { width, height },
}).catch((err) => {
console.error("[canvas] failed to update size", err)
setError("Failed to update size")
})
},
[boxes, updateBoxData],
)
const handleAddBox = useCallback(async () => {
const created = addBox()
if (!created) {
return
}
try {
const newId = await persistNewBox(created)
setSelectedBoxId(newId)
setError(null)
} catch (err) {
console.error("[canvas] failed to create box", err)
deleteBox(created.id)
setError("Failed to add box")
}
}, [addBox, deleteBox, persistNewBox, setSelectedBoxId])
const handleDeleteSelected = useCallback(async () => {
if (!selectedBoxId) {
return
}
if (boxes.length <= 1) {
setError("Keep at least one box on the canvas.")
return
}
try {
await deleteCanvasBox(selectedBoxId)
deleteBox(selectedBoxId)
setGeneratingBoxIds((prev) => prev.filter((id) => id !== selectedBoxId))
setError(null)
} catch (err) {
console.error("[canvas] failed to delete box", err)
setError("Failed to delete box")
}
}, [boxes.length, deleteBox, selectedBoxId])
const handleBranchFrom = useCallback(
async (box: CanvasBox) => {
const parentIndex = boxes.findIndex((candidate) => candidate.id === box.id) + 1
const branchName = `Box ${boxes.length + 1} Branch of Box ${parentIndex || "?"}`
const created = addBox(
{
name: branchName,
prompt: box.prompt,
model: box.model,
styleId: box.styleId,
branchParentId: box.id,
},
{ select: true },
)
if (!created) {
return
}
setPromptValue(box.prompt)
try {
const newId = await persistNewBox(created)
setSelectedBoxId(newId)
setError(null)
} catch (err) {
console.error("[canvas] failed to branch box", err)
deleteBox(created.id)
setError("Unable to create a branch box")
}
},
[addBox, boxes, deleteBox, persistNewBox, setSelectedBoxId],
)
const handleSubmitPrompt = useCallback(
async (value: string) => {
const trimmed = value.trim()
if (!trimmed) {
return false
}
let targetBoxId = selectedBoxId ?? null
let targetBox = targetBoxId
? boxes.find((candidate) => candidate.id === targetBoxId) ?? null
: null
if (!targetBox || !targetBoxId) {
const created = addBox({ prompt: trimmed }, { select: true })
if (!created) {
setError("Unable to create a box for this prompt")
return false
}
try {
targetBoxId = await persistNewBox(created)
targetBox = { ...created, id: targetBoxId }
setSelectedBoxId(targetBoxId)
} catch (err) {
console.error("[canvas] failed to create prompt box", err)
deleteBox(created.id)
setError("Unable to create a box for this prompt")
return false
}
}
const boxId = targetBoxId
let effectivePrompt = trimmed
if (targetBox?.branchParentId) {
const parent = boxes.find((candidate) => candidate.id === targetBox.branchParentId)
if (parent) {
const parentPrompt = parent.prompt.trim()
if (parentPrompt && !effectivePrompt.startsWith(parentPrompt)) {
effectivePrompt = [parentPrompt, effectivePrompt]
.map((item) => item.trim())
.filter(Boolean)
.join(" ")
}
}
}
setGeneratingBoxIds((prev) => (prev.includes(boxId) ? prev : [...prev, boxId]))
setError(null)
let currentId = boxId
try {
const image = await generateCanvasBoxImage({
imageId: boxId,
prompt: effectivePrompt,
modelId: providerModelFromUi(targetBox!.model),
})
currentId = syncBoxWithImage(boxId, image)
setPromptValue(effectivePrompt)
return true
} catch (err) {
console.error("[canvas] generation failed", err)
const message = err instanceof Error ? err.message : "Unable to generate image"
setError(message)
return false
} finally {
setGeneratingBoxIds((prev) => prev.filter((id) => id !== currentId))
}
},
[addBox, boxes, deleteBox, persistNewBox, selectedBoxId, setSelectedBoxId, syncBoxWithImage],
)
const currentBoxName = activeBox?.name ?? null
const isGenerating = selectedBoxId ? generatingBoxIds.includes(selectedBoxId) : false
const handleOpenEdit = useCallback(
(box: CanvasBox) => {
setEditingBoxId(box.id)
setSelectedBoxId(box.id)
},
[setSelectedBoxId],
)
const handleCloseEdit = useCallback(() => {
setEditingBoxId(null)
}, [])
useEffect(() => {
if (editingBoxId && !boxes.find((box) => box.id === editingBoxId)) {
setEditingBoxId(null)
}
}, [boxes, editingBoxId])
if (editingBox) {
const imageUrl = editingBox.imageUrl
return (
<div className="flex h-screen w-full divide-x divide-white/10 overflow-hidden bg-neutral-950 text-white">
<div className="relative flex-1 overflow-hidden">
{imageUrl ? (
<>
<div
className="absolute inset-0 scale-110 transform bg-cover bg-center blur-3xl opacity-50"
style={{ backgroundImage: `url(${imageUrl})` }}
/>
<div className="relative z-10 flex h-full w-full items-center justify-center p-8">
<motion.img
layoutId={`box-image-${editingBox.id}`}
src={imageUrl}
alt={editingBox.name}
className="max-h-full max-w-full rounded-3xl shadow-[0_30px_60px_rgba(0,0,0,0.5)]"
/>
</div>
</>
) : (
<div className="flex h-full w-full items-center justify-center text-white/60">
No image generated yet.
</div>
)}
</div>
<div className="flex w-full max-w-md flex-col gap-6 bg-neutral-900/80 p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm uppercase tracking-[0.4em] text-white/60">Editing</p>
<h2 className="text-2xl font-semibold">{editingBox.name}</h2>
</div>
<button
type="button"
onClick={handleCloseEdit}
className="rounded-full border border-white/20 px-4 py-1 text-sm text-white transition hover:border-white/50"
>
Done
</button>
</div>
<div className="space-y-4">
<label className="flex flex-col gap-2 text-sm">
<span className="text-white/60">Prompt</span>
<textarea
className="min-h-[150px] rounded-2xl border border-white/10 bg-white/5 p-3 text-sm text-white outline-none focus:border-white/40"
value={editingBox.prompt}
onChange={(event) => handleEditPromptChange(editingBox.id, event.target.value)}
/>
</label>
<div className="grid grid-cols-2 gap-4">
<label className="flex flex-col gap-1 text-sm">
<span className="text-white/60">Width</span>
<input
type="number"
min={64}
className="rounded-2xl border border-white/10 bg-white/5 px-3 py-2 text-white outline-none focus:border-white/40"
value={Math.round(editingBox.rect.width)}
onChange={(event) => handleEditSizeChange(editingBox.id, "width", Number(event.target.value))}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-white/60">Height</span>
<input
type="number"
min={64}
className="rounded-2xl border border-white/10 bg-white/5 px-3 py-2 text-white outline-none focus:border-white/40"
value={Math.round(editingBox.rect.height)}
onChange={(event) => handleEditSizeChange(editingBox.id, "height", Number(event.target.value))}
/>
</label>
</div>
<label className="flex flex-col gap-2 text-sm">
<span className="text-white/60">Model</span>
<select
className="rounded-2xl border border-white/10 bg-white/5 px-3 py-2 text-white outline-none focus:border-white/40"
value={editingBox.model}
onChange={(event) => handleModelChange(event.target.value as CanvasBox["model"], editingBox.id)}
>
<option value="gemini">Gemini</option>
<option value="dall-e-3">DALL·E 3</option>
<option value="nano-banana" disabled>
Nano Banana (Coming soon)
</option>
</select>
</label>
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/80">
<span>Style</span>
<span className="font-semibold">{editingBox.styleId}</span>
</div>
</div>
</div>
</div>
)
}
return (
<div className="relative h-screen w-full overflow-hidden rounded-xl border border-slate-200 bg-white text-slate-900 transition-colors duration-300 dark:border-neutral-800 dark:bg-neutral-950 dark:text-slate-100">
<Canvas
generatingBoxIds={generatingBoxIds}
error={error}
onBranchFrom={handleBranchFrom}
onRectCommit={handleRectCommit}
onEditBox={handleOpenEdit}
editingBoxId={editingBoxId}
/>
<Overlay
value={promptValue}
onValueChange={handlePromptValueChange}
onSubmit={handleSubmitPrompt}
isGenerating={isGenerating}
error={error}
contextLabel={promptContextLabel}
onAddBox={handleAddBox}
onDeleteSelected={handleDeleteSelected}
onSelectStyle={handleSelectStyle}
onSelectModel={handleModelChange}
tokenBalance={DEFAULT_TOKEN_BALANCE}
tokenCost={TOKEN_COST}
/>
{currentBoxName ? (
<div className="absolute left-1/2 top-4 -translate-x-1/2 text-xs uppercase tracking-wide text-white/50">
Selected: {currentBoxName}
</div>
) : null}
{/* <Onboarding /> */}
</div>
)
}

View File

@@ -0,0 +1,893 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react"
import { AnimatePresence, motion } from "framer-motion"
import { CANVAS_CONFIG, type CanvasRect } from "../config"
import { useCanvasStore, type CanvasBox } from "../store/canvasStore"
import { GitBranch, Pencil, Trash2, Type } from "lucide-react"
const normaliseRect = (rect: CanvasRect): CanvasRect => ({
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
})
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max)
const MIN_VIEWPORT_SCALE = 0.4
const MAX_VIEWPORT_SCALE = 3
const ZOOM_SENSITIVITY = 0.0012
export type CanvasControls = {
undo: () => void
redo: () => void
reset: () => void
addBox: () => void
deleteSelected: () => void
canUndo: boolean
canRedo: boolean
hasSelection: boolean
}
type CanvasProps = {
generatingBoxIds?: string[]
error?: string | null
onControlsChange?: (controls: CanvasControls) => void
onBranchFrom?: (box: CanvasBox) => void
onRectCommit?: (boxId: string, rect: CanvasRect) => void
onEditBox?: (box: CanvasBox) => void
editingBoxId?: string | null
}
export default function Canvas({
generatingBoxIds = [],
error,
onControlsChange,
onBranchFrom,
onRectCommit,
onEditBox,
editingBoxId = null,
}: CanvasProps) {
const canvasRef = useRef<HTMLDivElement>(null)
const panStateRef = useRef<{
pointerId: number
startPointer: { x: number; y: number }
startOffset: { x: number; y: number }
} | null>(null)
const {
boxes,
addBox,
updateBoxRect,
updateBoxData,
deleteBox,
selectedBoxId,
setSelectedBoxId,
} = useCanvasStore()
const [viewport, setViewport] = useState<{
x: number
y: number
scale: number
}>(() => ({ x: 0, y: 0, scale: 1 }))
const [contextMenuBoxId, setContextMenuBoxId] = useState<string | null>(null)
const statusMessage = error
? error
: "Enter a prompt below to create an image."
const centerOnBox = useCallback((box: CanvasBox) => {
const element = canvasRef.current
if (!element) {
return
}
const rect = element.getBoundingClientRect()
setViewport((prev) => {
const scale = prev.scale
const boxCenterX = box.rect.x + box.rect.width / 2
const boxCenterY = box.rect.y + box.rect.height / 2
return {
...prev,
x: rect.width / 2 - boxCenterX * scale,
y: rect.height / 2 - boxCenterY * scale,
}
})
}, [])
const previousBoxesLengthRef = useRef<number>(boxes.length)
useEffect(() => {
const previousLength = previousBoxesLengthRef.current
if (boxes.length === 0) {
previousBoxesLengthRef.current = 0
return
}
if (previousLength === 0) {
centerOnBox(boxes[0])
} else if (boxes.length > previousLength) {
const last = boxes[boxes.length - 1]
centerOnBox(last)
}
previousBoxesLengthRef.current = boxes.length
}, [boxes, centerOnBox])
const handleAdd = useCallback(() => {
setContextMenuBoxId(null)
addBox()
}, [addBox])
const handleDelete = useCallback(
(targetId?: string) => {
const id = targetId ?? selectedBoxId
if (!id) {
return
}
deleteBox(id)
setContextMenuBoxId((prev) => (prev === id ? null : prev))
},
[deleteBox, selectedBoxId]
)
const handleContextMenuOpen = useCallback(
(boxId: string) => {
setContextMenuBoxId(boxId)
setSelectedBoxId(boxId)
},
[setSelectedBoxId]
)
const handleContextMenuClose = useCallback(() => {
setContextMenuBoxId(null)
}, [])
const handleCanvasPointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) {
return
}
const target = event.target as HTMLElement
if (target.closest('[data-canvas-box="true"]')) {
setContextMenuBoxId(null)
return
}
event.preventDefault()
const element = event.currentTarget
panStateRef.current = {
pointerId: event.pointerId,
startPointer: { x: event.clientX, y: event.clientY },
startOffset: { x: viewport.x, y: viewport.y },
}
setSelectedBoxId(null)
setContextMenuBoxId(null)
if (element.setPointerCapture) {
try {
element.setPointerCapture(event.pointerId)
} catch {
// ignore capture errors
}
}
},
[setSelectedBoxId, viewport.x, viewport.y]
)
const handleCanvasPointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const state = panStateRef.current
if (!state || event.pointerId !== state.pointerId) {
return
}
event.preventDefault()
const dx = event.clientX - state.startPointer.x
const dy = event.clientY - state.startPointer.y
setViewport((prev) => ({
...prev,
x: state.startOffset.x + dx,
y: state.startOffset.y + dy,
}))
},
[]
)
const handleCanvasPointerEnd = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const state = panStateRef.current
if (!state || event.pointerId !== state.pointerId) {
return
}
panStateRef.current = null
const element = event.currentTarget
if (element.releasePointerCapture) {
try {
element.releasePointerCapture(event.pointerId)
} catch {
// ignore
}
}
},
[]
)
const handleWheel = useCallback((event: WheelEvent) => {
const element = canvasRef.current
if (!element) {
return
}
event.preventDefault()
event.stopPropagation()
const rect = element.getBoundingClientRect()
const point = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
}
setViewport((prev) => {
const scaleMultiplier = Math.exp(-event.deltaY * ZOOM_SENSITIVITY)
const nextScale = clamp(
prev.scale * scaleMultiplier,
MIN_VIEWPORT_SCALE,
MAX_VIEWPORT_SCALE
)
if (nextScale === prev.scale) {
return prev
}
const worldX = (point.x - prev.x) / prev.scale
const worldY = (point.y - prev.y) / prev.scale
const nextX = point.x - worldX * nextScale
const nextY = point.y - worldY * nextScale
return {
x: nextX,
y: nextY,
scale: nextScale,
}
})
}, [])
useEffect(() => {
const element = canvasRef.current
if (!element) {
return
}
const handle = (event: WheelEvent) => {
handleWheel(event)
}
element.addEventListener("wheel", handle, { passive: false })
return () => {
element.removeEventListener("wheel", handle)
}
}, [handleWheel])
useEffect(() => {
onControlsChange?.({
undo: () => undefined,
redo: () => undefined,
reset: () => {
const target =
boxes.find((box) => box.id === selectedBoxId) ?? boxes[0] ?? null
if (target) {
centerOnBox(target)
} else {
setViewport((prev) => ({ ...prev, x: 0, y: 0 }))
}
},
addBox: handleAdd,
deleteSelected: handleDelete,
canUndo: false,
canRedo: false,
hasSelection: Boolean(selectedBoxId),
})
}, [
boxes,
centerOnBox,
handleAdd,
handleDelete,
onControlsChange,
selectedBoxId,
])
const handleDrag = useCallback(
(id: string, start: CanvasRect, dx: number, dy: number) => {
const nextRect = normaliseRect({
...start,
x: start.x + dx / viewport.scale,
y: start.y + dy / viewport.scale,
})
updateBoxRect(id, () => nextRect)
return nextRect
},
[updateBoxRect, viewport.scale]
)
const handleResize = useCallback(
(
id: string,
start: CanvasRect,
handle: ResizeHandle,
dx: number,
dy: number
) => {
const nextRect = calculateResizedRect(
handle,
start,
dx / viewport.scale,
dy / viewport.scale,
{
minWidth: CANVAS_CONFIG.MIN_WIDTH,
minHeight: CANVAS_CONFIG.MIN_HEIGHT,
maxWidth: CANVAS_CONFIG.MAX_PIXEL_WIDTH,
maxHeight: CANVAS_CONFIG.MAX_PIXEL_HEIGHT,
}
)
updateBoxRect(id, () => nextRect)
return nextRect
},
[updateBoxRect, viewport.scale]
)
return (
<div
ref={canvasRef}
className="relative h-full w-full overflow-hidden bg-white transition-colors duration-300 dark:bg-neutral-950"
onPointerDown={handleCanvasPointerDown}
onPointerMove={handleCanvasPointerMove}
onPointerUp={handleCanvasPointerEnd}
onPointerLeave={handleCanvasPointerEnd}
onPointerCancel={handleCanvasPointerEnd}
style={{ touchAction: "none" }}
>
<div
className="absolute left-0 top-0"
style={{
transform: `translate3d(${viewport.x}px, ${viewport.y}px, 0) scale(${viewport.scale})`,
transformOrigin: "0 0",
}}
>
{boxes.map((box, index) => (
<CanvasBox
key={box.id}
box={box}
index={index}
isSelected={box.id === selectedBoxId}
defaultStatusMessage={statusMessage}
isGenerating={generatingBoxIds.includes(box.id)}
onSelect={() => setSelectedBoxId(box.id)}
onDrag={handleDrag}
onResize={handleResize}
onInteractionStart={() => {
const element = canvasRef.current
const panState = panStateRef.current
if (panState && element?.releasePointerCapture) {
try {
element.releasePointerCapture(panState.pointerId)
} catch {
// ignore
}
}
panStateRef.current = null
}}
onInteractionEnd={(_, rect) => {
if (onRectCommit) {
onRectCommit(box.id, rect)
}
}}
contextMenuOpen={contextMenuBoxId === box.id}
onOpenContextMenu={handleContextMenuOpen}
onCloseContextMenu={handleContextMenuClose}
onDeleteBox={() => handleDelete(box.id)}
onRenameBox={(newName) => {
updateBoxData(box.id, (current) => ({
...current,
name: newName,
}))
}}
onBranchFrom={() => {
if (onBranchFrom) {
onBranchFrom(box)
}
}}
onEditBox={() => onEditBox?.(box)}
layoutActive={editingBoxId === box.id}
/>
))}
</div>
{boxes.length === 0 ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-sm text-white/60">
No boxes yet. Use the toolbar or prompt to add one.
</div>
) : null}
</div>
)
}
type CanvasBoxProps = {
box: CanvasBox
index: number
isSelected: boolean
defaultStatusMessage: string
isGenerating: boolean
onSelect: () => void
onDrag: (id: string, start: CanvasRect, dx: number, dy: number) => CanvasRect
onResize: (
id: string,
start: CanvasRect,
handle: ResizeHandle,
dx: number,
dy: number
) => CanvasRect
onInteractionStart: () => void
onInteractionEnd?: (type: "move" | "resize", rect: CanvasRect) => void
contextMenuOpen: boolean
onOpenContextMenu: (boxId: string) => void
onCloseContextMenu: () => void
onDeleteBox: () => void
onRenameBox: (name: string) => void
onBranchFrom: () => void
onEditBox?: () => void
layoutActive?: boolean
}
function CanvasBox({
box,
index,
isSelected,
defaultStatusMessage,
isGenerating,
onSelect,
onDrag,
onResize,
onInteractionStart,
onInteractionEnd,
contextMenuOpen,
onOpenContextMenu,
onCloseContextMenu,
onDeleteBox,
onRenameBox,
onBranchFrom,
onEditBox,
layoutActive = false,
}: CanvasBoxProps) {
const [isHovering, setIsHovering] = useState(false)
const pointerStateRef = useRef<{
type: "move" | "resize"
startPointer: { x: number; y: number }
startRect: CanvasRect
handle?: ResizeHandle
latestRect?: CanvasRect
} | null>(null)
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) {
return
}
event.stopPropagation()
onSelect()
onInteractionStart()
onCloseContextMenu()
pointerStateRef.current = {
type: "move",
startPointer: { x: event.clientX, y: event.clientY },
startRect: { ...box.rect },
latestRect: { ...box.rect },
}
const pointerId = event.pointerId
const target = event.currentTarget
if (target.setPointerCapture) {
try {
target.setPointerCapture(pointerId)
} catch {
// ignore capture errors
}
}
const onMove = (ev: PointerEvent) => {
const ctx = pointerStateRef.current
if (!ctx || ctx.type !== "move") {
return
}
const nextRect = onDrag(
box.id,
ctx.startRect,
ev.clientX - ctx.startPointer.x,
ev.clientY - ctx.startPointer.y
)
ctx.latestRect = nextRect
}
const finish = () => {
const ctx = pointerStateRef.current
pointerStateRef.current = null
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", finish)
window.removeEventListener("pointercancel", finish)
if (target.releasePointerCapture) {
try {
target.releasePointerCapture(pointerId)
} catch {
// ignore
}
}
if (ctx && ctx.type === "move") {
onInteractionEnd?.("move", ctx.latestRect ?? ctx.startRect)
}
}
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", finish, { once: true })
window.addEventListener("pointercancel", finish, { once: true })
},
[box.id, box.rect, onCloseContextMenu, onDrag, onInteractionEnd, onInteractionStart, onSelect]
)
const startResize = useCallback(
(handle: ResizeHandle, event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) {
return
}
event.stopPropagation()
onSelect()
onInteractionStart()
onCloseContextMenu()
pointerStateRef.current = {
type: "resize",
handle,
startPointer: { x: event.clientX, y: event.clientY },
startRect: { ...box.rect },
latestRect: { ...box.rect },
}
const pointerId = event.pointerId
const target = event.currentTarget
if (target.setPointerCapture) {
try {
target.setPointerCapture(pointerId)
} catch {
// ignore
}
}
const onMove = (ev: PointerEvent) => {
const ctx = pointerStateRef.current
if (!ctx || ctx.type !== "resize" || !ctx.handle) {
return
}
const nextRect = onResize(
box.id,
ctx.startRect,
ctx.handle,
ev.clientX - ctx.startPointer.x,
ev.clientY - ctx.startPointer.y
)
ctx.latestRect = nextRect
}
const finish = () => {
const ctx = pointerStateRef.current
pointerStateRef.current = null
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", finish)
window.removeEventListener("pointercancel", finish)
if (target.releasePointerCapture) {
try {
target.releasePointerCapture(pointerId)
} catch {
// ignore
}
}
if (ctx && ctx.type === "resize") {
onInteractionEnd?.("resize", ctx.latestRect ?? ctx.startRect)
}
}
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", finish, { once: true })
window.addEventListener("pointercancel", finish, { once: true })
},
[box.rect, box.id, onCloseContextMenu, onInteractionEnd, onInteractionStart, onResize, onSelect]
)
const showOutline = isSelected || isHovering
const statusText = box.description ?? box.prompt ?? defaultStatusMessage
return (
<motion.div
data-canvas-box="true"
className="absolute"
style={{
width: box.rect.width,
height: box.rect.height,
left: box.rect.x,
top: box.rect.y,
}}
onPointerDown={handlePointerDown}
onContextMenu={(event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault()
onOpenContextMenu(box.id)
}}
onDoubleClick={() => onEditBox?.()}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
transition={{ type: "spring", stiffness: 200, damping: 22 }}
>
<div
className={`relative w-full transition-all duration-300 h-full border canvas-box ${
showOutline
? "border-indigo-400 shadow-[0_0_0_1px_rgba(99,102,241,0.3)]"
: "border-slate-200 dark:border-neutral-800"
} bg-white text-slate-900 dark:bg-neutral-900/70 dark:text-white`}
>
<AnimatePresence>
{contextMenuOpen ? (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ type: "spring", stiffness: 260, damping: 20 }}
className="absolute -top-12 left-1/2 z-30 flex -translate-x-1/2 flex-col gap-1 rounded-2xl border border-slate-200 bg-white px-3 py-2 text-slate-900 shadow-lg dark:border-white/10 dark:bg-neutral-950/95 dark:text-white"
onPointerDown={(event: ReactPointerEvent<HTMLDivElement>) => {
event.stopPropagation()
}}
>
<div className="flex flex-col gap-1">
<button
type="button"
onClick={() => {
console.log("branching from", box)
onBranchFrom()
onCloseContextMenu()
}}
className="flex items-center gap-2 rounded-xl px-3 py-2 text-xs text-slate-900 transition hover:bg-slate-100 dark:text-white/80 dark:hover:bg-white/10"
>
<GitBranch className="h-3.5 w-3.5" />
Branch From
</button>
</div>
<div className="h-px bg-white/10" />
<div className="flex flex-col gap-1">
<button
type="button"
onClick={() => {
onEditBox?.()
onCloseContextMenu()
}}
className="flex items-center gap-2 rounded-xl px-3 py-2 text-xs text-slate-900 transition hover:bg-slate-100 dark:text-white/80 dark:hover:bg-white/10"
>
<Pencil className="h-3.5 w-3.5" />
Edit
</button>
<button
type="button"
onClick={() => {
const nextName = window.prompt("Rename box", box.name)
const trimmed = nextName?.trim()
if (!trimmed) {
return
}
onRenameBox(trimmed)
onCloseContextMenu()
}}
className="flex items-center gap-2 rounded-xl px-3 py-2 text-xs text-slate-900 transition hover:bg-slate-100 dark:text-white/80 dark:hover:bg-white/10"
>
<Type className="h-3.5 w-3.5" />
Rename
</button>
<button
type="button"
onClick={() => {
onDeleteBox()
onCloseContextMenu()
}}
className="flex items-center gap-2 rounded-xl px-3 py-2 text-xs text-red-500 transition hover:bg-red-50 dark:text-red-200 dark:hover:bg-red-500/20"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
</div>
</motion.div>
) : null}
</AnimatePresence>
<div
className={`absolute left-3 top-3 flex items-center gap-1 rounded px-2 py-1 text-[11px] font-medium uppercase tracking-wide transition-all duration-300 ${
isSelected
? "bg-indigo-500/90 text-white"
: "bg-black/50 text-white/70"
}`}
>
{box.branchParentId ? <GitBranch className="h-3 w-3" /> : null}
<span>{box.name || `Box ${index + 1}`}</span>
</div>
{box.imageUrl ? (
layoutActive ? (
<motion.img
layoutId={`box-image-${box.id}`}
src={box.imageUrl}
alt={box.name}
className="w-full h-full object-cover pointer-events-none"
/>
) : (
<img
src={box.imageUrl}
alt={box.name}
className="w-full h-full object-cover pointer-events-none"
/>
)
) : (
<div className="w-full h-full flex flex-col items-center justify-center gap-2 text-center px-4 text-white/70 text-sm">
{statusText}
{isGenerating ? (
<div className="h-8 w-8 rounded-full border-2 border-white/20 border-t-white animate-spin" />
) : null}
</div>
)}
{showOutline ? (
<div className="absolute bottom-2 right-3 text-[11px] text-white/80">
{Math.round(box.rect.width)}×{Math.round(box.rect.height)}
</div>
) : null}
{showOutline ? (
<>
<EdgeHandle
position="top"
onPointerDown={(event) => startResize("n", event)}
/>
<EdgeHandle
position="bottom"
onPointerDown={(event) => startResize("s", event)}
/>
<EdgeHandle
position="left"
onPointerDown={(event) => startResize("w", event)}
/>
<EdgeHandle
position="right"
onPointerDown={(event) => startResize("e", event)}
/>
<CornerHandle
position="top-left"
onPointerDown={(event) => startResize("nw", event)}
/>
<CornerHandle
position="top-right"
onPointerDown={(event) => startResize("ne", event)}
/>
<CornerHandle
position="bottom-left"
onPointerDown={(event) => startResize("sw", event)}
/>
<CornerHandle
position="bottom-right"
onPointerDown={(event) => startResize("se", event)}
/>
</>
) : null}
{isGenerating ? <div className="absolute inset-0 bg-black/30" /> : null}
</div>
</motion.div>
)
}
type ResizeHandle = "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se"
type EdgeHandleProps = {
position: "top" | "bottom" | "left" | "right"
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void
}
function EdgeHandle({ position, onPointerDown }: EdgeHandleProps) {
const isVertical = position === "top" || position === "bottom"
const cursor = isVertical ? "ns-resize" : "ew-resize"
const translateClass =
position === "top"
? "-translate-y-1/2"
: position === "bottom"
? "translate-y-1/2"
: position === "left"
? "-translate-x-1/2"
: "translate-x-1/2"
return (
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 20 }}
onPointerDown={onPointerDown}
className={`absolute ${translateClass} ${
isVertical ? "left-0 right-0 h-3" : "top-0 bottom-0 w-3"
} bg-transparent`}
style={{ cursor }}
/>
)
}
type CornerHandleProps = {
position: "top-left" | "top-right" | "bottom-left" | "bottom-right"
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void
}
function CornerHandle({ position, onPointerDown }: CornerHandleProps) {
const cursor =
position === "top-left" || position === "bottom-right"
? "nwse-resize"
: "nesw-resize"
const className =
position === "top-left"
? "-top-1 -left-1"
: position === "top-right"
? "-top-1 -right-1"
: position === "bottom-left"
? "-bottom-1 -left-1"
: "-bottom-1 -right-1"
return (
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 20 }}
onPointerDown={onPointerDown}
className={`absolute h-[10px] w-[10px] bg-indigo-400 ${className}`}
style={{ cursor }}
/>
)
}
function calculateResizedRect(
handle: ResizeHandle,
startRect: CanvasRect,
dx: number,
dy: number,
limits: {
minWidth: number
minHeight: number
maxWidth: number
maxHeight: number
}
): CanvasRect {
let x = startRect.x
let y = startRect.y
let width = startRect.width
let height = startRect.height
if (handle.includes("e")) {
width = clamp(width + dx, limits.minWidth, limits.maxWidth)
}
if (handle.includes("w")) {
const updatedWidth = clamp(width - dx, limits.minWidth, limits.maxWidth)
const delta = width - updatedWidth
width = updatedWidth
x += delta
}
if (handle.includes("s")) {
height = clamp(height + dy, limits.minHeight, limits.maxHeight)
}
if (handle.includes("n")) {
const updatedHeight = clamp(height - dy, limits.minHeight, limits.maxHeight)
const delta = height - updatedHeight
height = updatedHeight
y += delta
}
return normaliseRect({ x, y, width, height })
}

View File

@@ -0,0 +1,118 @@
import { useMemo } from "react"
import { CheckIcon } from "lucide-react"
import { useCanvasStore } from "../store/canvasStore"
export type ModelId = "gemini" | "dall-e-3" | "nano-banana"
export const MODEL_OPTIONS: Array<{
id: ModelId
label: string
description: string
disabled?: boolean
badge?: string
}> = [
{
id: "gemini",
label: "Gemini 2.5 Flash Image Preview",
description:
"Google's multimodal model for high-quality image + text generations.",
},
{
id: "dall-e-3",
label: "DALL·E 3",
description:
"OpenAI's flagship model for photorealistic, stylistic image generation.",
},
{
id: "nano-banana",
label: "Nano Banana",
description:
"Fast experimental model for playful concepts and draft visuals.",
disabled: true,
badge: "Coming soon",
},
]
export default function Models({
onClose,
onSelectModel,
}: {
onClose: () => void
onSelectModel?: (modelId: ModelId) => void
}) {
const { boxes, selectedBoxId, updateBoxData } = useCanvasStore()
const active = useMemo(
() => boxes.find((box) => box.id === selectedBoxId) ?? null,
[boxes, selectedBoxId]
)
if (!active) {
return (
<div className="text-sm text-white/60">
Select a box to choose its generation model.
</div>
)
}
return (
<div className="flex flex-col gap-3">
<div className="text-sm font-medium text-white">Choose a model</div>
<div className="space-y-2">
{MODEL_OPTIONS.map((option) => {
const isActive = option.id === active.model
const disabled = option.disabled
return (
<button
key={option.id}
type="button"
disabled={disabled}
onClick={() => {
if (disabled) {
return
}
if (option.id !== active.model) {
if (onSelectModel) {
onSelectModel(option.id)
} else {
updateBoxData(active.id, (box) => ({
...box,
model: option.id,
}))
}
}
onClose()
}}
className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${
disabled
? "cursor-not-allowed border-white/10 bg-white/5 text-white/40"
: isActive
? "border-indigo-500 bg-indigo-500/20 text-white"
: "border-white/10 bg-white/5 text-white/80 hover:border-white/20 hover:bg-white/10"
}`}
>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">{option.label}</div>
<div className="text-xs text-white/60">
{option.description}
</div>
</div>
<div className="flex items-center gap-2">
{option.badge ? (
<span className="rounded bg-white/10 px-2 py-0.5 text-[10px] uppercase tracking-wide text-white/70">
{option.badge}
</span>
) : null}
{isActive ? (
<CheckIcon className="h-4 w-4 text-white" />
) : null}
</div>
</div>
</button>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,257 @@
import { useCallback, useMemo } from "react"
import { motion } from "framer-motion"
import {
ChevronLeft,
ChevronRight,
X,
ArrowRight,
ArrowDown,
ArrowLeft,
ArrowUp,
} from "lucide-react"
import { useCanvasStore } from "../store/canvasStore"
type OnboardingStep =
| "welcome"
| "add-box"
| "select-box"
| "enter-prompt"
| "generate-image"
| "resize-box"
| "complete"
export default function Onboarding() {
const { onboardingStep, setOnboardingStep, completeOnboarding } =
useCanvasStore()
const steps: OnboardingStep[] = useMemo(
() => [
"welcome",
"add-box",
"select-box",
"enter-prompt",
"generate-image",
"resize-box",
"complete",
],
[]
)
const getStepConfig = (step: OnboardingStep) => {
switch (step) {
case "welcome":
return {
title: "Welcome to Image Generation!",
description:
"Let's walk through how to create amazing images with AI. This will only take a few minutes.",
position: { x: 50, y: 50 },
showArrow: false,
}
case "add-box":
return {
title: "Step 1: Add a Canvas Box",
description:
"First, let's add a canvas box where your image will be generated. Click the '+' button in the toolbar.",
position: { x: 50, y: 200 },
showArrow: true,
arrowDirection: "down" as const,
highlightElement: "[data-toolbar-add]",
}
case "select-box":
return {
title: "Step 2: Select the Box",
description:
"Click on the box you just created to select it. You'll see it highlighted with a blue border.",
position: { x: 300, y: 200 },
showArrow: true,
arrowDirection: "right" as const,
highlightElement: ".canvas-box",
}
case "enter-prompt":
return {
title: "Step 3: Enter Your Prompt",
description:
"Type your image description in the prompt box at the bottom. Be creative and descriptive!",
position: { x: 50, y: 400 },
showArrow: true,
arrowDirection: "down" as const,
highlightElement: "[data-prompt-input]",
}
case "generate-image":
return {
title: "Step 4: Generate Your Image",
description:
"Click the generate button to create your AI image. This may take a few moments.",
position: { x: 200, y: 400 },
showArrow: true,
arrowDirection: "right" as const,
highlightElement: "[data-generate-button]",
}
case "resize-box":
return {
title: "Step 5: Resize Your Box",
description:
"Drag the corner handles to resize your box. You can also move it around the canvas.",
position: { x: 400, y: 200 },
showArrow: true,
arrowDirection: "left" as const,
highlightElement: ".canvas-box",
}
case "complete":
return {
title: "You're All Set!",
description:
"You've learned the basics! You can now create, customize, and generate images. Have fun creating!",
position: { x: 50, y: 50 },
showArrow: false,
}
default:
return null
}
}
const handleNext = useCallback(() => {
if (!onboardingStep) return
const currentIndex = steps.indexOf(onboardingStep)
const nextIndex = currentIndex + 1
if (nextIndex >= steps.length) {
completeOnboarding()
} else {
setOnboardingStep(steps[nextIndex])
}
}, [onboardingStep, steps, setOnboardingStep, completeOnboarding])
const handlePrevious = useCallback(() => {
if (!onboardingStep) return
const currentIndex = steps.indexOf(onboardingStep)
const prevIndex = currentIndex - 1
if (prevIndex >= 0) {
setOnboardingStep(steps[prevIndex])
}
}, [onboardingStep, steps, setOnboardingStep])
const handleSkip = useCallback(() => {
completeOnboarding()
}, [completeOnboarding])
const stepConfig = onboardingStep ? getStepConfig(onboardingStep) : null
const canGoBack = onboardingStep !== "welcome"
const isLastStep = onboardingStep === "complete"
if (!onboardingStep || !stepConfig) {
return null
}
return (
<div className="absolute top-0 left-0 w-full h-full z-[110] pointer-events-auto">
{/* Backdrop overlay */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
{/* Highlight overlay for specific elements */}
{stepConfig.highlightElement && (
<div className="absolute inset-0 pointer-events-none">
<div className="absolute inset-0 bg-black/30" />
</div>
)}
{/* Onboarding content */}
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
layoutId="onboarding-content"
exit={{ opacity: 0, scale: 0.9, y: 20 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className="absolute bg-white/95 backdrop-blur-sm rounded-xl shadow-2xl border border-white/20 p-6 max-w-sm"
style={{
left: stepConfig.position.x,
top: stepConfig.position.y,
}}
>
{/* Arrow pointing to element */}
{stepConfig.showArrow && stepConfig.arrowDirection && (
<div className="absolute text-indigo-500">
{(stepConfig.arrowDirection as string) === "up" && (
<ArrowUp className="w-6 h-6 -top-8 left-1/2 -translate-x-1/2" />
)}
{stepConfig.arrowDirection === "down" && (
<ArrowDown className="w-6 h-6 -bottom-8 left-1/2 -translate-x-1/2" />
)}
{stepConfig.arrowDirection === "left" && (
<ArrowLeft className="w-6 h-6 -left-8 top-1/2 -translate-y-1/2" />
)}
{stepConfig.arrowDirection === "right" && (
<ArrowRight className="w-6 h-6 -right-8 top-1/2 -translate-y-1/2" />
)}
</div>
)}
{/* Content */}
<div className="space-y-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
{stepConfig.title}
</h3>
<p className="text-sm text-gray-600 leading-relaxed">
{stepConfig.description}
</p>
</div>
<button
onClick={handleSkip}
className="ml-4 p-1 text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Progress indicator */}
<div className="flex space-x-1">
{steps.map((step, _index) => (
<div
key={step}
className={`h-1 flex-1 rounded ${
onboardingStep === step ? "bg-indigo-500" : "bg-gray-200"
}`}
/>
))}
</div>
{/* Action buttons */}
<div className="flex items-center justify-between pt-2">
<div className="flex space-x-2">
{canGoBack && (
<button
onClick={handlePrevious}
className="flex items-center space-x-1 px-3 py-2 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors"
>
<ChevronLeft className="w-4 h-4" />
<span>Back</span>
</button>
)}
</div>
<button
onClick={handleNext}
className="flex items-center space-x-1 px-4 py-2 bg-indigo-500 text-white text-sm font-medium rounded-lg hover:bg-indigo-600 transition-colors"
>
<span>{isLastStep ? "Finish" : "Next"}</span>
{!isLastStep && <ChevronRight className="w-4 h-4" />}
</button>
</div>
</div>
</motion.div>
</div>
)
}

View File

@@ -0,0 +1,157 @@
import { ImageIcon, PlusIcon, Trash2Icon } from "lucide-react"
import Prompt, { type PromptProps } from "./Prompt"
import { motion, AnimatePresence } from "framer-motion"
import { useMemo, useState } from "react"
import { useCanvasStore } from "../store/canvasStore"
import type { ModelId } from "./Models"
type TokenBalance = {
tokens: number
premiumTokens: number
}
type OverlayProps = PromptProps & {
onAddBox?: () => void
onDeleteSelected?: () => void
onSetBackground?: () => void
onSelectStyle?: (styleId: string) => void
onSelectModel?: (modelId: ModelId) => void
contextLabel?: string | null
tokenBalance: TokenBalance
tokenCost: number
}
export default function Overlay({
onAddBox,
onDeleteSelected,
onSetBackground,
onSelectStyle,
onSelectModel,
contextLabel,
tokenBalance,
tokenCost,
...promptProps
}: OverlayProps) {
const [hoveredTool, setHoveredTool] = useState<string | null>(null)
const { boxes, selectedBoxId, onboardingStep } = useCanvasStore()
const activeBox = useMemo(
() => boxes.find((box) => box.id === selectedBoxId) ?? null,
[boxes, selectedBoxId]
)
const activeBoxName = contextLabel ?? activeBox?.name ?? null
const activeStyleId = activeBox?.styleId ?? "default"
const tools = [
{
icon: <PlusIcon size={18} strokeWidth={2.9} />,
label: "Add",
description: "Add a new content box",
onClick: () => onAddBox?.(),
},
{
icon: <Trash2Icon size={18} strokeWidth={2} />,
label: "Delete",
description: "Delete the selected box",
onClick: () => onDeleteSelected?.(),
},
{
icon: <ImageIcon size={18} strokeWidth={2} />,
label: "Background",
description: "Set the canvas background",
onClick: () => onSetBackground?.(),
},
]
return (
<div
className="absolute top-0 left-0 w-full h-full z-[110] pointer-events-none"
onClick={(e) => e.stopPropagation()}
>
<div className="pointer-events-auto">
<div className="flex items-center justify-end gap-2 px-6 py-3">
<TokenBadge label="Tokens" value={tokenBalance.tokens} />
<TokenBadge label="Premium" value={tokenBalance.premiumTokens} variant="premium" />
</div>
<Prompt
{...promptProps}
activeBoxName={activeBoxName}
activeModelId={activeBox?.model ?? "gemini"}
activeStyleId={activeStyleId}
onSelectStyle={onSelectStyle}
onSelectModel={onSelectModel}
tokenCost={tokenCost}
/>
<motion.div
initial={{ opacity: 0, x: -100, scale: 0 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: -100, scale: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 20 }}
className={`absolute top-[50%] left-0 translate-y-[-50%] p-[10px] ${
onboardingStep === "add-box" ? "z-[120]" : ""
}`}
>
<div className="flex flex-col gap-[6px] rounded-2xl border border-slate-200 bg-white p-[6px] shadow-sm dark:border-white/10 dark:bg-neutral-900">
{tools.map((tool) => (
<div
key={tool.label}
data-toolbar-add={tool.label === "Add" ? "true" : undefined}
className="flex relative cursor-pointer items-center justify-center rounded-xl p-[10px] hover:bg-slate-100 dark:hover:bg-neutral-800"
onClick={tool.onClick}
onMouseEnter={() => setHoveredTool(tool.label)}
onMouseLeave={() => setHoveredTool(null)}
>
{tool.icon}
{/* Tooltip with Framer Motion */}
<AnimatePresence>
{hoveredTool === tool.label && (
<motion.div
initial={{ opacity: 0, x: -10, scale: 0.8 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: -10, scale: 0.8 }}
transition={{
type: "spring",
stiffness: 300,
damping: 25,
duration: 0.2,
}}
className="absolute left-full top-1/2 z-50 ml-3 -translate-y-1/2 whitespace-nowrap rounded-lg bg-slate-900 px-3 py-2 text-[12px] text-white shadow-lg dark:bg-neutral-900"
>
{tool.description}
</motion.div>
)}
</AnimatePresence>
</div>
))}
</div>
</motion.div>
</div>
</div>
)
}
function TokenBadge({
label,
value,
variant = "default",
}: {
label: string
value: number
variant?: "default" | "premium"
}) {
const isPremium = variant === "premium"
return (
<div
className={`rounded-2xl border px-3 py-2 text-left ${
isPremium
? "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-200/40 dark:bg-amber-400/10 dark:text-amber-50"
: "border-slate-200 bg-white text-slate-900 dark:border-white/10 dark:bg-white/5 dark:text-white"
}`}
>
<p className="text-[10px] uppercase tracking-[0.2em] text-slate-500 dark:text-white/70">
{label}
</p>
<p className="text-base font-semibold">{value}</p>
</div>
)
}

View File

@@ -0,0 +1,251 @@
import { motion, useAnimate } from "framer-motion"
import { Fragment, useCallback, useEffect, useRef, useState } from "react"
import Models, { MODEL_OPTIONS, type ModelId } from "./Models"
import Styles from "./Styles"
import { STYLE_PRESETS } from "../styles-presets"
import { useCanvasStore } from "../store/canvasStore"
export type PromptProps = {
value: string
onValueChange: (value: string) => void
onSubmit: (value: string) => Promise<boolean> | boolean
isGenerating: boolean
error?: string | null
activeBoxName?: string | null
activeModelId?: "gemini" | "dall-e-3" | "nano-banana" | null
activeStyleId?: string | null
onSelectStyle?: (styleId: string) => void
onSelectModel?: (modelId: ModelId) => void
contextLabel?: string | null
tokenCost?: number
}
export default function Prompt({
value,
onValueChange,
onSubmit,
isGenerating,
error,
activeBoxName,
activeModelId = "gemini",
activeStyleId = "default",
onSelectStyle,
onSelectModel,
contextLabel,
tokenCost = 1,
}: PromptProps) {
const [currentCase, setCurrentCase] = useState<number>(0)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const { onboardingStep } = useCanvasStore()
const autoResize = useCallback(() => {
const textarea = textareaRef.current
if (!textarea) {
return
}
textarea.style.height = "auto"
textarea.style.height = `${Math.min(textarea.scrollHeight, 250)}px`
}, [])
useEffect(() => {
autoResize()
}, [autoResize, value])
const handleSubmit = useCallback(async () => {
if (isGenerating) {
return
}
const trimmed = value.trim()
if (!trimmed) {
return
}
await onSubmit(trimmed)
}, [isGenerating, onSubmit, onValueChange, value])
const activeModelLabel = MODEL_OPTIONS.find(
(option) => option.id === activeModelId
)?.label
const activeStyle = STYLE_PRESETS.find(
(preset) => preset.id === activeStyleId
)
return (
<div
className={`absolute bottom-0 left-[50%] translate-x-[-50%] flex justify-center items-center p-[10px] w-full max-w-[520px] ${
onboardingStep === "enter-prompt" || onboardingStep === "generate-image"
? "z-[120]"
: ""
}`}
>
<PromptWrapper currentCase={currentCase} setCurrentCase={setCurrentCase}>
{(() => {
switch (currentCase) {
case 0:
return (
<div className="flex w-full flex-col gap-2">
{activeBoxName || contextLabel ? (
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-white/60">
<span className="uppercase tracking-wide">
Prompting {contextLabel ?? activeBoxName}
</span>
<div className="flex items-center gap-2">
{activeStyle && activeStyle.id !== "default" ? (
<span className="rounded bg-slate-100 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-slate-600 dark:bg-white/10 dark:text-white/70">
{activeStyle.label}
</span>
) : null}
{activeModelLabel ? (
<span className="rounded bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-700 dark:bg-white/10 dark:text-white">
{activeModelLabel}
</span>
) : null}
</div>
</div>
) : null}
<textarea
ref={textareaRef}
data-prompt-input="true"
data-generate-button="true"
className="h-[100px] min-h-[60px] max-h-[250px] w-full resize-none overflow-y-auto bg-transparent text-slate-900 outline-none placeholder:text-slate-400 dark:text-white dark:placeholder:text-white/50"
placeholder="Describe the image you want to see"
value={value}
onChange={(event) => onValueChange(event.target.value)}
onInput={autoResize}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
void handleSubmit()
}
}}
aria-label="Image prompt"
aria-busy={isGenerating}
/>
<div className="flex justify-between text-xs text-slate-500 dark:text-white/40">
<span>
Press Enter to generate Shift+Enter for a new line
</span>
<div className="flex items-center gap-3">
<span className="font-semibold text-slate-600 dark:text-white/60">
Cost: {tokenCost} premium token
{tokenCost !== 1 ? "s" : ""}
</span>
{isGenerating ? (
<span className="text-primary-300">Generating</span>
) : null}
</div>
</div>
{error ? (
<div className="text-xs text-red-400">{error}</div>
) : null}
</div>
)
case 1:
return (
<Models
onClose={() => setCurrentCase(0)}
onSelectModel={onSelectModel}
/>
)
case 2:
return onSelectStyle ? (
<Styles
onClose={() => setCurrentCase(0)}
onSelectStyle={onSelectStyle}
activeStyleId={activeStyleId ?? "default"}
/>
) : (
<div className="text-sm text-slate-600 dark:text-white/60">
Style selection unavailable.
</div>
)
default:
return <div className="h-[50px]" />
}
})()}
</PromptWrapper>
</div>
)
}
function PromptWrapper({
children,
currentCase,
setCurrentCase,
}: {
children: React.ReactNode | (() => React.ReactNode)
currentCase: number
setCurrentCase: (index: number) => void
}) {
const constraintsRef = useRef<HTMLDivElement>(null)
const tools = ["Prompt", "Models", "Styles"]
const [scope, animate] = useAnimate()
useEffect(() => {
void animate(
`#case-${currentCase}`,
{ opacity: [0, 1] },
{ duration: 0.5, delay: 0.5 }
)
}, [animate, currentCase])
return (
<div ref={scope} className="w-full">
<div
ref={constraintsRef}
className=" flex justify-center w-full relative items-center"
>
{/* <div className="absolute z-10 left-[50%] translate-x-[-50%] top-[-30px] w-[90%] h-full ">
<div className="glass-card w-full h-full rounded-2xl">
<div className="w-full h-[24px] group flex items-center justify-center ">
<div className="w-[40px] h-[5px] bg-neutral-700/40 group-hover:bg-neutral-700/70 transition-all duration-300 rounded-full"></div>
</div>
</div>
</div> */}
<motion.div
layoutId="container"
initial={{
scale: 0.6,
y: 100,
}}
animate={{
scale: 1,
y: 0,
}}
transition={{ type: "spring", stiffness: 200, damping: 20 }}
className="relative z-20 w-full rounded-2xl bg-white p-0 text-slate-900 shadow-xl dark:bg-neutral-900 dark:text-white"
>
<div
id={`case-${currentCase}`}
className="relative flex flex-col justify-between p-4 "
>
{typeof children === "function"
? (children as () => React.ReactNode)()
: children}
</div>
<div className="flex absolute top-[-26px] left-0 z-[-10] scrollbar-hide text-slate-600 gap-0.5 text-xs dark:text-white/80">
{tools.map((tool, index) => (
<Fragment key={tool}>
<div
onClick={() => setCurrentCase(index)}
className={`cursor-pointer rounded-t-xl px-4 py-1 ${
index === currentCase
? "h-[50px] bg-white text-slate-900 shadow transition-all duration-300 dark:bg-neutral-900 dark:text-white"
: "h-[40px] bg-slate-100 text-slate-500 opacity-70 dark:bg-neutral-900/70 dark:text-white/70"
}`}
>
{tool}
</div>
</Fragment>
))}
</div>
</motion.div>
</div>
</div>
)
}

View File

@@ -0,0 +1,69 @@
import { CheckIcon } from "lucide-react"
import { useMemo } from "react"
import { useCanvasStore } from "../store/canvasStore"
import { STYLE_PRESETS } from "../styles-presets"
export default function Styles({
onClose,
onSelectStyle,
activeStyleId,
}: {
onClose: () => void
onSelectStyle: (styleId: string) => void
activeStyleId: string
}) {
const { boxes, selectedBoxId } = useCanvasStore()
const active = useMemo(
() => boxes.find((box) => box.id === selectedBoxId) ?? null,
[boxes, selectedBoxId]
)
if (!active) {
return (
<div className="text-sm text-white/60">
Select a box to choose a style.
</div>
)
}
return (
<div className="flex flex-col gap-3">
<div className="text-sm font-medium text-white">Choose a style</div>
<div className="space-y-2">
{STYLE_PRESETS.map((preset) => {
const currentId = activeStyleId ?? active.styleId ?? "default"
const isActive = currentId === preset.id
return (
<button
key={preset.id}
type="button"
onClick={() => {
onSelectStyle(preset.id)
onClose()
}}
className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${
isActive
? "border-indigo-500 bg-indigo-500/20 text-white"
: "border-white/10 bg-white/5 text-white/80 hover:border-white/20 hover:bg-white/10"
}`}
>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">{preset.label}</div>
<div className="text-xs text-white/60">
{preset.description}
</div>
</div>
{isActive ? <CheckIcon className="h-4 w-4 text-white" /> : null}
</div>
<div className="mt-1 text-[11px] text-white/50">
{preset.prompt}
</div>
</button>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { Undo2, Redo2 } from "lucide-react"
interface CanvasToolbarProps {
onUndo?: () => void
onRedo?: () => void
onReset?: () => void
canUndo?: boolean
canRedo?: boolean
}
export default function UndoRedoToolbar({
onUndo,
onRedo,
onReset,
canUndo = false,
canRedo = false,
}: CanvasToolbarProps) {
return (
<div className="flex items-center gap-2 rounded-lg">
<button
onClick={onUndo}
color=""
disabled={!canUndo}
className="text-white p-2 hover:bg-neutral-700 h-[40px] flex items-center justify-center min-w-[40px] bg-neutral-800/50 rounded-lg"
>
<Undo2 className="h-4 w-4" />
</button>
<button
onClick={onRedo}
disabled={!canRedo}
className="text-white p-2 hover:bg-neutral-700 h-[40px] flex items-center justify-center min-w-[40px] bg-neutral-800/50 rounded-lg"
>
<Redo2 className="h-4 w-4" />
</button>
<button
onClick={onReset}
className="text-white p-2 hover:bg-neutral-700 h-[40px] flex items-center justify-center min-w-[40px] bg-neutral-800/50 rounded-lg"
>
Reset
</button>
</div>
)
}

View File

@@ -0,0 +1,39 @@
// Canvas configuration constants
export const CANVAS_CONFIG = {
// Initial sizing
INITIAL_SIZE: 254,
MIN_WIDTH: 254,
MIN_HEIGHT: 254,
MAX_PIXEL_WIDTH: 1000,
MAX_PIXEL_HEIGHT: 1000,
// Visual styling
HANDLE_COLOR: "#6366F1", // indigo-500
OUTLINE_COLOR: "rgba(99,102,241,0.7)", // indigo-500 with opacity
// Handle sizing
HANDLE_SIZE: 8,
EDGE_HANDLE_THICKNESS: 2,
// Animation settings
ANIMATION_DURATION: 0.2,
SPRING_STIFFNESS: 200,
SPRING_DAMPING: 22,
// History settings
MAX_HISTORY_SIZE: 50,
} as const
export type CanvasRect = {
x: number
y: number
width: number
height: number
}
export type CanvasItem = {
id: string
rect: CanvasRect
description?: string
imageUrl?: string
}

View File

@@ -0,0 +1,367 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type PropsWithChildren,
} from "react"
import type { CanvasRect } from "../config"
type CanvasBox = {
id: string
name: string
prompt: string
rect: CanvasRect
imageUrl?: string
description?: string
model: "gemini" | "dall-e-3" | "nano-banana"
styleId?: string
branchParentId?: string | null
}
type OnboardingStep =
| "welcome"
| "add-box"
| "select-box"
| "enter-prompt"
| "generate-image"
| "resize-box"
| "complete"
type CanvasStoreValue = {
boxes: CanvasBox[]
selectedBoxId: string | null
setBoxes: (next: CanvasBox[] | ((prev: CanvasBox[]) => CanvasBox[])) => void
addBox: (
box?: Partial<Omit<CanvasBox, "rect">> & {
rect?: Partial<CanvasRect>
},
options?: {
select?: boolean
}
) => CanvasBox | null
updateBoxRect: (id: string, updater: (rect: CanvasRect) => CanvasRect) => void
updateBoxData: (id: string, updater: (box: CanvasBox) => CanvasBox) => void
deleteBox: (id: string) => void
setSelectedBoxId: (id: string | null) => void
reset: (
boxes: Array<
Partial<Omit<CanvasBox, "rect">> & {
name?: string
rect?: Partial<CanvasRect>
imageUrl?: string
description?: string
model?: "gemini" | "dall-e-3" | "nano-banana"
styleId?: string
}
>
) => void
// Onboarding state
onboardingStep: OnboardingStep | null
setOnboardingStep: (step: OnboardingStep | null) => void
startOnboarding: () => void
completeOnboarding: () => void
}
const CanvasStoreContext = createContext<CanvasStoreValue | undefined>(
undefined
)
const defaultRect: CanvasRect = {
x: 0,
y: 0,
width: 256,
height: 256,
}
const BOX_GAP = 24
const BRANCH_VERTICAL_GAP = 32
let idCounter = 0
const newId = () => `canvas-box-${++idCounter}`
const normaliseRect = (rect: CanvasRect): CanvasRect => ({
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height),
})
type CreateBoxInput = {
id?: string
name: string
prompt?: string
rect?: CanvasRect
imageUrl?: string
description?: string
model?: "gemini" | "dall-e-3" | "nano-banana"
styleId?: string
branchParentId?: string | null
}
const createBox = ({
id,
name,
prompt = "",
rect = defaultRect,
imageUrl,
description,
model = "gemini",
styleId = "default",
branchParentId,
}: CreateBoxInput): CanvasBox => ({
id: id ?? newId(),
name,
prompt,
rect: normaliseRect(rect),
imageUrl,
description,
model,
styleId,
branchParentId: branchParentId ?? null,
})
export function CanvasProvider({ children }: PropsWithChildren) {
const [boxes, setBoxesState] = useState<CanvasBox[]>([])
const [selectedBoxId, setSelectedBoxId] = useState<string | null>(null)
const [onboardingStep, setOnboardingStep] = useState<OnboardingStep | null>(
null
)
const setBoxes = useCallback(
(next: CanvasBox[] | ((prev: CanvasBox[]) => CanvasBox[])) => {
setBoxesState((prev) => {
const resolved =
typeof next === "function"
? (next as (p: CanvasBox[]) => CanvasBox[])(prev)
: next
return resolved.map((box) => ({
...box,
rect: normaliseRect(box.rect),
model: box.model ?? "gemini",
styleId: box.styleId ?? "default",
branchParentId: box.branchParentId ?? null,
}))
})
},
[]
)
const addBox = useCallback(
(
overrides?: Partial<Omit<CanvasBox, "id" | "name" | "rect">> & {
rect?: Partial<CanvasRect>
},
options?: {
select?: boolean
}
) => {
let created: CanvasBox | null = null
setBoxesState((prev) => {
const name = overrides?.name ?? `Box ${prev.length + 1}`
const last = prev[prev.length - 1] ?? null
const branchParent = overrides?.branchParentId
? prev.find((box) => box.id === overrides.branchParentId)
: null
const branchSiblingCount = branchParent
? prev.filter((box) => box.branchParentId === branchParent.id).length
: 0
const rect: CanvasRect = {
x:
overrides?.rect?.x ??
(branchParent
? branchParent.rect.x
: last
? last.rect.x + last.rect.width + BOX_GAP
: 0),
y:
overrides?.rect?.y ??
(branchParent
? branchParent.rect.y +
branchParent.rect.height +
BRANCH_VERTICAL_GAP +
branchSiblingCount *
(branchParent.rect.height + BRANCH_VERTICAL_GAP)
: last
? last.rect.y
: 0),
width:
overrides?.rect?.width ??
(branchParent ? branchParent.rect.width : defaultRect.width),
height:
overrides?.rect?.height ??
(branchParent ? branchParent.rect.height : defaultRect.height),
}
created = createBox({
id: overrides?.id,
name,
prompt: overrides?.prompt ?? "",
rect,
imageUrl: overrides?.imageUrl,
description: overrides?.description,
model: overrides?.model ?? "gemini",
styleId: overrides?.styleId ?? "default",
branchParentId: overrides?.branchParentId ?? null,
})
return [...prev, created]
})
const shouldSelect = options?.select ?? true
if (created && shouldSelect) {
setSelectedBoxId(created.id)
}
return created
},
[]
)
const updateBoxRect = useCallback(
(id: string, updater: (rect: CanvasRect) => CanvasRect) => {
setBoxesState((prev) =>
prev.map((box) =>
box.id === id
? {
...box,
rect: normaliseRect(updater(box.rect)),
}
: box
)
)
},
[]
)
const updateBoxData = useCallback(
(id: string, updater: (box: CanvasBox) => CanvasBox) => {
setBoxesState((prev) =>
prev.map((box) => {
if (box.id !== id) {
return box
}
const updated = updater(box)
return {
...updated,
model: updated.model ?? "gemini",
rect: normaliseRect(updated.rect),
styleId: updated.styleId ?? box.styleId ?? "default",
branchParentId:
updated.branchParentId ?? box.branchParentId ?? null,
}
})
)
},
[]
)
const deleteBox = useCallback((id: string) => {
setBoxesState((prev) => {
if (prev.length <= 1) {
return prev
}
const next = prev.filter((box) => box.id !== id)
if (next.length > 0) {
setSelectedBoxId(next[next.length - 1]?.id ?? null)
} else {
setSelectedBoxId(null)
}
return next
})
}, [])
const reset = useCallback(
(
initial: Array<
Partial<Omit<CanvasBox, "id" | "name" | "rect">> & {
name?: string
rect?: Partial<CanvasRect>
imageUrl?: string
}
>
) => {
let counter = 0
let cursorX = 0
const next = initial.map((item) => {
counter += 1
const rect: CanvasRect = {
x: item.rect?.x ?? cursorX,
y: item.rect?.y ?? 0,
width: item.rect?.width ?? defaultRect.width,
height: item.rect?.height ?? defaultRect.height,
}
const box = createBox({
id: item.id,
name: item.name ?? `Box ${counter}`,
prompt: item.prompt ?? "",
rect,
imageUrl: item.imageUrl,
description: item.description,
model: item.model ?? "gemini",
styleId: item.styleId ?? "default",
branchParentId: item.branchParentId ?? null,
})
cursorX = box.rect.x + box.rect.width + BOX_GAP
return box
})
setBoxes(next)
setSelectedBoxId(next[0]?.id ?? null)
},
[setBoxes]
)
const startOnboarding = useCallback(() => {
setOnboardingStep("welcome")
}, [])
const completeOnboarding = useCallback(() => {
setOnboardingStep(null)
}, [])
const value = useMemo<CanvasStoreValue>(
() => ({
boxes,
selectedBoxId,
setBoxes,
addBox,
updateBoxRect,
updateBoxData,
deleteBox,
setSelectedBoxId,
reset,
onboardingStep,
setOnboardingStep,
startOnboarding,
completeOnboarding,
}),
[
boxes,
selectedBoxId,
setBoxes,
addBox,
updateBoxRect,
updateBoxData,
deleteBox,
reset,
onboardingStep,
setOnboardingStep,
startOnboarding,
completeOnboarding,
]
)
return (
<CanvasStoreContext.Provider value={value}>
{children}
</CanvasStoreContext.Provider>
)
}
export function useCanvasStore(): CanvasStoreValue {
const ctx = useContext(CanvasStoreContext)
if (!ctx) {
throw new Error("useCanvasStore must be used within a CanvasProvider")
}
return ctx
}
export type { CanvasBox }

View File

@@ -0,0 +1,39 @@
export type StylePreset = {
id: string
label: string
description: string
prompt: string
}
export const STYLE_PRESETS: StylePreset[] = [
{
id: "default",
label: "Natural",
description: "Balanced, unstyled rendering",
prompt: "Render the scene with natural lighting and realistic tones.",
},
{
id: "cinematic",
label: "Cinematic",
description: "High-contrast, filmic look",
prompt: "Cinematic lighting, dramatic contrast, 35mm film aesthetic, rich color grading.",
},
{
id: "watercolor",
label: "Watercolor",
description: "Soft painterly textures",
prompt: "Watercolor illustration, soft brush strokes, flowing pigment, textured paper background.",
},
{
id: "anime",
label: "Anime",
description: "Vibrant anime style",
prompt: "Anime illustration, clean line art, vibrant cel shading, dynamic background, Studio Ghibli inspired.",
},
{
id: "noir",
label: "Noir",
description: "Moody black-and-white",
prompt: "Film noir photography, high contrast black and white, dramatic shadows, moody atmosphere.",
},
]

View File

@@ -0,0 +1,73 @@
import { useState, useEffect, useCallback } from "react"
const GUEST_USAGE_KEY = "gen_guest_usage"
const GUEST_FREE_LIMIT = 5
type GuestUsage = {
count: number
lastReset: string // ISO date string
}
function getStoredUsage(): GuestUsage {
if (typeof window === "undefined") {
return { count: 0, lastReset: new Date().toISOString() }
}
try {
const stored = localStorage.getItem(GUEST_USAGE_KEY)
if (stored) {
return JSON.parse(stored) as GuestUsage
}
} catch {
// Invalid data, reset
}
return { count: 0, lastReset: new Date().toISOString() }
}
function setStoredUsage(usage: GuestUsage): void {
if (typeof window === "undefined") return
try {
localStorage.setItem(GUEST_USAGE_KEY, JSON.stringify(usage))
} catch {
// localStorage might be full or disabled
}
}
export function useGuestUsage() {
const [usage, setUsage] = useState<GuestUsage>(getStoredUsage)
useEffect(() => {
setUsage(getStoredUsage())
}, [])
const remaining = Math.max(0, GUEST_FREE_LIMIT - usage.count)
const canUse = remaining > 0
const incrementUsage = useCallback(() => {
setUsage((prev) => {
const newUsage = {
count: prev.count + 1,
lastReset: prev.lastReset,
}
setStoredUsage(newUsage)
return newUsage
})
}, [])
const resetUsage = useCallback(() => {
const newUsage = { count: 0, lastReset: new Date().toISOString() }
setStoredUsage(newUsage)
setUsage(newUsage)
}, [])
return {
used: usage.count,
remaining,
limit: GUEST_FREE_LIMIT,
canUse,
incrementUsage,
resetUsage,
}
}

View File

@@ -0,0 +1,104 @@
const GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta"
export const DEFAULT_GEMINI_IMAGE_MODEL = "gemini-2.5-flash-image-preview"
type GeminiEnv = {
GEMINI_API_KEY?: string
GOOGLE_API_KEY?: string
}
const getEnv = (): GeminiEnv => {
try {
const { getServerContext } = require("@tanstack/react-start/server") as {
getServerContext: () => { cloudflare?: { env?: GeminiEnv } } | null
}
const ctx = getServerContext()
if (ctx?.cloudflare?.env) {
const env = ctx.cloudflare.env
if (env.GEMINI_API_KEY || env.GOOGLE_API_KEY) {
return {
GEMINI_API_KEY: env.GEMINI_API_KEY ?? env.GOOGLE_API_KEY,
GOOGLE_API_KEY: env.GOOGLE_API_KEY,
}
}
}
} catch {
// ignore, running outside Cloudflare
}
const key = process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY
return { GEMINI_API_KEY: key, GOOGLE_API_KEY: process.env.GOOGLE_API_KEY }
}
export type GeminiImageRequest = {
prompt: string
model?: string
temperature?: number
}
export type GeminiImageResponse = {
base64Image: string
mimeType: string
rawResponse: unknown
}
export async function generateGeminiImage(
params: GeminiImageRequest,
): Promise<GeminiImageResponse> {
const { GEMINI_API_KEY } = getEnv()
if (!GEMINI_API_KEY) {
throw new Error(
"Set GEMINI_API_KEY or GOOGLE_API_KEY to enable Gemini image generation.",
)
}
const model = params.model ?? DEFAULT_GEMINI_IMAGE_MODEL
const body = {
contents: [
{
role: "user",
parts: [{ text: params.prompt }],
},
],
generationConfig: {
temperature: params.temperature ?? 0.9,
},
}
const response = await fetch(`${GEMINI_API_BASE}/models/${model}:generateContent`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-goog-api-key": GEMINI_API_KEY,
},
body: JSON.stringify(body),
})
const json = await response.json().catch(() => ({}))
if (!response.ok) {
const message =
typeof json?.error?.message === "string"
? json.error.message
: "Gemini image generation failed"
throw new Error(message)
}
const candidates = Array.isArray(json?.candidates) ? json.candidates : []
for (const candidate of candidates) {
const parts = candidate?.content?.parts ?? []
for (const part of parts) {
if (part?.inlineData?.data) {
return {
base64Image: part.inlineData.data,
mimeType: part.inlineData.mimeType ?? "image/png",
rawResponse: json,
}
}
}
}
throw new Error("Gemini did not return inline image data.")
}

View File

@@ -0,0 +1,77 @@
const OPENAI_API_URL = "https://api.openai.com/v1/images/generations"
const DEFAULT_OPENAI_MODEL = "gpt-image-1"
type OpenAIEnv = {
OPENAI_API_KEY?: string
}
const getEnv = (): OpenAIEnv => {
try {
const { getServerContext } = require("@tanstack/react-start/server") as {
getServerContext: () => { cloudflare?: { env?: OpenAIEnv } } | null
}
const ctx = getServerContext()
if (ctx?.cloudflare?.env?.OPENAI_API_KEY) {
return { OPENAI_API_KEY: ctx.cloudflare.env.OPENAI_API_KEY }
}
} catch {
// ignore — not running in server context
}
return { OPENAI_API_KEY: process.env.OPENAI_API_KEY }
}
export type OpenAIImageRequest = {
prompt: string
model?: string
size?: "1024x1024" | "1024x1792" | "1792x1024"
}
export type OpenAIImageResponse = {
base64Image: string
mimeType: string
revisedPrompt?: string
}
export async function generateOpenAIImage(
params: OpenAIImageRequest,
): Promise<OpenAIImageResponse> {
const { OPENAI_API_KEY } = getEnv()
if (!OPENAI_API_KEY) {
throw new Error("Set OPENAI_API_KEY to enable DALL·E image generation.")
}
const response = await fetch(OPENAI_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: params.model ?? DEFAULT_OPENAI_MODEL,
prompt: params.prompt,
size: params.size ?? "1024x1024",
response_format: "b64_json",
}),
})
const json: any = await response.json().catch(() => ({}))
if (!response.ok) {
const message =
typeof json?.error?.message === "string"
? json.error.message
: "OpenAI image generation failed"
throw new Error(message)
}
const payload = Array.isArray(json?.data) ? json.data[0] : undefined
const base64 = typeof payload?.b64_json === "string" ? payload.b64_json : null
if (!base64) {
throw new Error("OpenAI returned no image data")
}
return {
base64Image: base64,
mimeType: "image/png",
revisedPrompt: typeof payload?.revised_prompt === "string" ? payload.revised_prompt : undefined,
}
}

View File

@@ -0,0 +1,39 @@
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
// Get API key from Cloudflare env or process.env
const getApiKey = (): string | undefined => {
// Try Cloudflare Workers context first
try {
const { getServerContext } = require("@tanstack/react-start/server")
const ctx = getServerContext()
if (ctx?.cloudflare?.env?.OPENROUTER_API_KEY) {
return ctx.cloudflare.env.OPENROUTER_API_KEY as string
}
} catch {
// Not in Cloudflare context
}
return process.env.OPENROUTER_API_KEY
}
const getModel = (): string => {
try {
const { getServerContext } = require("@tanstack/react-start/server")
const ctx = getServerContext()
if (ctx?.cloudflare?.env?.OPENROUTER_MODEL) {
return ctx.cloudflare.env.OPENROUTER_MODEL as string
}
} catch {
// Not in Cloudflare context
}
return process.env.OPENROUTER_MODEL ?? "google/gemini-2.0-flash-001"
}
export const getOpenRouter = () => {
const apiKey = getApiKey()
if (!apiKey) {
return null
}
return createOpenRouter({ apiKey })
}
export const getDefaultModel = () => getModel()

View File

@@ -0,0 +1,7 @@
import { createAuthClient } from "better-auth/react"
import { emailOTPClient } from "better-auth/client/plugins"
export const authClient = createAuthClient({
baseURL: typeof window !== "undefined" ? window.location.origin : undefined,
plugins: [emailOTPClient()],
})

View File

@@ -0,0 +1,145 @@
import { betterAuth } from "better-auth"
import { drizzleAdapter } from "better-auth/adapters/drizzle"
import { tanstackStartCookies } from "better-auth/tanstack-start"
import { emailOTP } from "better-auth/plugins"
import { Resend } from "resend"
import { authDb } from "@/db/connection"
import * as schema from "@/db/schema"
type AuthEnv = {
BETTER_AUTH_SECRET: string
APP_BASE_URL?: string
RESEND_API_KEY?: string
RESEND_FROM_EMAIL?: string
}
// Helper to get Cloudflare env from server context
const getCloudflareEnv = (): Partial<AuthEnv> | undefined => {
try {
const { getServerContext } = require("@tanstack/react-start/server") as {
getServerContext: () => { cloudflare?: { env?: Partial<AuthEnv> } } | null
}
return getServerContext()?.cloudflare?.env
} catch {
return undefined
}
}
// Get env from Cloudflare context or process.env
const getEnv = (): AuthEnv => {
let BETTER_AUTH_SECRET: string | undefined
let APP_BASE_URL: string | undefined
let RESEND_API_KEY: string | undefined
let RESEND_FROM_EMAIL: string | undefined
// Try Cloudflare Workers context first (production)
const cfEnv = getCloudflareEnv()
if (cfEnv) {
BETTER_AUTH_SECRET = cfEnv.BETTER_AUTH_SECRET
APP_BASE_URL = cfEnv.APP_BASE_URL
RESEND_API_KEY = cfEnv.RESEND_API_KEY
RESEND_FROM_EMAIL = cfEnv.RESEND_FROM_EMAIL
}
// Fall back to process.env (local dev)
BETTER_AUTH_SECRET = BETTER_AUTH_SECRET ?? process.env.BETTER_AUTH_SECRET
APP_BASE_URL = APP_BASE_URL ?? process.env.APP_BASE_URL
RESEND_API_KEY = RESEND_API_KEY ?? process.env.RESEND_API_KEY
RESEND_FROM_EMAIL = RESEND_FROM_EMAIL ?? process.env.RESEND_FROM_EMAIL
if (!BETTER_AUTH_SECRET) {
throw new Error("BETTER_AUTH_SECRET is not configured")
}
return {
BETTER_AUTH_SECRET,
APP_BASE_URL,
RESEND_API_KEY,
RESEND_FROM_EMAIL,
}
}
export const getAuth = () => {
// Note: We create a fresh auth instance per request because Cloudflare Workers
// doesn't allow sharing I/O objects (like DB connections) across requests
const env = getEnv()
const database = authDb()
// Detect production: if APP_BASE_URL is set and not localhost, we're in production
const isProduction =
env.APP_BASE_URL && !env.APP_BASE_URL.includes("localhost")
const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null
const fromEmail = env.RESEND_FROM_EMAIL ?? "noreply@example.com"
console.log("[auth] Config:", {
isProduction,
hasResendKey: !!env.RESEND_API_KEY,
fromEmail,
appBaseUrl: env.APP_BASE_URL,
})
return betterAuth({
database: drizzleAdapter(database, {
provider: "pg",
usePlural: true,
schema,
}),
trustedOrigins: [env.APP_BASE_URL ?? "http://localhost:3000"],
plugins: [
tanstackStartCookies(),
emailOTP({
async sendVerificationOTP({ email, otp }) {
console.log("[auth] sendVerificationOTP called:", {
email,
isProduction,
hasResend: !!resend,
})
if (!isProduction || !resend) {
// In dev mode or if Resend not configured, log OTP to terminal
console.log("\n" + "=".repeat(50))
console.log(`🔐 OTP CODE for ${email}`)
console.log(` Code: ${otp}`)
console.log("=".repeat(50) + "\n")
return
}
// Send email via Resend in production
console.log("[auth] Sending email via Resend to:", email)
const { error, data } = await resend.emails.send({
from: fromEmail,
to: email,
subject: "Your Linsa verification code",
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 400px; margin: 0 auto; padding: 20px; background-color: #050505; color: #ffffff;">
<h2 style="color: #ffffff; margin-bottom: 16px; font-weight: 600;">Your verification code</h2>
<p style="color: #a1a1aa; margin-bottom: 24px;">Enter this code to sign in to Linsa:</p>
<div style="background-color: #18181b; border: 1px solid #27272a; border-radius: 12px; padding: 24px; text-align: center;">
<span style="font-size: 36px; font-weight: bold; letter-spacing: 8px; color: #ffffff; font-family: monospace;">${otp}</span>
</div>
<p style="color: #71717a; font-size: 14px; margin-top: 24px;">This code expires in 5 minutes.</p>
<p style="color: #52525b; font-size: 12px; margin-top: 16px;">If you didn't request this code, you can safely ignore this email.</p>
</div>
`,
})
if (error) {
console.error("[auth] Failed to send OTP email:", error)
throw new Error("Failed to send verification email")
}
console.log("[auth] Email sent successfully:", data)
},
otpLength: 6,
expiresIn: 300, // 5 minutes
}),
],
})
}
// Lazy proxy that calls getAuth() on each access
export const auth = new Proxy({} as ReturnType<typeof betterAuth>, {
get(_target, prop) {
return getAuth()[prop as keyof ReturnType<typeof betterAuth>]
},
})

View File

@@ -0,0 +1,109 @@
import type { BillingWithChecks, Price, UsageMeter, Product } from "@flowglad/server"
/**
* Computes the total usage credits for a given usage meter slug from the current subscription's feature items.
*/
export function computeUsageTotal(
usageMeterSlug: string,
currentSubscription:
| NonNullable<NonNullable<BillingWithChecks["currentSubscriptions"]>[number]>
| undefined,
pricingModel: BillingWithChecks["pricingModel"] | undefined,
): number {
try {
if (!currentSubscription || !pricingModel?.usageMeters) return 0
const experimental = currentSubscription.experimental as
| { featureItems?: Array<{ type: string; usageMeterId: string; amount: number }> }
| undefined
const featureItems = experimental?.featureItems ?? []
if (featureItems.length === 0) return 0
// Build lookup map: usageMeterId -> slug
const usageMeterById: Record<string, string> = {}
for (const meter of pricingModel.usageMeters) {
usageMeterById[String(meter.id)] = String(meter.slug)
}
// Sum up usage credits for matching meter
let total = 0
for (const item of featureItems) {
if (item.type !== "usage_credit_grant") continue
const meterSlug = usageMeterById[item.usageMeterId]
if (meterSlug === usageMeterSlug) {
total += item.amount
}
}
return total
} catch {
return 0
}
}
/**
* Finds a usage meter by its slug from the pricing model.
*/
export function findUsageMeterBySlug(
usageMeterSlug: string,
pricingModel: BillingWithChecks["pricingModel"] | undefined,
): { id: string; slug: string } | null {
if (!pricingModel?.usageMeters) return null
const usageMeter = pricingModel.usageMeters.find(
(meter: UsageMeter) => meter.slug === usageMeterSlug,
)
if (!usageMeter) return null
return {
id: String(usageMeter.id),
slug: String(usageMeter.slug),
}
}
/**
* Finds a usage price by its associated usage meter slug from the pricing model.
*/
export function findUsagePriceByMeterSlug(
usageMeterSlug: string,
pricingModel: BillingWithChecks["pricingModel"] | undefined,
): Price | null {
if (!pricingModel?.products || !pricingModel?.usageMeters) return null
// Build lookup map: slug -> id
const meterIdBySlug = new Map(
pricingModel.usageMeters.map((meter: UsageMeter) => [meter.slug, meter.id]),
)
const usageMeterId = meterIdBySlug.get(usageMeterSlug)
if (!usageMeterId) return null
// Find price by meter ID
const usagePrice = pricingModel.products
.flatMap((product: Product) => product.prices ?? [])
.find(
(price: Price) => price.type === "usage" && price.usageMeterId === usageMeterId,
)
return usagePrice ?? null
}
/**
* Checks if a plan is a default (free) plan by looking up the price by slug.
*/
export function isDefaultPlanBySlug(
pricingModel: BillingWithChecks["pricingModel"] | null | undefined,
priceSlug: string | undefined,
): boolean {
if (!pricingModel?.products || !priceSlug) return false
for (const product of pricingModel.products) {
const price = product.prices?.find((p: Price) => p.slug === priceSlug)
if (price) {
return product.default === true
}
}
return false
}

View File

@@ -0,0 +1,236 @@
import { getFlowgladServer } from "./flowglad"
import { getAuth } from "./auth"
// Usage limits
const GUEST_FREE_REQUESTS = 5
const AUTH_FREE_REQUESTS_DAILY = 20
const PAID_PLAN_REQUESTS = 1000
// Usage meter slug (configure in Flowglad dashboard)
export const AI_REQUESTS_METER = "ai_requests"
// Price slug for the pro plan (configure in Flowglad dashboard)
export const PRO_PLAN_PRICE_SLUG = "pro_monthly"
type UsageCheckResult = {
allowed: boolean
remaining: number
limit: number
reason?: "guest_limit" | "daily_limit" | "subscription_limit" | "no_subscription"
isGuest: boolean
isPaid: boolean
}
/**
* Check if user can make an AI request based on their billing status.
*
* Tiers:
* - Guest (no auth): 5 free requests total (stored in cookie/localStorage)
* - Authenticated free: 20 free requests per day
* - Pro plan ($7.99/mo): 1000 requests per billing period
*/
export async function checkUsageAllowed(request: Request): Promise<UsageCheckResult> {
const auth = getAuth()
const session = await auth.api.getSession({ headers: request.headers })
// Guest user - check local/cookie based limit
if (!session?.user) {
// For guests, we'll track on client side via localStorage
// Server just knows they're a guest with limited access
return {
allowed: true, // Client will enforce limit
remaining: GUEST_FREE_REQUESTS,
limit: GUEST_FREE_REQUESTS,
isGuest: true,
isPaid: false,
}
}
// Authenticated user - check Flowglad billing
const flowglad = getFlowgladServer(request)
if (!flowglad) {
// Flowglad not configured, fall back to daily free limit
return {
allowed: true,
remaining: AUTH_FREE_REQUESTS_DAILY,
limit: AUTH_FREE_REQUESTS_DAILY,
isGuest: false,
isPaid: false,
}
}
try {
const billing = await flowglad.getBilling()
// Check if user has an active subscription
const hasActiveSubscription = billing.currentSubscriptions &&
billing.currentSubscriptions.length > 0
if (hasActiveSubscription) {
// Check usage balance for paid plan
const usage = billing.checkUsageBalance(AI_REQUESTS_METER)
if (usage) {
const remaining = usage.availableBalance
return {
allowed: remaining > 0,
remaining,
limit: PAID_PLAN_REQUESTS,
reason: remaining <= 0 ? "subscription_limit" : undefined,
isGuest: false,
isPaid: true,
}
}
// Has subscription but no usage meter configured yet
return {
allowed: true,
remaining: PAID_PLAN_REQUESTS,
limit: PAID_PLAN_REQUESTS,
isGuest: false,
isPaid: true,
}
}
// No subscription - use daily free limit
// For now we allow without tracking (TODO: implement daily limit tracking)
return {
allowed: true,
remaining: AUTH_FREE_REQUESTS_DAILY,
limit: AUTH_FREE_REQUESTS_DAILY,
isGuest: false,
isPaid: false,
}
} catch (error) {
console.error("[billing] Error checking usage:", error)
// On error, allow with daily limit
return {
allowed: true,
remaining: AUTH_FREE_REQUESTS_DAILY,
limit: AUTH_FREE_REQUESTS_DAILY,
isGuest: false,
isPaid: false,
}
}
}
/**
* Record a usage event after AI request completes.
* Only records for paid users with active subscriptions.
*/
export async function recordUsage(
request: Request,
amount: number = 1,
transactionId?: string
): Promise<void> {
const auth = getAuth()
const session = await auth.api.getSession({ headers: request.headers })
if (!session?.user) {
// Guest users don't record to Flowglad
return
}
const flowglad = getFlowgladServer(request)
if (!flowglad) {
return
}
try {
const billing = await flowglad.getBilling()
const hasActiveSubscription = billing.currentSubscriptions &&
billing.currentSubscriptions.length > 0
if (!hasActiveSubscription) {
// Only record usage for paid subscriptions
return
}
const subscription = billing.currentSubscriptions![0]
// Find the usage price for the AI requests meter
const usagePrice = billing.pricingModel?.products
?.flatMap(p => p.prices || [])
?.find((p: { type?: string; usageMeterSlug?: string }) =>
p.type === "usage" && p.usageMeterSlug === AI_REQUESTS_METER
) as { id: string } | undefined
if (!usagePrice) {
console.warn("[billing] No usage price found for meter:", AI_REQUESTS_METER)
return
}
await flowglad.createUsageEvent({
subscriptionId: subscription.id,
priceId: usagePrice.id,
amount,
transactionId: transactionId ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`,
})
} catch (error) {
console.error("[billing] Error recording usage:", error)
// Don't throw - usage recording should not block the request
}
}
/**
* Get billing summary for display in UI.
*/
export async function getBillingSummary(request: Request) {
const auth = getAuth()
const session = await auth.api.getSession({ headers: request.headers })
if (!session?.user) {
return {
isGuest: true,
isPaid: false,
freeLimit: GUEST_FREE_REQUESTS,
planName: "Guest",
}
}
const flowglad = getFlowgladServer(request)
if (!flowglad) {
return {
isGuest: false,
isPaid: false,
freeLimit: AUTH_FREE_REQUESTS_DAILY,
planName: "Free",
}
}
try {
const billing = await flowglad.getBilling()
const hasActiveSubscription = billing.currentSubscriptions &&
billing.currentSubscriptions.length > 0
if (hasActiveSubscription) {
const usage = billing.checkUsageBalance(AI_REQUESTS_METER)
return {
isGuest: false,
isPaid: true,
remaining: usage?.availableBalance ?? PAID_PLAN_REQUESTS,
limit: PAID_PLAN_REQUESTS,
planName: "Pro",
billingPortalUrl: billing.billingPortalUrl ?? undefined,
}
}
return {
isGuest: false,
isPaid: false,
freeLimit: AUTH_FREE_REQUESTS_DAILY,
planName: "Free",
}
} catch (error) {
console.error("[billing] Error getting summary:", error)
return {
isGuest: false,
isPaid: false,
freeLimit: AUTH_FREE_REQUESTS_DAILY,
planName: "Free",
}
}
}

View File

@@ -0,0 +1,115 @@
import type {
SerializedCanvas,
SerializedCanvasImage,
SerializedCanvasSummary,
} from "./types"
const jsonHeaders = { "content-type": "application/json" }
const handleJson = async (response: Response) => {
if (!response.ok) {
const message = await response.text()
throw new Error(message || "Canvas request failed")
}
return (await response.json()) as any
}
export const fetchCanvasSnapshot = async (
canvasId: string,
): Promise<SerializedCanvas> => {
const res = await fetch(`/api/canvas/${canvasId}`, {
credentials: "include",
})
const data = await handleJson(res)
return data as SerializedCanvas
}
export const fetchCanvasList = async (): Promise<SerializedCanvasSummary[]> => {
const res = await fetch("/api/canvas", { credentials: "include" })
const data = await handleJson(res)
return data.canvases as SerializedCanvasSummary[]
}
export const createCanvasProject = async (params: {
name?: string
} = {}): Promise<SerializedCanvas> => {
const res = await fetch("/api/canvas", {
method: "POST",
headers: jsonHeaders,
credentials: "include",
body: JSON.stringify({ name: params.name }),
})
const data = await handleJson(res)
return data as SerializedCanvas
}
export const createCanvasBox = async (params: {
canvasId: string
name?: string
prompt?: string
position?: { x: number; y: number }
size?: { width: number; height: number }
modelId?: string
styleId?: string
branchParentId?: string | null
}): Promise<SerializedCanvasImage> => {
const res = await fetch("/api/canvas/images", {
method: "POST",
headers: jsonHeaders,
credentials: "include",
body: JSON.stringify(params),
})
const data = await handleJson(res)
return data.image as SerializedCanvasImage
}
export const updateCanvasBox = async (
imageId: string,
data: Partial<{
name: string
prompt: string
modelId: string
styleId: string
position: { x: number; y: number }
size: { width: number; height: number }
rotation: number
}>,
): Promise<SerializedCanvasImage> => {
const res = await fetch(`/api/canvas/images/${imageId}`, {
method: "PATCH",
headers: jsonHeaders,
credentials: "include",
body: JSON.stringify(data),
})
const json = await handleJson(res)
return json.image as SerializedCanvasImage
}
export const deleteCanvasBox = async (imageId: string) => {
const res = await fetch(`/api/canvas/images/${imageId}`, {
method: "DELETE",
headers: jsonHeaders,
credentials: "include",
})
await handleJson(res)
}
export const generateCanvasBoxImage = async (params: {
imageId: string
prompt?: string
modelId?: string
temperature?: number
}): Promise<SerializedCanvasImage> => {
const res = await fetch(`/api/canvas/images/${params.imageId}/generate`, {
method: "POST",
headers: jsonHeaders,
credentials: "include",
body: JSON.stringify({
prompt: params.prompt,
modelId: params.modelId,
temperature: params.temperature,
}),
})
const json = await handleJson(res)
return json.image as SerializedCanvasImage
}

View File

@@ -0,0 +1,364 @@
import { asc, desc, eq, inArray } from "drizzle-orm"
import { getDb } from "@/db/connection"
import { canvas, canvas_images } from "@/db/schema"
import type {
CanvasPoint,
CanvasSize,
SerializedCanvas,
SerializedCanvasImage,
SerializedCanvasRecord,
SerializedCanvasSummary,
} from "./types"
const DEFAULT_POSITION: CanvasPoint = { x: 0, y: 0 }
const DEFAULT_IMAGE_SIZE: CanvasSize = { width: 512, height: 512 }
const DEFAULT_IMAGE_NAME = "Box 1"
const DEFAULT_MODEL = "gemini-2.5-flash-image-preview"
const resolveDatabaseUrl = () => {
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 db = () => getDb(resolveDatabaseUrl())
type DatabaseClient = ReturnType<typeof db>
const parsePoint = (value: unknown): CanvasPoint => {
if (
value &&
typeof value === "object" &&
"x" in value &&
"y" in value &&
typeof (value as any).x === "number" &&
typeof (value as any).y === "number"
) {
return { x: (value as any).x, y: (value as any).y }
}
return DEFAULT_POSITION
}
const serializeCanvasRecord = (record: typeof canvas.$inferSelect): SerializedCanvasRecord => ({
id: record.id,
name: record.name,
ownerId: record.owner_id,
defaultModel: record.default_model,
defaultStyle: record.default_style,
backgroundPrompt: record.background_prompt,
width: record.width,
height: record.height,
createdAt: record.created_at.toISOString(),
updatedAt: record.updated_at.toISOString(),
})
const serializeImage = (image: typeof canvas_images.$inferSelect): SerializedCanvasImage => ({
id: image.id,
canvasId: image.canvas_id,
name: image.name,
prompt: image.prompt,
modelId: image.model_id,
modelUsed: image.model_used,
styleId: image.style_id,
width: image.width,
height: image.height,
rotation: image.rotation,
position: parsePoint(image.position),
branchParentId: image.branch_parent_id,
metadata: (image.metadata as Record<string, unknown> | null) ?? null,
imageUrl: image.image_url,
imageData: image.content_base64 ?? null,
createdAt: image.created_at.toISOString(),
updatedAt: image.updated_at.toISOString(),
})
const createCanvasWithDefaults = async (
params: {
ownerId: string
name?: string
database?: DatabaseClient
},
): Promise<SerializedCanvas> => {
const database = params.database ?? db()
const [createdCanvas] = await database
.insert(canvas)
.values({
owner_id: params.ownerId,
name: params.name ?? "Untitled Canvas",
})
.returning()
const [createdImage] = await database
.insert(canvas_images)
.values({
canvas_id: createdCanvas.id,
name: DEFAULT_IMAGE_NAME,
prompt: "",
position: DEFAULT_POSITION,
width: DEFAULT_IMAGE_SIZE.width,
height: DEFAULT_IMAGE_SIZE.height,
model_id: DEFAULT_MODEL,
style_id: "default",
})
.returning()
return {
canvas: serializeCanvasRecord(createdCanvas),
images: [serializeImage(createdImage)],
}
}
export async function getOrCreateCanvasForUser(userId: string): Promise<SerializedCanvas> {
const database = db()
const existing = await database
.select()
.from(canvas)
.where(eq(canvas.owner_id, userId))
.limit(1)
if (existing.length > 0) {
const images = await database
.select()
.from(canvas_images)
.where(eq(canvas_images.canvas_id, existing[0].id))
.orderBy(asc(canvas_images.created_at))
return {
canvas: serializeCanvasRecord(existing[0]),
images: images.map(serializeImage),
}
}
return createCanvasWithDefaults({ ownerId: userId, database })
}
export async function getCanvasSnapshotById(canvasId: string): Promise<SerializedCanvas | null> {
const database = db()
const records = await database.select().from(canvas).where(eq(canvas.id, canvasId)).limit(1)
if (records.length === 0) {
return null
}
const images = await database
.select()
.from(canvas_images)
.where(eq(canvas_images.canvas_id, canvasId))
.orderBy(asc(canvas_images.created_at))
return {
canvas: serializeCanvasRecord(records[0]),
images: images.map(serializeImage),
}
}
export async function createCanvasForUser(params: {
userId: string
name?: string
}): Promise<SerializedCanvas> {
return createCanvasWithDefaults({ ownerId: params.userId, name: params.name })
}
export async function listCanvasesForUser(userId: string): Promise<SerializedCanvasSummary[]> {
const database = db()
const records = await database
.select()
.from(canvas)
.where(eq(canvas.owner_id, userId))
.orderBy(desc(canvas.updated_at))
if (records.length === 0) {
const created = await createCanvasWithDefaults({ ownerId: userId, database })
return [
{
canvas: created.canvas,
previewImage: created.images[0] ?? null,
imageCount: created.images.length,
},
]
}
const canvasIds = records.map((record) => record.id)
const previewMap = new Map<string, SerializedCanvasImage>()
const countMap = new Map<string, number>()
if (canvasIds.length > 0) {
const images = await database
.select()
.from(canvas_images)
.where(inArray(canvas_images.canvas_id, canvasIds))
.orderBy(desc(canvas_images.updated_at))
for (const image of images) {
const serialized = serializeImage(image)
const parentCanvasId = serialized.canvasId
countMap.set(parentCanvasId, (countMap.get(parentCanvasId) ?? 0) + 1)
if (!previewMap.has(parentCanvasId)) {
previewMap.set(parentCanvasId, serialized)
}
}
}
return records.map((record) => ({
canvas: serializeCanvasRecord(record),
previewImage: previewMap.get(record.id) ?? null,
imageCount: countMap.get(record.id) ?? 0,
}))
}
export async function createCanvasImage(params: {
canvasId: string
name?: string
prompt?: string
position?: CanvasPoint
size?: CanvasSize
modelId?: string
styleId?: string
branchParentId?: string | null
}): Promise<SerializedCanvasImage> {
const database = db()
const [image] = await database
.insert(canvas_images)
.values({
canvas_id: params.canvasId,
name: params.name ?? DEFAULT_IMAGE_NAME,
prompt: params.prompt ?? "",
position: params.position ?? DEFAULT_POSITION,
width: params.size?.width ?? DEFAULT_IMAGE_SIZE.width,
height: params.size?.height ?? DEFAULT_IMAGE_SIZE.height,
model_id: params.modelId ?? DEFAULT_MODEL,
style_id: params.styleId ?? "default",
branch_parent_id: params.branchParentId ?? null,
})
.returning()
return serializeImage(image)
}
export async function updateCanvasImage(params: {
imageId: string
data: {
name?: string
prompt?: string
modelId?: string
modelUsed?: string | null
styleId?: string
position?: CanvasPoint
size?: CanvasSize
rotation?: number
metadata?: Record<string, unknown> | null
branchParentId?: string | null
imageDataBase64?: string | null
imageUrl?: string | null
}
}): Promise<SerializedCanvasImage> {
const database = db()
const values: Partial<typeof canvas_images.$inferInsert> = {
updated_at: new Date(),
}
if (params.data.name !== undefined) values.name = params.data.name
if (params.data.prompt !== undefined) values.prompt = params.data.prompt
if (params.data.modelId !== undefined) values.model_id = params.data.modelId
if (params.data.modelUsed !== undefined) values.model_used = params.data.modelUsed
if (params.data.styleId !== undefined) values.style_id = params.data.styleId
if (params.data.position) values.position = params.data.position
if (params.data.size) {
values.width = params.data.size.width
values.height = params.data.size.height
}
if (typeof params.data.rotation === "number") {
values.rotation = params.data.rotation
}
if (params.data.metadata !== undefined) {
values.metadata = params.data.metadata ?? null
}
if (params.data.branchParentId !== undefined) {
values.branch_parent_id = params.data.branchParentId
}
if (params.data.imageDataBase64 !== undefined) {
values.content_base64 = params.data.imageDataBase64 ?? null
}
if (params.data.imageUrl !== undefined) {
values.image_url = params.data.imageUrl
}
const [updated] = await database
.update(canvas_images)
.set(values)
.where(eq(canvas_images.id, params.imageId))
.returning()
return serializeImage(updated)
}
export async function deleteCanvasImage(imageId: string) {
const database = db()
await database.delete(canvas_images).where(eq(canvas_images.id, imageId))
}
export async function updateCanvasRecord(params: {
canvasId: string
data: {
name?: string
width?: number
height?: number
defaultModel?: string
defaultStyle?: string
backgroundPrompt?: string | null
}
}): Promise<SerializedCanvasRecord> {
const database = db()
const values: Partial<typeof canvas.$inferInsert> = {
updated_at: new Date(),
}
if (params.data.name !== undefined) values.name = params.data.name
if (params.data.width !== undefined) values.width = params.data.width
if (params.data.height !== undefined) values.height = params.data.height
if (params.data.defaultModel !== undefined) values.default_model = params.data.defaultModel
if (params.data.defaultStyle !== undefined) values.default_style = params.data.defaultStyle
if (params.data.backgroundPrompt !== undefined)
values.background_prompt = params.data.backgroundPrompt
const [record] = await database
.update(canvas)
.set(values)
.where(eq(canvas.id, params.canvasId))
.returning()
return serializeCanvasRecord(record)
}
export async function getCanvasOwner(canvasId: string) {
const database = db()
const [record] = await database
.select({ ownerId: canvas.owner_id })
.from(canvas)
.where(eq(canvas.id, canvasId))
.limit(1)
return record ?? null
}
export async function getCanvasImageRecord(imageId: string) {
const database = db()
const [record] = await database
.select()
.from(canvas_images)
.where(eq(canvas_images.id, imageId))
.limit(1)
return record ?? null
}

View File

@@ -0,0 +1,53 @@
export type CanvasPoint = {
x: number
y: number
}
export type CanvasSize = {
width: number
height: number
}
export type SerializedCanvasRecord = {
id: string
name: string
ownerId: string
defaultModel: string
defaultStyle: string
backgroundPrompt: string | null
width: number
height: number
createdAt: string
updatedAt: string
}
export type SerializedCanvasImage = {
id: string
canvasId: string
name: string
prompt: string
modelId: string
modelUsed: string | null
styleId: string
width: number
height: number
rotation: number
position: CanvasPoint
branchParentId: string | null
metadata: Record<string, unknown> | null
imageUrl: string | null
imageData: string | null
createdAt: string
updatedAt: string
}
export type SerializedCanvas = {
canvas: SerializedCanvasRecord
images: SerializedCanvasImage[]
}
export type SerializedCanvasSummary = {
canvas: SerializedCanvasRecord
previewImage: SerializedCanvasImage | null
imageCount: number
}

View File

@@ -0,0 +1,85 @@
import { eq } from "drizzle-orm"
import { getAuth } from "@/lib/auth"
import { getAuthDb } from "@/db/connection"
import { users } from "@/db/schema"
const COOKIE_NAME = "canvas_guest_id"
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30 // 30 days
const parseCookies = (header: string | null) => {
if (!header) return {}
return header.split(";").reduce<Record<string, string>>((acc, part) => {
const [key, ...rest] = part.trim().split("=")
if (!key) return acc
acc[key] = rest.join("=")
return acc
}, {})
}
const buildCookie = (id: string) =>
`${COOKIE_NAME}=${id}; Path=/; Max-Age=${COOKIE_MAX_AGE}; HttpOnly; SameSite=Lax`
const resolveDatabaseUrl = () => {
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 {
// ignore
}
if (process.env.DATABASE_URL) {
return process.env.DATABASE_URL
}
throw new Error("DATABASE_URL is not configured")
}
const getAuthDatabase = () => {
const url = resolveDatabaseUrl()
return getAuthDb(url)
}
async function ensureGuestUser(existingId?: string) {
const db = getAuthDatabase()
if (existingId) {
const existing = await db.query.users.findFirst({
where(fields, { eq }) {
return eq(fields.id, existingId)
},
})
if (existing) {
return { userId: existingId, setCookie: undefined }
}
}
const newId = crypto.randomUUID()
const email = `canvas-guest-${newId}@example.local`
await db.insert(users).values({
id: newId,
name: "Canvas Guest",
email,
})
return { userId: newId, setCookie: buildCookie(newId) }
}
export async function resolveCanvasUser(request: Request) {
const session = await getAuth().api.getSession({ headers: request.headers })
if (session?.user?.id) {
return { userId: session.user.id, setCookie: undefined }
}
const cookies = parseCookies(request.headers.get("cookie"))
const guestId = cookies[COOKIE_NAME]
return ensureGuestUser(guestId)
}

View File

@@ -0,0 +1,107 @@
import { createCollection } from "@tanstack/react-db"
import { electricCollectionOptions } from "@tanstack/electric-db-collection"
import {
selectUsersSchema,
selectChatThreadSchema,
selectChatMessageSchema,
} from "@/db/schema"
export const usersCollection = createCollection(
electricCollectionOptions({
id: "users",
shapeOptions: {
url: new URL(
"/api/users",
typeof window !== "undefined"
? window.location.origin
: "http://localhost:3000",
).toString(),
parser: {
timestamptz: (date: string) => new Date(date),
},
},
schema: selectUsersSchema,
getKey: (item) => item.id,
}),
)
const baseUrl =
typeof window !== "undefined"
? window.location.origin
: "http://localhost:3000"
// Create collections lazily to avoid fetching before authentication
// Using a factory pattern so each call gets the same collection instance
const createChatThreadsCollection = () =>
createCollection(
electricCollectionOptions({
id: "chat_threads",
shapeOptions: {
url: new URL("/api/chat-threads", baseUrl).toString(),
parser: {
timestamptz: (date: string) => new Date(date),
},
fetchClient: (input, init) =>
fetch(input, { ...init, credentials: "include" }),
onError: () => {
// Silently ignore auth errors for guest users
},
},
schema: selectChatThreadSchema,
getKey: (item) => item.id,
}),
)
const createChatMessagesCollection = () =>
createCollection(
electricCollectionOptions({
id: "chat_messages",
shapeOptions: {
url: new URL("/api/chat-messages", baseUrl).toString(),
parser: {
timestamptz: (date: string) => new Date(date),
},
fetchClient: (input, init) =>
fetch(input, { ...init, credentials: "include" }),
onError: () => {
// Silently ignore auth errors for guest users
},
},
schema: selectChatMessageSchema,
getKey: (item) => item.id,
}),
)
type ChatThreadsCollection = ReturnType<typeof createChatThreadsCollection>
type ChatMessagesCollection = ReturnType<typeof createChatMessagesCollection>
let _chatThreadsCollection: ChatThreadsCollection | null = null
let _chatMessagesCollection: ChatMessagesCollection | null = null
export function getChatThreadsCollection(): ChatThreadsCollection {
if (!_chatThreadsCollection) {
_chatThreadsCollection = createChatThreadsCollection()
}
return _chatThreadsCollection
}
export function getChatMessagesCollection(): ChatMessagesCollection {
if (!_chatMessagesCollection) {
_chatMessagesCollection = createChatMessagesCollection()
}
return _chatMessagesCollection
}
// Keep exports for backward compatibility but as getters
export const chatThreadsCollection = {
get collection() {
return getChatThreadsCollection()
},
}
export const chatMessagesCollection = {
get collection() {
return getChatMessagesCollection()
},
}

View File

@@ -0,0 +1,103 @@
import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from "@electric-sql/client"
type ElectricEnv = {
ELECTRIC_URL?: string
ELECTRIC_SOURCE_ID?: string
ELECTRIC_SOURCE_SECRET?: string
}
const DEFAULT_ALLOW_HEADERS =
"content-type,authorization,x-requested-with,x-electric-client-id"
// Get env from Cloudflare context or process.env
const getElectricEnv = (): ElectricEnv => {
let ELECTRIC_URL: string | undefined
let ELECTRIC_SOURCE_ID: string | undefined
let ELECTRIC_SOURCE_SECRET: string | undefined
// Try Cloudflare Workers context first (production)
try {
const { getServerContext } = require("@tanstack/react-start/server")
const ctx = getServerContext()
if (ctx?.cloudflare?.env) {
const cfEnv = ctx.cloudflare.env as Partial<ElectricEnv>
ELECTRIC_URL = cfEnv.ELECTRIC_URL
ELECTRIC_SOURCE_ID = cfEnv.ELECTRIC_SOURCE_ID
ELECTRIC_SOURCE_SECRET = cfEnv.ELECTRIC_SOURCE_SECRET
}
} catch {
// Not in Cloudflare context
}
// Fall back to process.env (local dev)
return {
ELECTRIC_URL: ELECTRIC_URL ?? process.env.ELECTRIC_URL,
ELECTRIC_SOURCE_ID: ELECTRIC_SOURCE_ID ?? process.env.ELECTRIC_SOURCE_ID,
ELECTRIC_SOURCE_SECRET:
ELECTRIC_SOURCE_SECRET ?? process.env.ELECTRIC_SOURCE_SECRET,
}
}
export function prepareElectricUrl(requestUrl: string): URL {
const url = new URL(requestUrl)
const env = getElectricEnv()
const electricUrl = env.ELECTRIC_URL ?? "http://localhost:3100"
const originUrl = new URL(`${electricUrl}/v1/shape`)
url.searchParams.forEach((value, key) => {
if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) {
originUrl.searchParams.set(key, value)
}
})
if (env.ELECTRIC_SOURCE_ID && env.ELECTRIC_SOURCE_SECRET) {
originUrl.searchParams.set("source_id", env.ELECTRIC_SOURCE_ID)
originUrl.searchParams.set("secret", env.ELECTRIC_SOURCE_SECRET)
}
return originUrl
}
const buildCorsHeaders = (request?: Request) => {
const headers = new Headers()
const origin = request?.headers.get("origin")
if (origin) {
headers.set("access-control-allow-origin", origin)
headers.set("access-control-allow-credentials", "true")
} else {
headers.set("access-control-allow-origin", "*")
}
const requestedHeaders =
request?.headers.get("access-control-request-headers") ?? DEFAULT_ALLOW_HEADERS
headers.set("access-control-allow-headers", requestedHeaders)
headers.set("access-control-allow-methods", "GET,OPTIONS")
return headers
}
export const optionsResponse = (request?: Request) =>
new Response(null, {
status: 204,
headers: buildCorsHeaders(request),
})
export async function proxyElectricRequest(
originUrl: URL,
request?: Request,
): Promise<Response> {
const response = await fetch(originUrl)
const headers = new Headers(response.headers)
const corsHeaders = buildCorsHeaders(request)
headers.delete("content-encoding")
headers.delete("content-length")
headers.set("vary", "cookie")
corsHeaders.forEach((value, key) => headers.set(key, value))
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
})
}

View File

@@ -0,0 +1,73 @@
import { FlowgladServer } from "@flowglad/server"
import { getAuth } from "./auth"
type FlowgladEnv = {
FLOWGLAD_SECRET_KEY?: string
}
const getEnv = (): FlowgladEnv => {
let FLOWGLAD_SECRET_KEY: string | undefined
try {
const { getServerContext } = require("@tanstack/react-start/server") as {
getServerContext: () => { cloudflare?: { env?: FlowgladEnv } } | null
}
const ctx = getServerContext()
FLOWGLAD_SECRET_KEY = ctx?.cloudflare?.env?.FLOWGLAD_SECRET_KEY
} catch {
// Not in server context
}
FLOWGLAD_SECRET_KEY = FLOWGLAD_SECRET_KEY ?? process.env.FLOWGLAD_SECRET_KEY
return { FLOWGLAD_SECRET_KEY }
}
export const getFlowgladServer = (request?: Request) => {
const env = getEnv()
if (!env.FLOWGLAD_SECRET_KEY) {
return null
}
return new FlowgladServer({
apiKey: env.FLOWGLAD_SECRET_KEY,
getRequestingCustomer: async () => {
if (!request) {
throw new Error("Request required to get customer")
}
const auth = getAuth()
const session = await auth.api.getSession({ headers: request.headers })
if (!session?.user) {
throw new Error("Unauthenticated")
}
return {
externalId: session.user.id,
email: session.user.email,
name: session.user.name ?? undefined,
}
},
})
}
/**
* Create a FlowgladServer instance for a specific user ID.
* Use this when you already have the user ID and don't need request-based auth.
*/
export const flowglad = (userId: string) => {
const env = getEnv()
if (!env.FLOWGLAD_SECRET_KEY) {
return null
}
return new FlowgladServer({
apiKey: env.FLOWGLAD_SECRET_KEY,
getRequestingCustomer: async () => ({
externalId: userId,
}),
})
}

View File

@@ -0,0 +1,36 @@
export type StreamPageData = {
user: {
id: string
name: string
username: string | null
image: string | null
}
stream: {
id: string
title: string
description: string | null
is_live: boolean
viewer_count: number
hls_url: string | null
thumbnail_url: string | null
started_at: string | null
} | null
}
export async function getStreamByUsername(
username: string,
): Promise<StreamPageData | null> {
const res = await fetch(`/api/streams/${username}`, {
credentials: "include",
})
if (res.status === 404) {
return null
}
if (!res.ok) {
throw new Error("Failed to fetch stream data")
}
return res.json()
}

View File

@@ -0,0 +1,49 @@
/**
* Example utilities for calling the Worker RPC from the web package
*
* Usage in server functions or loaders:
*
* import { getServerContext } from '@tanstack/react-start/server';
* import { callWorkerRpc } from '@/lib/worker-rpc';
*
* export const loader = async () => {
* const { WORKER_RPC } = getServerContext().cloudflare.env;
* const result = await callWorkerRpc(WORKER_RPC);
* return result;
* };
*/
import type { WorkerRpc } from "../../../worker/src/rpc"
/**
* Example: Call the sayHello RPC method
*/
export async function sayHelloRpc(workerRpc: WorkerRpc, name: string) {
return await workerRpc.sayHello(name)
}
/**
* Example: Call the calculate RPC method
*/
export async function calculateRpc(
workerRpc: WorkerRpc,
operation: "add" | "subtract" | "multiply" | "divide",
a: number,
b: number,
) {
return await workerRpc.calculate(operation, a, b)
}
/**
* Example: Call the processBatch RPC method
*/
export async function processBatchRpc(workerRpc: WorkerRpc, items: string[]) {
return await workerRpc.processBatch(items)
}
/**
* Example: Call the getData RPC method
*/
export async function getDataRpc(workerRpc: WorkerRpc, key: string) {
return await workerRpc.getData(key)
}

12
packages/web/src/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -0,0 +1,993 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as UsersRouteImport } from './routes/users'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as SessionsRouteImport } from './routes/sessions'
import { Route as MarketplaceRouteImport } from './routes/marketplace'
import { Route as LoginRouteImport } from './routes/login'
import { Route as ChatRouteImport } from './routes/chat'
import { Route as CanvasRouteImport } from './routes/canvas'
import { Route as BlocksRouteImport } from './routes/blocks'
import { Route as AuthRouteImport } from './routes/auth'
import { Route as UsernameRouteImport } from './routes/$username'
import { Route as IndexRouteImport } from './routes/index'
import { Route as CanvasIndexRouteImport } from './routes/canvas.index'
import { Route as I1focusDemoRouteImport } from './routes/i.1focus-demo'
import { Route as CanvasCanvasIdRouteImport } from './routes/canvas.$canvasId'
import { Route as ApiUsersRouteImport } from './routes/api/users'
import { Route as ApiUsageEventsRouteImport } from './routes/api/usage-events'
import { Route as ApiStreamRouteImport } from './routes/api/stream'
import { Route as ApiProfileRouteImport } from './routes/api/profile'
import { Route as ApiContextItemsRouteImport } from './routes/api/context-items'
import { Route as ApiChatThreadsRouteImport } from './routes/api/chat-threads'
import { Route as ApiChatMessagesRouteImport } from './routes/api/chat-messages'
import { Route as ApiCanvasRouteImport } from './routes/api/canvas'
import { Route as ApiBrowserSessionsRouteImport } from './routes/api/browser-sessions'
import { Route as DemoStartServerFuncsRouteImport } from './routes/demo/start.server-funcs'
import { Route as DemoStartApiRequestRouteImport } from './routes/demo/start.api-request'
import { Route as DemoApiNamesRouteImport } from './routes/demo/api.names'
import { Route as ApiUsageEventsCreateRouteImport } from './routes/api/usage-events.create'
import { Route as ApiStreamsUsernameRouteImport } from './routes/api/streams.$username'
import { Route as ApiFlowgladSplatRouteImport } from './routes/api/flowglad/$'
import { Route as ApiChatMutationsRouteImport } from './routes/api/chat/mutations'
import { Route as ApiChatGuestRouteImport } from './routes/api/chat/guest'
import { Route as ApiChatAiRouteImport } from './routes/api/chat/ai'
import { Route as ApiCanvasImagesRouteImport } from './routes/api/canvas.images'
import { Route as ApiCanvasCanvasIdRouteImport } from './routes/api/canvas.$canvasId'
import { Route as ApiBrowserSessionsSessionIdRouteImport } from './routes/api/browser-sessions.$sessionId'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
import { Route as DemoStartSsrIndexRouteImport } from './routes/demo/start.ssr.index'
import { Route as DemoStartSsrSpaModeRouteImport } from './routes/demo/start.ssr.spa-mode'
import { Route as DemoStartSsrFullSsrRouteImport } from './routes/demo/start.ssr.full-ssr'
import { Route as DemoStartSsrDataOnlyRouteImport } from './routes/demo/start.ssr.data-only'
import { Route as ApiCanvasImagesImageIdRouteImport } from './routes/api/canvas.images.$imageId'
import { Route as ApiCanvasImagesImageIdGenerateRouteImport } from './routes/api/canvas.images.$imageId.generate'
const UsersRoute = UsersRouteImport.update({
id: '/users',
path: '/users',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsRoute = SettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)
const SessionsRoute = SessionsRouteImport.update({
id: '/sessions',
path: '/sessions',
getParentRoute: () => rootRouteImport,
} as any)
const MarketplaceRoute = MarketplaceRouteImport.update({
id: '/marketplace',
path: '/marketplace',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const ChatRoute = ChatRouteImport.update({
id: '/chat',
path: '/chat',
getParentRoute: () => rootRouteImport,
} as any)
const CanvasRoute = CanvasRouteImport.update({
id: '/canvas',
path: '/canvas',
getParentRoute: () => rootRouteImport,
} as any)
const BlocksRoute = BlocksRouteImport.update({
id: '/blocks',
path: '/blocks',
getParentRoute: () => rootRouteImport,
} as any)
const AuthRoute = AuthRouteImport.update({
id: '/auth',
path: '/auth',
getParentRoute: () => rootRouteImport,
} as any)
const UsernameRoute = UsernameRouteImport.update({
id: '/$username',
path: '/$username',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const CanvasIndexRoute = CanvasIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => CanvasRoute,
} as any)
const I1focusDemoRoute = I1focusDemoRouteImport.update({
id: '/i/1focus-demo',
path: '/i/1focus-demo',
getParentRoute: () => rootRouteImport,
} as any)
const CanvasCanvasIdRoute = CanvasCanvasIdRouteImport.update({
id: '/$canvasId',
path: '/$canvasId',
getParentRoute: () => CanvasRoute,
} as any)
const ApiUsersRoute = ApiUsersRouteImport.update({
id: '/api/users',
path: '/api/users',
getParentRoute: () => rootRouteImport,
} as any)
const ApiUsageEventsRoute = ApiUsageEventsRouteImport.update({
id: '/api/usage-events',
path: '/api/usage-events',
getParentRoute: () => rootRouteImport,
} as any)
const ApiStreamRoute = ApiStreamRouteImport.update({
id: '/api/stream',
path: '/api/stream',
getParentRoute: () => rootRouteImport,
} as any)
const ApiProfileRoute = ApiProfileRouteImport.update({
id: '/api/profile',
path: '/api/profile',
getParentRoute: () => rootRouteImport,
} as any)
const ApiContextItemsRoute = ApiContextItemsRouteImport.update({
id: '/api/context-items',
path: '/api/context-items',
getParentRoute: () => rootRouteImport,
} as any)
const ApiChatThreadsRoute = ApiChatThreadsRouteImport.update({
id: '/api/chat-threads',
path: '/api/chat-threads',
getParentRoute: () => rootRouteImport,
} as any)
const ApiChatMessagesRoute = ApiChatMessagesRouteImport.update({
id: '/api/chat-messages',
path: '/api/chat-messages',
getParentRoute: () => rootRouteImport,
} as any)
const ApiCanvasRoute = ApiCanvasRouteImport.update({
id: '/api/canvas',
path: '/api/canvas',
getParentRoute: () => rootRouteImport,
} as any)
const ApiBrowserSessionsRoute = ApiBrowserSessionsRouteImport.update({
id: '/api/browser-sessions',
path: '/api/browser-sessions',
getParentRoute: () => rootRouteImport,
} as any)
const DemoStartServerFuncsRoute = DemoStartServerFuncsRouteImport.update({
id: '/demo/start/server-funcs',
path: '/demo/start/server-funcs',
getParentRoute: () => rootRouteImport,
} as any)
const DemoStartApiRequestRoute = DemoStartApiRequestRouteImport.update({
id: '/demo/start/api-request',
path: '/demo/start/api-request',
getParentRoute: () => rootRouteImport,
} as any)
const DemoApiNamesRoute = DemoApiNamesRouteImport.update({
id: '/demo/api/names',
path: '/demo/api/names',
getParentRoute: () => rootRouteImport,
} as any)
const ApiUsageEventsCreateRoute = ApiUsageEventsCreateRouteImport.update({
id: '/create',
path: '/create',
getParentRoute: () => ApiUsageEventsRoute,
} as any)
const ApiStreamsUsernameRoute = ApiStreamsUsernameRouteImport.update({
id: '/api/streams/$username',
path: '/api/streams/$username',
getParentRoute: () => rootRouteImport,
} as any)
const ApiFlowgladSplatRoute = ApiFlowgladSplatRouteImport.update({
id: '/api/flowglad/$',
path: '/api/flowglad/$',
getParentRoute: () => rootRouteImport,
} as any)
const ApiChatMutationsRoute = ApiChatMutationsRouteImport.update({
id: '/api/chat/mutations',
path: '/api/chat/mutations',
getParentRoute: () => rootRouteImport,
} as any)
const ApiChatGuestRoute = ApiChatGuestRouteImport.update({
id: '/api/chat/guest',
path: '/api/chat/guest',
getParentRoute: () => rootRouteImport,
} as any)
const ApiChatAiRoute = ApiChatAiRouteImport.update({
id: '/api/chat/ai',
path: '/api/chat/ai',
getParentRoute: () => rootRouteImport,
} as any)
const ApiCanvasImagesRoute = ApiCanvasImagesRouteImport.update({
id: '/images',
path: '/images',
getParentRoute: () => ApiCanvasRoute,
} as any)
const ApiCanvasCanvasIdRoute = ApiCanvasCanvasIdRouteImport.update({
id: '/$canvasId',
path: '/$canvasId',
getParentRoute: () => ApiCanvasRoute,
} as any)
const ApiBrowserSessionsSessionIdRoute =
ApiBrowserSessionsSessionIdRouteImport.update({
id: '/$sessionId',
path: '/$sessionId',
getParentRoute: () => ApiBrowserSessionsRoute,
} as any)
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
id: '/api/auth/$',
path: '/api/auth/$',
getParentRoute: () => rootRouteImport,
} as any)
const DemoStartSsrIndexRoute = DemoStartSsrIndexRouteImport.update({
id: '/demo/start/ssr/',
path: '/demo/start/ssr/',
getParentRoute: () => rootRouteImport,
} as any)
const DemoStartSsrSpaModeRoute = DemoStartSsrSpaModeRouteImport.update({
id: '/demo/start/ssr/spa-mode',
path: '/demo/start/ssr/spa-mode',
getParentRoute: () => rootRouteImport,
} as any)
const DemoStartSsrFullSsrRoute = DemoStartSsrFullSsrRouteImport.update({
id: '/demo/start/ssr/full-ssr',
path: '/demo/start/ssr/full-ssr',
getParentRoute: () => rootRouteImport,
} as any)
const DemoStartSsrDataOnlyRoute = DemoStartSsrDataOnlyRouteImport.update({
id: '/demo/start/ssr/data-only',
path: '/demo/start/ssr/data-only',
getParentRoute: () => rootRouteImport,
} as any)
const ApiCanvasImagesImageIdRoute = ApiCanvasImagesImageIdRouteImport.update({
id: '/$imageId',
path: '/$imageId',
getParentRoute: () => ApiCanvasImagesRoute,
} as any)
const ApiCanvasImagesImageIdGenerateRoute =
ApiCanvasImagesImageIdGenerateRouteImport.update({
id: '/generate',
path: '/generate',
getParentRoute: () => ApiCanvasImagesImageIdRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/$username': typeof UsernameRoute
'/auth': typeof AuthRoute
'/blocks': typeof BlocksRoute
'/canvas': typeof CanvasRouteWithChildren
'/chat': typeof ChatRoute
'/login': typeof LoginRoute
'/marketplace': typeof MarketplaceRoute
'/sessions': typeof SessionsRoute
'/settings': typeof SettingsRoute
'/users': typeof UsersRoute
'/api/browser-sessions': typeof ApiBrowserSessionsRouteWithChildren
'/api/canvas': typeof ApiCanvasRouteWithChildren
'/api/chat-messages': typeof ApiChatMessagesRoute
'/api/chat-threads': typeof ApiChatThreadsRoute
'/api/context-items': typeof ApiContextItemsRoute
'/api/profile': typeof ApiProfileRoute
'/api/stream': typeof ApiStreamRoute
'/api/usage-events': typeof ApiUsageEventsRouteWithChildren
'/api/users': typeof ApiUsersRoute
'/canvas/$canvasId': typeof CanvasCanvasIdRoute
'/i/1focus-demo': typeof I1focusDemoRoute
'/canvas/': typeof CanvasIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/browser-sessions/$sessionId': typeof ApiBrowserSessionsSessionIdRoute
'/api/canvas/$canvasId': typeof ApiCanvasCanvasIdRoute
'/api/canvas/images': typeof ApiCanvasImagesRouteWithChildren
'/api/chat/ai': typeof ApiChatAiRoute
'/api/chat/guest': typeof ApiChatGuestRoute
'/api/chat/mutations': typeof ApiChatMutationsRoute
'/api/flowglad/$': typeof ApiFlowgladSplatRoute
'/api/streams/$username': typeof ApiStreamsUsernameRoute
'/api/usage-events/create': typeof ApiUsageEventsCreateRoute
'/demo/api/names': typeof DemoApiNamesRoute
'/demo/start/api-request': typeof DemoStartApiRequestRoute
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
'/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute
'/demo/start/ssr': typeof DemoStartSsrIndexRoute
'/api/canvas/images/$imageId/generate': typeof ApiCanvasImagesImageIdGenerateRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/$username': typeof UsernameRoute
'/auth': typeof AuthRoute
'/blocks': typeof BlocksRoute
'/chat': typeof ChatRoute
'/login': typeof LoginRoute
'/marketplace': typeof MarketplaceRoute
'/sessions': typeof SessionsRoute
'/settings': typeof SettingsRoute
'/users': typeof UsersRoute
'/api/browser-sessions': typeof ApiBrowserSessionsRouteWithChildren
'/api/canvas': typeof ApiCanvasRouteWithChildren
'/api/chat-messages': typeof ApiChatMessagesRoute
'/api/chat-threads': typeof ApiChatThreadsRoute
'/api/context-items': typeof ApiContextItemsRoute
'/api/profile': typeof ApiProfileRoute
'/api/stream': typeof ApiStreamRoute
'/api/usage-events': typeof ApiUsageEventsRouteWithChildren
'/api/users': typeof ApiUsersRoute
'/canvas/$canvasId': typeof CanvasCanvasIdRoute
'/i/1focus-demo': typeof I1focusDemoRoute
'/canvas': typeof CanvasIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/browser-sessions/$sessionId': typeof ApiBrowserSessionsSessionIdRoute
'/api/canvas/$canvasId': typeof ApiCanvasCanvasIdRoute
'/api/canvas/images': typeof ApiCanvasImagesRouteWithChildren
'/api/chat/ai': typeof ApiChatAiRoute
'/api/chat/guest': typeof ApiChatGuestRoute
'/api/chat/mutations': typeof ApiChatMutationsRoute
'/api/flowglad/$': typeof ApiFlowgladSplatRoute
'/api/streams/$username': typeof ApiStreamsUsernameRoute
'/api/usage-events/create': typeof ApiUsageEventsCreateRoute
'/demo/api/names': typeof DemoApiNamesRoute
'/demo/start/api-request': typeof DemoStartApiRequestRoute
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
'/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute
'/demo/start/ssr': typeof DemoStartSsrIndexRoute
'/api/canvas/images/$imageId/generate': typeof ApiCanvasImagesImageIdGenerateRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/$username': typeof UsernameRoute
'/auth': typeof AuthRoute
'/blocks': typeof BlocksRoute
'/canvas': typeof CanvasRouteWithChildren
'/chat': typeof ChatRoute
'/login': typeof LoginRoute
'/marketplace': typeof MarketplaceRoute
'/sessions': typeof SessionsRoute
'/settings': typeof SettingsRoute
'/users': typeof UsersRoute
'/api/browser-sessions': typeof ApiBrowserSessionsRouteWithChildren
'/api/canvas': typeof ApiCanvasRouteWithChildren
'/api/chat-messages': typeof ApiChatMessagesRoute
'/api/chat-threads': typeof ApiChatThreadsRoute
'/api/context-items': typeof ApiContextItemsRoute
'/api/profile': typeof ApiProfileRoute
'/api/stream': typeof ApiStreamRoute
'/api/usage-events': typeof ApiUsageEventsRouteWithChildren
'/api/users': typeof ApiUsersRoute
'/canvas/$canvasId': typeof CanvasCanvasIdRoute
'/i/1focus-demo': typeof I1focusDemoRoute
'/canvas/': typeof CanvasIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/browser-sessions/$sessionId': typeof ApiBrowserSessionsSessionIdRoute
'/api/canvas/$canvasId': typeof ApiCanvasCanvasIdRoute
'/api/canvas/images': typeof ApiCanvasImagesRouteWithChildren
'/api/chat/ai': typeof ApiChatAiRoute
'/api/chat/guest': typeof ApiChatGuestRoute
'/api/chat/mutations': typeof ApiChatMutationsRoute
'/api/flowglad/$': typeof ApiFlowgladSplatRoute
'/api/streams/$username': typeof ApiStreamsUsernameRoute
'/api/usage-events/create': typeof ApiUsageEventsCreateRoute
'/demo/api/names': typeof DemoApiNamesRoute
'/demo/start/api-request': typeof DemoStartApiRequestRoute
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
'/api/canvas/images/$imageId': typeof ApiCanvasImagesImageIdRouteWithChildren
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute
'/demo/start/ssr/': typeof DemoStartSsrIndexRoute
'/api/canvas/images/$imageId/generate': typeof ApiCanvasImagesImageIdGenerateRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/$username'
| '/auth'
| '/blocks'
| '/canvas'
| '/chat'
| '/login'
| '/marketplace'
| '/sessions'
| '/settings'
| '/users'
| '/api/browser-sessions'
| '/api/canvas'
| '/api/chat-messages'
| '/api/chat-threads'
| '/api/context-items'
| '/api/profile'
| '/api/stream'
| '/api/usage-events'
| '/api/users'
| '/canvas/$canvasId'
| '/i/1focus-demo'
| '/canvas/'
| '/api/auth/$'
| '/api/browser-sessions/$sessionId'
| '/api/canvas/$canvasId'
| '/api/canvas/images'
| '/api/chat/ai'
| '/api/chat/guest'
| '/api/chat/mutations'
| '/api/flowglad/$'
| '/api/streams/$username'
| '/api/usage-events/create'
| '/demo/api/names'
| '/demo/start/api-request'
| '/demo/start/server-funcs'
| '/api/canvas/images/$imageId'
| '/demo/start/ssr/data-only'
| '/demo/start/ssr/full-ssr'
| '/demo/start/ssr/spa-mode'
| '/demo/start/ssr'
| '/api/canvas/images/$imageId/generate'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/$username'
| '/auth'
| '/blocks'
| '/chat'
| '/login'
| '/marketplace'
| '/sessions'
| '/settings'
| '/users'
| '/api/browser-sessions'
| '/api/canvas'
| '/api/chat-messages'
| '/api/chat-threads'
| '/api/context-items'
| '/api/profile'
| '/api/stream'
| '/api/usage-events'
| '/api/users'
| '/canvas/$canvasId'
| '/i/1focus-demo'
| '/canvas'
| '/api/auth/$'
| '/api/browser-sessions/$sessionId'
| '/api/canvas/$canvasId'
| '/api/canvas/images'
| '/api/chat/ai'
| '/api/chat/guest'
| '/api/chat/mutations'
| '/api/flowglad/$'
| '/api/streams/$username'
| '/api/usage-events/create'
| '/demo/api/names'
| '/demo/start/api-request'
| '/demo/start/server-funcs'
| '/api/canvas/images/$imageId'
| '/demo/start/ssr/data-only'
| '/demo/start/ssr/full-ssr'
| '/demo/start/ssr/spa-mode'
| '/demo/start/ssr'
| '/api/canvas/images/$imageId/generate'
id:
| '__root__'
| '/'
| '/$username'
| '/auth'
| '/blocks'
| '/canvas'
| '/chat'
| '/login'
| '/marketplace'
| '/sessions'
| '/settings'
| '/users'
| '/api/browser-sessions'
| '/api/canvas'
| '/api/chat-messages'
| '/api/chat-threads'
| '/api/context-items'
| '/api/profile'
| '/api/stream'
| '/api/usage-events'
| '/api/users'
| '/canvas/$canvasId'
| '/i/1focus-demo'
| '/canvas/'
| '/api/auth/$'
| '/api/browser-sessions/$sessionId'
| '/api/canvas/$canvasId'
| '/api/canvas/images'
| '/api/chat/ai'
| '/api/chat/guest'
| '/api/chat/mutations'
| '/api/flowglad/$'
| '/api/streams/$username'
| '/api/usage-events/create'
| '/demo/api/names'
| '/demo/start/api-request'
| '/demo/start/server-funcs'
| '/api/canvas/images/$imageId'
| '/demo/start/ssr/data-only'
| '/demo/start/ssr/full-ssr'
| '/demo/start/ssr/spa-mode'
| '/demo/start/ssr/'
| '/api/canvas/images/$imageId/generate'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
UsernameRoute: typeof UsernameRoute
AuthRoute: typeof AuthRoute
BlocksRoute: typeof BlocksRoute
CanvasRoute: typeof CanvasRouteWithChildren
ChatRoute: typeof ChatRoute
LoginRoute: typeof LoginRoute
MarketplaceRoute: typeof MarketplaceRoute
SessionsRoute: typeof SessionsRoute
SettingsRoute: typeof SettingsRoute
UsersRoute: typeof UsersRoute
ApiBrowserSessionsRoute: typeof ApiBrowserSessionsRouteWithChildren
ApiCanvasRoute: typeof ApiCanvasRouteWithChildren
ApiChatMessagesRoute: typeof ApiChatMessagesRoute
ApiChatThreadsRoute: typeof ApiChatThreadsRoute
ApiContextItemsRoute: typeof ApiContextItemsRoute
ApiProfileRoute: typeof ApiProfileRoute
ApiStreamRoute: typeof ApiStreamRoute
ApiUsageEventsRoute: typeof ApiUsageEventsRouteWithChildren
ApiUsersRoute: typeof ApiUsersRoute
I1focusDemoRoute: typeof I1focusDemoRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiChatAiRoute: typeof ApiChatAiRoute
ApiChatGuestRoute: typeof ApiChatGuestRoute
ApiChatMutationsRoute: typeof ApiChatMutationsRoute
ApiFlowgladSplatRoute: typeof ApiFlowgladSplatRoute
ApiStreamsUsernameRoute: typeof ApiStreamsUsernameRoute
DemoApiNamesRoute: typeof DemoApiNamesRoute
DemoStartApiRequestRoute: typeof DemoStartApiRequestRoute
DemoStartServerFuncsRoute: typeof DemoStartServerFuncsRoute
DemoStartSsrDataOnlyRoute: typeof DemoStartSsrDataOnlyRoute
DemoStartSsrFullSsrRoute: typeof DemoStartSsrFullSsrRoute
DemoStartSsrSpaModeRoute: typeof DemoStartSsrSpaModeRoute
DemoStartSsrIndexRoute: typeof DemoStartSsrIndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/users': {
id: '/users'
path: '/users'
fullPath: '/users'
preLoaderRoute: typeof UsersRouteImport
parentRoute: typeof rootRouteImport
}
'/settings': {
id: '/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/sessions': {
id: '/sessions'
path: '/sessions'
fullPath: '/sessions'
preLoaderRoute: typeof SessionsRouteImport
parentRoute: typeof rootRouteImport
}
'/marketplace': {
id: '/marketplace'
path: '/marketplace'
fullPath: '/marketplace'
preLoaderRoute: typeof MarketplaceRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/chat': {
id: '/chat'
path: '/chat'
fullPath: '/chat'
preLoaderRoute: typeof ChatRouteImport
parentRoute: typeof rootRouteImport
}
'/canvas': {
id: '/canvas'
path: '/canvas'
fullPath: '/canvas'
preLoaderRoute: typeof CanvasRouteImport
parentRoute: typeof rootRouteImport
}
'/blocks': {
id: '/blocks'
path: '/blocks'
fullPath: '/blocks'
preLoaderRoute: typeof BlocksRouteImport
parentRoute: typeof rootRouteImport
}
'/auth': {
id: '/auth'
path: '/auth'
fullPath: '/auth'
preLoaderRoute: typeof AuthRouteImport
parentRoute: typeof rootRouteImport
}
'/$username': {
id: '/$username'
path: '/$username'
fullPath: '/$username'
preLoaderRoute: typeof UsernameRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/canvas/': {
id: '/canvas/'
path: '/'
fullPath: '/canvas/'
preLoaderRoute: typeof CanvasIndexRouteImport
parentRoute: typeof CanvasRoute
}
'/i/1focus-demo': {
id: '/i/1focus-demo'
path: '/i/1focus-demo'
fullPath: '/i/1focus-demo'
preLoaderRoute: typeof I1focusDemoRouteImport
parentRoute: typeof rootRouteImport
}
'/canvas/$canvasId': {
id: '/canvas/$canvasId'
path: '/$canvasId'
fullPath: '/canvas/$canvasId'
preLoaderRoute: typeof CanvasCanvasIdRouteImport
parentRoute: typeof CanvasRoute
}
'/api/users': {
id: '/api/users'
path: '/api/users'
fullPath: '/api/users'
preLoaderRoute: typeof ApiUsersRouteImport
parentRoute: typeof rootRouteImport
}
'/api/usage-events': {
id: '/api/usage-events'
path: '/api/usage-events'
fullPath: '/api/usage-events'
preLoaderRoute: typeof ApiUsageEventsRouteImport
parentRoute: typeof rootRouteImport
}
'/api/stream': {
id: '/api/stream'
path: '/api/stream'
fullPath: '/api/stream'
preLoaderRoute: typeof ApiStreamRouteImport
parentRoute: typeof rootRouteImport
}
'/api/profile': {
id: '/api/profile'
path: '/api/profile'
fullPath: '/api/profile'
preLoaderRoute: typeof ApiProfileRouteImport
parentRoute: typeof rootRouteImport
}
'/api/context-items': {
id: '/api/context-items'
path: '/api/context-items'
fullPath: '/api/context-items'
preLoaderRoute: typeof ApiContextItemsRouteImport
parentRoute: typeof rootRouteImport
}
'/api/chat-threads': {
id: '/api/chat-threads'
path: '/api/chat-threads'
fullPath: '/api/chat-threads'
preLoaderRoute: typeof ApiChatThreadsRouteImport
parentRoute: typeof rootRouteImport
}
'/api/chat-messages': {
id: '/api/chat-messages'
path: '/api/chat-messages'
fullPath: '/api/chat-messages'
preLoaderRoute: typeof ApiChatMessagesRouteImport
parentRoute: typeof rootRouteImport
}
'/api/canvas': {
id: '/api/canvas'
path: '/api/canvas'
fullPath: '/api/canvas'
preLoaderRoute: typeof ApiCanvasRouteImport
parentRoute: typeof rootRouteImport
}
'/api/browser-sessions': {
id: '/api/browser-sessions'
path: '/api/browser-sessions'
fullPath: '/api/browser-sessions'
preLoaderRoute: typeof ApiBrowserSessionsRouteImport
parentRoute: typeof rootRouteImport
}
'/demo/start/server-funcs': {
id: '/demo/start/server-funcs'
path: '/demo/start/server-funcs'
fullPath: '/demo/start/server-funcs'
preLoaderRoute: typeof DemoStartServerFuncsRouteImport
parentRoute: typeof rootRouteImport
}
'/demo/start/api-request': {
id: '/demo/start/api-request'
path: '/demo/start/api-request'
fullPath: '/demo/start/api-request'
preLoaderRoute: typeof DemoStartApiRequestRouteImport
parentRoute: typeof rootRouteImport
}
'/demo/api/names': {
id: '/demo/api/names'
path: '/demo/api/names'
fullPath: '/demo/api/names'
preLoaderRoute: typeof DemoApiNamesRouteImport
parentRoute: typeof rootRouteImport
}
'/api/usage-events/create': {
id: '/api/usage-events/create'
path: '/create'
fullPath: '/api/usage-events/create'
preLoaderRoute: typeof ApiUsageEventsCreateRouteImport
parentRoute: typeof ApiUsageEventsRoute
}
'/api/streams/$username': {
id: '/api/streams/$username'
path: '/api/streams/$username'
fullPath: '/api/streams/$username'
preLoaderRoute: typeof ApiStreamsUsernameRouteImport
parentRoute: typeof rootRouteImport
}
'/api/flowglad/$': {
id: '/api/flowglad/$'
path: '/api/flowglad/$'
fullPath: '/api/flowglad/$'
preLoaderRoute: typeof ApiFlowgladSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/api/chat/mutations': {
id: '/api/chat/mutations'
path: '/api/chat/mutations'
fullPath: '/api/chat/mutations'
preLoaderRoute: typeof ApiChatMutationsRouteImport
parentRoute: typeof rootRouteImport
}
'/api/chat/guest': {
id: '/api/chat/guest'
path: '/api/chat/guest'
fullPath: '/api/chat/guest'
preLoaderRoute: typeof ApiChatGuestRouteImport
parentRoute: typeof rootRouteImport
}
'/api/chat/ai': {
id: '/api/chat/ai'
path: '/api/chat/ai'
fullPath: '/api/chat/ai'
preLoaderRoute: typeof ApiChatAiRouteImport
parentRoute: typeof rootRouteImport
}
'/api/canvas/images': {
id: '/api/canvas/images'
path: '/images'
fullPath: '/api/canvas/images'
preLoaderRoute: typeof ApiCanvasImagesRouteImport
parentRoute: typeof ApiCanvasRoute
}
'/api/canvas/$canvasId': {
id: '/api/canvas/$canvasId'
path: '/$canvasId'
fullPath: '/api/canvas/$canvasId'
preLoaderRoute: typeof ApiCanvasCanvasIdRouteImport
parentRoute: typeof ApiCanvasRoute
}
'/api/browser-sessions/$sessionId': {
id: '/api/browser-sessions/$sessionId'
path: '/$sessionId'
fullPath: '/api/browser-sessions/$sessionId'
preLoaderRoute: typeof ApiBrowserSessionsSessionIdRouteImport
parentRoute: typeof ApiBrowserSessionsRoute
}
'/api/auth/$': {
id: '/api/auth/$'
path: '/api/auth/$'
fullPath: '/api/auth/$'
preLoaderRoute: typeof ApiAuthSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/demo/start/ssr/': {
id: '/demo/start/ssr/'
path: '/demo/start/ssr'
fullPath: '/demo/start/ssr'
preLoaderRoute: typeof DemoStartSsrIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/demo/start/ssr/spa-mode': {
id: '/demo/start/ssr/spa-mode'
path: '/demo/start/ssr/spa-mode'
fullPath: '/demo/start/ssr/spa-mode'
preLoaderRoute: typeof DemoStartSsrSpaModeRouteImport
parentRoute: typeof rootRouteImport
}
'/demo/start/ssr/full-ssr': {
id: '/demo/start/ssr/full-ssr'
path: '/demo/start/ssr/full-ssr'
fullPath: '/demo/start/ssr/full-ssr'
preLoaderRoute: typeof DemoStartSsrFullSsrRouteImport
parentRoute: typeof rootRouteImport
}
'/demo/start/ssr/data-only': {
id: '/demo/start/ssr/data-only'
path: '/demo/start/ssr/data-only'
fullPath: '/demo/start/ssr/data-only'
preLoaderRoute: typeof DemoStartSsrDataOnlyRouteImport
parentRoute: typeof rootRouteImport
}
'/api/canvas/images/$imageId': {
id: '/api/canvas/images/$imageId'
path: '/$imageId'
fullPath: '/api/canvas/images/$imageId'
preLoaderRoute: typeof ApiCanvasImagesImageIdRouteImport
parentRoute: typeof ApiCanvasImagesRoute
}
'/api/canvas/images/$imageId/generate': {
id: '/api/canvas/images/$imageId/generate'
path: '/generate'
fullPath: '/api/canvas/images/$imageId/generate'
preLoaderRoute: typeof ApiCanvasImagesImageIdGenerateRouteImport
parentRoute: typeof ApiCanvasImagesImageIdRoute
}
}
}
interface CanvasRouteChildren {
CanvasCanvasIdRoute: typeof CanvasCanvasIdRoute
CanvasIndexRoute: typeof CanvasIndexRoute
}
const CanvasRouteChildren: CanvasRouteChildren = {
CanvasCanvasIdRoute: CanvasCanvasIdRoute,
CanvasIndexRoute: CanvasIndexRoute,
}
const CanvasRouteWithChildren =
CanvasRoute._addFileChildren(CanvasRouteChildren)
interface ApiBrowserSessionsRouteChildren {
ApiBrowserSessionsSessionIdRoute: typeof ApiBrowserSessionsSessionIdRoute
}
const ApiBrowserSessionsRouteChildren: ApiBrowserSessionsRouteChildren = {
ApiBrowserSessionsSessionIdRoute: ApiBrowserSessionsSessionIdRoute,
}
const ApiBrowserSessionsRouteWithChildren =
ApiBrowserSessionsRoute._addFileChildren(ApiBrowserSessionsRouteChildren)
interface ApiCanvasImagesImageIdRouteChildren {
ApiCanvasImagesImageIdGenerateRoute: typeof ApiCanvasImagesImageIdGenerateRoute
}
const ApiCanvasImagesImageIdRouteChildren: ApiCanvasImagesImageIdRouteChildren =
{
ApiCanvasImagesImageIdGenerateRoute: ApiCanvasImagesImageIdGenerateRoute,
}
const ApiCanvasImagesImageIdRouteWithChildren =
ApiCanvasImagesImageIdRoute._addFileChildren(
ApiCanvasImagesImageIdRouteChildren,
)
interface ApiCanvasImagesRouteChildren {
ApiCanvasImagesImageIdRoute: typeof ApiCanvasImagesImageIdRouteWithChildren
}
const ApiCanvasImagesRouteChildren: ApiCanvasImagesRouteChildren = {
ApiCanvasImagesImageIdRoute: ApiCanvasImagesImageIdRouteWithChildren,
}
const ApiCanvasImagesRouteWithChildren = ApiCanvasImagesRoute._addFileChildren(
ApiCanvasImagesRouteChildren,
)
interface ApiCanvasRouteChildren {
ApiCanvasCanvasIdRoute: typeof ApiCanvasCanvasIdRoute
ApiCanvasImagesRoute: typeof ApiCanvasImagesRouteWithChildren
}
const ApiCanvasRouteChildren: ApiCanvasRouteChildren = {
ApiCanvasCanvasIdRoute: ApiCanvasCanvasIdRoute,
ApiCanvasImagesRoute: ApiCanvasImagesRouteWithChildren,
}
const ApiCanvasRouteWithChildren = ApiCanvasRoute._addFileChildren(
ApiCanvasRouteChildren,
)
interface ApiUsageEventsRouteChildren {
ApiUsageEventsCreateRoute: typeof ApiUsageEventsCreateRoute
}
const ApiUsageEventsRouteChildren: ApiUsageEventsRouteChildren = {
ApiUsageEventsCreateRoute: ApiUsageEventsCreateRoute,
}
const ApiUsageEventsRouteWithChildren = ApiUsageEventsRoute._addFileChildren(
ApiUsageEventsRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
UsernameRoute: UsernameRoute,
AuthRoute: AuthRoute,
BlocksRoute: BlocksRoute,
CanvasRoute: CanvasRouteWithChildren,
ChatRoute: ChatRoute,
LoginRoute: LoginRoute,
MarketplaceRoute: MarketplaceRoute,
SessionsRoute: SessionsRoute,
SettingsRoute: SettingsRoute,
UsersRoute: UsersRoute,
ApiBrowserSessionsRoute: ApiBrowserSessionsRouteWithChildren,
ApiCanvasRoute: ApiCanvasRouteWithChildren,
ApiChatMessagesRoute: ApiChatMessagesRoute,
ApiChatThreadsRoute: ApiChatThreadsRoute,
ApiContextItemsRoute: ApiContextItemsRoute,
ApiProfileRoute: ApiProfileRoute,
ApiStreamRoute: ApiStreamRoute,
ApiUsageEventsRoute: ApiUsageEventsRouteWithChildren,
ApiUsersRoute: ApiUsersRoute,
I1focusDemoRoute: I1focusDemoRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiChatAiRoute: ApiChatAiRoute,
ApiChatGuestRoute: ApiChatGuestRoute,
ApiChatMutationsRoute: ApiChatMutationsRoute,
ApiFlowgladSplatRoute: ApiFlowgladSplatRoute,
ApiStreamsUsernameRoute: ApiStreamsUsernameRoute,
DemoApiNamesRoute: DemoApiNamesRoute,
DemoStartApiRequestRoute: DemoStartApiRequestRoute,
DemoStartServerFuncsRoute: DemoStartServerFuncsRoute,
DemoStartSsrDataOnlyRoute: DemoStartSsrDataOnlyRoute,
DemoStartSsrFullSsrRoute: DemoStartSsrFullSsrRoute,
DemoStartSsrSpaModeRoute: DemoStartSsrSpaModeRoute,
DemoStartSsrIndexRoute: DemoStartSsrIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}

View File

@@ -0,0 +1,16 @@
import { createRouter } from "@tanstack/react-router"
import { routeTree } from "./routeTree.gen"
import "./styles.css"
export const getRouter = () =>
createRouter({
routeTree,
defaultPreload: "viewport",
scrollRestoration: true,
})
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More