mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
.
This commit is contained in:
28
packages/web/src/components/BillingProvider.tsx
Normal file
28
packages/web/src/components/BillingProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
721
packages/web/src/components/BlockLayout.tsx
Normal file
721
packages/web/src/components/BlockLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
556
packages/web/src/components/Context-panel.tsx
Normal file
556
packages/web/src/components/Context-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
283
packages/web/src/components/Header.tsx
Normal file
283
packages/web/src/components/Header.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
112
packages/web/src/components/Settings-panel.tsx
Normal file
112
packages/web/src/components/Settings-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
493
packages/web/src/components/ShaderBackground.tsx
Normal file
493
packages/web/src/components/ShaderBackground.tsx
Normal 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" }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
259
packages/web/src/components/VideoPlayer.tsx
Normal file
259
packages/web/src/components/VideoPlayer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
packages/web/src/components/billing/BillingStatus.tsx
Normal file
47
packages/web/src/components/billing/BillingStatus.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
packages/web/src/components/billing/BillingStatusNew.tsx
Normal file
26
packages/web/src/components/billing/BillingStatusNew.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
packages/web/src/components/billing/UpgradeButton.tsx
Normal file
51
packages/web/src/components/billing/UpgradeButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
packages/web/src/components/billing/UpgradeButtonNew.tsx
Normal file
46
packages/web/src/components/billing/UpgradeButtonNew.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
packages/web/src/components/billing/UsageDisplay.tsx
Normal file
39
packages/web/src/components/billing/UsageDisplay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
packages/web/src/components/billing/UsageDisplayNew.tsx
Normal file
48
packages/web/src/components/billing/UsageDisplayNew.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
175
packages/web/src/components/billing/UsageSubmissionForm.tsx
Normal file
175
packages/web/src/components/billing/UsageSubmissionForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
7
packages/web/src/components/billing/index.ts
Normal file
7
packages/web/src/components/billing/index.ts
Normal 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"
|
||||
5
packages/web/src/components/blocks/BlockPage.tsx
Normal file
5
packages/web/src/components/blocks/BlockPage.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { MyBlocksView } from "../BlockLayout"
|
||||
|
||||
export default function BlockPage() {
|
||||
return <MyBlocksView />
|
||||
}
|
||||
5
packages/web/src/components/blocks/MarketplacePage.tsx
Normal file
5
packages/web/src/components/blocks/MarketplacePage.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { MarketplaceView } from "../BlockLayout"
|
||||
|
||||
export default function MarketplacePage() {
|
||||
return <MarketplaceView />
|
||||
}
|
||||
279
packages/web/src/components/canvas/CanvasBoard.tsx
Normal file
279
packages/web/src/components/canvas/CanvasBoard.tsx
Normal 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 },
|
||||
}
|
||||
}
|
||||
286
packages/web/src/components/canvas/CanvasExperience.tsx
Normal file
286
packages/web/src/components/canvas/CanvasExperience.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
packages/web/src/components/canvas/CanvasToolbar.tsx
Normal file
54
packages/web/src/components/canvas/CanvasToolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
108
packages/web/src/components/canvas/PromptPanel.tsx
Normal file
108
packages/web/src/components/canvas/PromptPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
10
packages/web/src/components/canvas/types.ts
Normal file
10
packages/web/src/components/canvas/types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type {
|
||||
SerializedCanvasImage,
|
||||
SerializedCanvasRecord,
|
||||
} from "@/lib/canvas/types"
|
||||
|
||||
export type CanvasBox = SerializedCanvasImage & {
|
||||
isGenerating?: boolean
|
||||
}
|
||||
|
||||
export type CanvasSnapshot = SerializedCanvasRecord
|
||||
282
packages/web/src/components/chat/ChatInput.tsx
Normal file
282
packages/web/src/components/chat/ChatInput.tsx
Normal 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 }
|
||||
45
packages/web/src/components/chat/ChatMessages.tsx
Normal file
45
packages/web/src/components/chat/ChatMessages.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
618
packages/web/src/components/chat/ChatPage.tsx
Normal file
618
packages/web/src/components/chat/ChatPage.tsx
Normal 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 />
|
||||
}
|
||||
215
packages/web/src/components/chat/MessageBubble.tsx
Normal file
215
packages/web/src/components/chat/MessageBubble.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
packages/web/src/components/flowglad/regularPlanButton.tsx
Normal file
29
packages/web/src/components/flowglad/regularPlanButton.tsx
Normal 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>
|
||||
}
|
||||
13
packages/web/src/data/demo.punk-songs.ts
Normal file
13
packages/web/src/data/demo.punk-songs.ts
Normal 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" },
|
||||
])
|
||||
75
packages/web/src/db/connection.ts
Normal file
75
packages/web/src/db/connection.ts
Normal 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 })
|
||||
}
|
||||
283
packages/web/src/db/schema.ts
Normal file
283
packages/web/src/db/schema.ts
Normal 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
29
packages/web/src/env.d.ts
vendored
Normal 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
|
||||
}
|
||||
612
packages/web/src/features/canvas/BladeCanvasExperience.tsx
Normal file
612
packages/web/src/features/canvas/BladeCanvasExperience.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
893
packages/web/src/features/canvas/components/Canvas.tsx
Normal file
893
packages/web/src/features/canvas/components/Canvas.tsx
Normal 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 })
|
||||
}
|
||||
118
packages/web/src/features/canvas/components/Models.tsx
Normal file
118
packages/web/src/features/canvas/components/Models.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
257
packages/web/src/features/canvas/components/Onboarding.tsx
Normal file
257
packages/web/src/features/canvas/components/Onboarding.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
157
packages/web/src/features/canvas/components/Overlay.tsx
Normal file
157
packages/web/src/features/canvas/components/Overlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
251
packages/web/src/features/canvas/components/Prompt.tsx
Normal file
251
packages/web/src/features/canvas/components/Prompt.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
packages/web/src/features/canvas/components/Styles.tsx
Normal file
69
packages/web/src/features/canvas/components/Styles.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
39
packages/web/src/features/canvas/config.ts
Normal file
39
packages/web/src/features/canvas/config.ts
Normal 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
|
||||
}
|
||||
367
packages/web/src/features/canvas/store/canvasStore.tsx
Normal file
367
packages/web/src/features/canvas/store/canvasStore.tsx
Normal 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 }
|
||||
39
packages/web/src/features/canvas/styles-presets.ts
Normal file
39
packages/web/src/features/canvas/styles-presets.ts
Normal 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.",
|
||||
},
|
||||
]
|
||||
73
packages/web/src/hooks/useGuestUsage.ts
Normal file
73
packages/web/src/hooks/useGuestUsage.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
104
packages/web/src/lib/ai/gemini-image.ts
Normal file
104
packages/web/src/lib/ai/gemini-image.ts
Normal 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.")
|
||||
}
|
||||
77
packages/web/src/lib/ai/openai-image.ts
Normal file
77
packages/web/src/lib/ai/openai-image.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
39
packages/web/src/lib/ai/provider.ts
Normal file
39
packages/web/src/lib/ai/provider.ts
Normal 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()
|
||||
7
packages/web/src/lib/auth-client.ts
Normal file
7
packages/web/src/lib/auth-client.ts
Normal 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()],
|
||||
})
|
||||
145
packages/web/src/lib/auth.ts
Normal file
145
packages/web/src/lib/auth.ts
Normal 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>]
|
||||
},
|
||||
})
|
||||
109
packages/web/src/lib/billing-helpers.ts
Normal file
109
packages/web/src/lib/billing-helpers.ts
Normal 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
|
||||
}
|
||||
236
packages/web/src/lib/billing.ts
Normal file
236
packages/web/src/lib/billing.ts
Normal 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
115
packages/web/src/lib/canvas/client.ts
Normal file
115
packages/web/src/lib/canvas/client.ts
Normal 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
|
||||
}
|
||||
364
packages/web/src/lib/canvas/db.ts
Normal file
364
packages/web/src/lib/canvas/db.ts
Normal 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
|
||||
}
|
||||
53
packages/web/src/lib/canvas/types.ts
Normal file
53
packages/web/src/lib/canvas/types.ts
Normal 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
|
||||
}
|
||||
85
packages/web/src/lib/canvas/user-session.ts
Normal file
85
packages/web/src/lib/canvas/user-session.ts
Normal 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)
|
||||
}
|
||||
107
packages/web/src/lib/collections.ts
Normal file
107
packages/web/src/lib/collections.ts
Normal 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()
|
||||
},
|
||||
}
|
||||
103
packages/web/src/lib/electric-proxy.ts
Normal file
103
packages/web/src/lib/electric-proxy.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
73
packages/web/src/lib/flowglad.ts
Normal file
73
packages/web/src/lib/flowglad.ts
Normal 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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
36
packages/web/src/lib/stream/db.ts
Normal file
36
packages/web/src/lib/stream/db.ts
Normal 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()
|
||||
}
|
||||
49
packages/web/src/lib/worker-rpc.ts
Normal file
49
packages/web/src/lib/worker-rpc.ts
Normal 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
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 |
993
packages/web/src/routeTree.gen.ts
Normal file
993
packages/web/src/routeTree.gen.ts
Normal 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>>
|
||||
}
|
||||
}
|
||||
16
packages/web/src/router.tsx
Normal file
16
packages/web/src/router.tsx
Normal 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>
|
||||
}
|
||||
}
|
||||
137
packages/web/src/routes/$username.tsx
Normal file
137
packages/web/src/routes/$username.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { getStreamByUsername, type StreamPageData } from "@/lib/stream/db"
|
||||
import { VideoPlayer } from "@/components/VideoPlayer"
|
||||
|
||||
export const Route = createFileRoute("/$username")({
|
||||
ssr: false,
|
||||
component: StreamPage,
|
||||
})
|
||||
|
||||
// Cloudflare Stream HLS URL
|
||||
const HLS_URL = "https://customer-xctsztqzu046isdc.cloudflarestream.com/bb7858eafc85de6c92963f3817477b5d/manifest/video.m3u8"
|
||||
|
||||
// Hardcoded user for nikiv
|
||||
const NIKIV_DATA: StreamPageData = {
|
||||
user: {
|
||||
id: "nikiv",
|
||||
name: "Nikita",
|
||||
username: "nikiv",
|
||||
image: null,
|
||||
},
|
||||
stream: {
|
||||
id: "nikiv-stream",
|
||||
title: "Live Coding",
|
||||
description: "Building in public",
|
||||
is_live: true,
|
||||
viewer_count: 0,
|
||||
hls_url: HLS_URL,
|
||||
thumbnail_url: null,
|
||||
started_at: null,
|
||||
},
|
||||
}
|
||||
|
||||
function StreamPage() {
|
||||
const { username } = Route.useParams()
|
||||
const [data, setData] = useState<StreamPageData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [streamReady, setStreamReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Special handling for nikiv - hardcoded stream
|
||||
if (username === "nikiv") {
|
||||
setData(NIKIV_DATA)
|
||||
setLoading(false)
|
||||
// Check if stream is actually live
|
||||
fetch(HLS_URL)
|
||||
.then((res) => setStreamReady(res.ok))
|
||||
.catch(() => setStreamReady(false))
|
||||
return
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await getStreamByUsername(username)
|
||||
setData(result)
|
||||
if (result?.stream?.hls_url) {
|
||||
const res = await fetch(result.stream.hls_url)
|
||||
setStreamReady(res.ok)
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to load stream")
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
}, [username])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-black text-white">
|
||||
<div className="text-xl">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-black text-white">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold">Error</h1>
|
||||
<p className="mt-2 text-neutral-400">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-black text-white">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold">User not found</h1>
|
||||
<p className="mt-2 text-neutral-400">
|
||||
This username doesn't exist or hasn't set up streaming.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { user, stream } = data
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-black">
|
||||
{stream?.is_live && stream.hls_url && streamReady ? (
|
||||
<VideoPlayer src={stream.hls_url} muted={false} />
|
||||
) : stream?.is_live && stream.hls_url ? (
|
||||
<div className="flex h-full w-full items-center justify-center text-white">
|
||||
<div className="text-center">
|
||||
<div className="animate-pulse text-4xl">🔴</div>
|
||||
<p className="mt-4 text-xl text-neutral-400">
|
||||
Connecting to stream...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-white">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-medium">Streaming soon</p>
|
||||
<a
|
||||
href="https://nikiv.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-4 inline-block text-lg text-neutral-400 underline hover:text-white transition-colors"
|
||||
>
|
||||
nikiv.dev
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
103
packages/web/src/routes/__root.tsx
Normal file
103
packages/web/src/routes/__root.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
Outlet,
|
||||
HeadContent,
|
||||
Scripts,
|
||||
createRootRoute,
|
||||
Link,
|
||||
} from "@tanstack/react-router"
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
|
||||
import { BillingProvider } from "@/components/BillingProvider"
|
||||
|
||||
import appCss from "../styles.css?url"
|
||||
|
||||
const SITE_URL = "https://linsa.io"
|
||||
const SITE_NAME = "Linsa"
|
||||
const SITE_TITLE = "Linsa – Save anything privately. Share it."
|
||||
const SITE_DESCRIPTION = "Save anything privately. Share it."
|
||||
|
||||
function DevtoolsToggle() {
|
||||
const [show, setShow] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Use Ctrl+Shift+D to avoid conflicts with browser shortcuts
|
||||
if (e.ctrlKey && e.shiftKey && e.key === "D") {
|
||||
e.preventDefault()
|
||||
setShow((prev) => !prev)
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [])
|
||||
|
||||
if (!show) return null
|
||||
return <TanStackRouterDevtools />
|
||||
}
|
||||
|
||||
function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold text-slate-900 mb-4">404</h1>
|
||||
<p className="text-slate-600 mb-4">Page not found</p>
|
||||
<Link to="/" className="text-slate-900 underline hover:no-underline">
|
||||
Go home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ charSet: "utf-8" },
|
||||
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
||||
{ title: SITE_TITLE },
|
||||
{ name: "description", content: SITE_DESCRIPTION },
|
||||
{
|
||||
name: "keywords",
|
||||
content: "save, bookmarks, private, share, organize",
|
||||
},
|
||||
{ name: "author", content: SITE_NAME },
|
||||
{ name: "theme-color", content: "#03050a" },
|
||||
{ property: "og:type", content: "website" },
|
||||
{ property: "og:url", content: SITE_URL },
|
||||
{ property: "og:title", content: SITE_TITLE },
|
||||
{ property: "og:description", content: SITE_DESCRIPTION },
|
||||
{ property: "og:site_name", content: SITE_NAME },
|
||||
{ name: "twitter:card", content: "summary" },
|
||||
{ name: "twitter:title", content: SITE_TITLE },
|
||||
{ name: "twitter:description", content: SITE_DESCRIPTION },
|
||||
{ name: "twitter:creator", content: "@linaborisova" },
|
||||
],
|
||||
links: [
|
||||
{ rel: "canonical", href: SITE_URL },
|
||||
{ rel: "icon", href: "/favicon.ico" },
|
||||
{ rel: "stylesheet", href: appCss },
|
||||
],
|
||||
}),
|
||||
shellComponent: RootDocument,
|
||||
notFoundComponent: NotFound,
|
||||
component: () => (
|
||||
<BillingProvider>
|
||||
<Outlet />
|
||||
<DevtoolsToggle />
|
||||
</BillingProvider>
|
||||
),
|
||||
})
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
67
packages/web/src/routes/api/auth/$.ts
Normal file
67
packages/web/src/routes/api/auth/$.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { getAuth } from "@/lib/auth"
|
||||
|
||||
export const Route = createFileRoute("/api/auth/$")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
console.log("[api/auth] GET request:", request.url)
|
||||
try {
|
||||
const auth = getAuth()
|
||||
console.log("[api/auth] Auth instance created")
|
||||
const response = await auth.handler(request)
|
||||
console.log("[api/auth] Response status:", response.status)
|
||||
// Log response body for debugging
|
||||
if (response.status >= 400) {
|
||||
const cloned = response.clone()
|
||||
const body = await cloned.text()
|
||||
console.log("[api/auth] Error response body:", body)
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error("[api/auth] GET error:", error)
|
||||
console.error("[api/auth] GET error stack:", error instanceof Error ? error.stack : "no stack")
|
||||
return new Response(JSON.stringify({ error: String(error) }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
}
|
||||
},
|
||||
POST: async ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
console.log("[api/auth] POST request:", url.pathname)
|
||||
|
||||
// Clone request to read body for logging
|
||||
const clonedReq = request.clone()
|
||||
try {
|
||||
const bodyText = await clonedReq.text()
|
||||
console.log("[api/auth] POST body:", bodyText)
|
||||
} catch {
|
||||
console.log("[api/auth] Could not read body")
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = getAuth()
|
||||
console.log("[api/auth] Auth instance created, calling handler...")
|
||||
const response = await auth.handler(request)
|
||||
console.log("[api/auth] Response status:", response.status)
|
||||
|
||||
// Log response body for debugging
|
||||
if (response.status >= 400) {
|
||||
const cloned = response.clone()
|
||||
const body = await cloned.text()
|
||||
console.log("[api/auth] Error response body:", body)
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error("[api/auth] POST error:", error)
|
||||
console.error("[api/auth] POST error stack:", error instanceof Error ? error.stack : "no stack")
|
||||
return new Response(JSON.stringify({ error: String(error) }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
141
packages/web/src/routes/api/browser-sessions.$sessionId.ts
Normal file
141
packages/web/src/routes/api/browser-sessions.$sessionId.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { getAuth } from "@/lib/auth"
|
||||
import { db } from "@/db/connection"
|
||||
import { browser_sessions, browser_session_tabs } from "@/db/schema"
|
||||
import { eq, and } from "drizzle-orm"
|
||||
|
||||
const jsonResponse = (data: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/api/browser-sessions/$sessionId")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({
|
||||
request,
|
||||
params,
|
||||
}: {
|
||||
request: Request
|
||||
params: { sessionId: string }
|
||||
}) => {
|
||||
const session = await getAuth().api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
if (!session?.user?.id) {
|
||||
return jsonResponse({ error: "Unauthorized" }, 401)
|
||||
}
|
||||
|
||||
const { sessionId } = params
|
||||
|
||||
// Get session
|
||||
const [browserSession] = await db()
|
||||
.select()
|
||||
.from(browser_sessions)
|
||||
.where(
|
||||
and(
|
||||
eq(browser_sessions.id, sessionId),
|
||||
eq(browser_sessions.user_id, session.user.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!browserSession) {
|
||||
return jsonResponse({ error: "Session not found" }, 404)
|
||||
}
|
||||
|
||||
// Get tabs
|
||||
const tabs = await db()
|
||||
.select()
|
||||
.from(browser_session_tabs)
|
||||
.where(eq(browser_session_tabs.session_id, sessionId))
|
||||
.orderBy(browser_session_tabs.position)
|
||||
|
||||
return jsonResponse({ session: browserSession, tabs })
|
||||
},
|
||||
|
||||
PATCH: async ({
|
||||
request,
|
||||
params,
|
||||
}: {
|
||||
request: Request
|
||||
params: { sessionId: string }
|
||||
}) => {
|
||||
const session = await getAuth().api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
if (!session?.user?.id) {
|
||||
return jsonResponse({ error: "Unauthorized" }, 401)
|
||||
}
|
||||
|
||||
const { sessionId } = params
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
name?: string
|
||||
is_favorite?: boolean
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
const [existing] = await db()
|
||||
.select()
|
||||
.from(browser_sessions)
|
||||
.where(
|
||||
and(
|
||||
eq(browser_sessions.id, sessionId),
|
||||
eq(browser_sessions.user_id, session.user.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existing) {
|
||||
return jsonResponse({ error: "Session not found" }, 404)
|
||||
}
|
||||
|
||||
// Build update
|
||||
const updates: Partial<{ name: string; is_favorite: boolean }> = {}
|
||||
if (body.name !== undefined) updates.name = body.name
|
||||
if (body.is_favorite !== undefined) updates.is_favorite = body.is_favorite
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return jsonResponse({ error: "No updates provided" }, 400)
|
||||
}
|
||||
|
||||
const [updated] = await db()
|
||||
.update(browser_sessions)
|
||||
.set(updates)
|
||||
.where(eq(browser_sessions.id, sessionId))
|
||||
.returning()
|
||||
|
||||
return jsonResponse({ session: updated })
|
||||
},
|
||||
|
||||
DELETE: async ({
|
||||
request,
|
||||
params,
|
||||
}: {
|
||||
request: Request
|
||||
params: { sessionId: string }
|
||||
}) => {
|
||||
const session = await getAuth().api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
if (!session?.user?.id) {
|
||||
return jsonResponse({ error: "Unauthorized" }, 401)
|
||||
}
|
||||
|
||||
const { sessionId } = params
|
||||
|
||||
await db()
|
||||
.delete(browser_sessions)
|
||||
.where(
|
||||
and(
|
||||
eq(browser_sessions.id, sessionId),
|
||||
eq(browser_sessions.user_id, session.user.id),
|
||||
),
|
||||
)
|
||||
|
||||
return jsonResponse({ success: true })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
364
packages/web/src/routes/api/browser-sessions.ts
Normal file
364
packages/web/src/routes/api/browser-sessions.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { getAuth } from "@/lib/auth"
|
||||
import { db } from "@/db/connection"
|
||||
import { browser_sessions, browser_session_tabs } from "@/db/schema"
|
||||
import { eq, and, desc, ilike, or, sql } from "drizzle-orm"
|
||||
|
||||
interface TabInput {
|
||||
title: string
|
||||
url: string
|
||||
favicon_url?: string
|
||||
}
|
||||
|
||||
interface SaveSessionBody {
|
||||
action: "save"
|
||||
name: string
|
||||
browser?: string
|
||||
tabs: TabInput[]
|
||||
captured_at?: string // ISO date string
|
||||
}
|
||||
|
||||
interface ListSessionsBody {
|
||||
action: "list"
|
||||
page?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
}
|
||||
|
||||
interface GetSessionBody {
|
||||
action: "get"
|
||||
session_id: string
|
||||
}
|
||||
|
||||
interface UpdateSessionBody {
|
||||
action: "update"
|
||||
session_id: string
|
||||
name?: string
|
||||
is_favorite?: boolean
|
||||
}
|
||||
|
||||
interface DeleteSessionBody {
|
||||
action: "delete"
|
||||
session_id: string
|
||||
}
|
||||
|
||||
interface SearchTabsBody {
|
||||
action: "searchTabs"
|
||||
query: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
type RequestBody =
|
||||
| SaveSessionBody
|
||||
| ListSessionsBody
|
||||
| GetSessionBody
|
||||
| UpdateSessionBody
|
||||
| DeleteSessionBody
|
||||
| SearchTabsBody
|
||||
|
||||
const jsonResponse = (data: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/api/browser-sessions")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }: { request: Request }) => {
|
||||
const session = await getAuth().api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
if (!session?.user?.id) {
|
||||
return jsonResponse({ error: "Unauthorized" }, 401)
|
||||
}
|
||||
|
||||
const database = db()
|
||||
const body = (await request.json().catch(() => ({}))) as RequestBody
|
||||
|
||||
try {
|
||||
switch (body.action) {
|
||||
case "save": {
|
||||
const { name, browser = "safari", tabs, captured_at } = body
|
||||
|
||||
if (!name || !tabs || !Array.isArray(tabs)) {
|
||||
return jsonResponse({ error: "Missing name or tabs" }, 400)
|
||||
}
|
||||
|
||||
// Create session
|
||||
const [newSession] = await database
|
||||
.insert(browser_sessions)
|
||||
.values({
|
||||
user_id: session.user.id,
|
||||
name,
|
||||
browser,
|
||||
tab_count: tabs.length,
|
||||
captured_at: captured_at ? new Date(captured_at) : new Date(),
|
||||
})
|
||||
.returning()
|
||||
|
||||
// Insert tabs
|
||||
if (tabs.length > 0) {
|
||||
await database.insert(browser_session_tabs).values(
|
||||
tabs.map((tab, index) => ({
|
||||
session_id: newSession.id,
|
||||
title: tab.title || "",
|
||||
url: tab.url,
|
||||
position: index,
|
||||
favicon_url: tab.favicon_url,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
return jsonResponse({ session: newSession })
|
||||
}
|
||||
|
||||
case "list": {
|
||||
const page = Math.max(1, body.page || 1)
|
||||
const limit = Math.min(100, Math.max(1, body.limit || 50))
|
||||
const offset = (page - 1) * limit
|
||||
const search = body.search?.trim()
|
||||
|
||||
// Build query
|
||||
let query = database
|
||||
.select()
|
||||
.from(browser_sessions)
|
||||
.where(eq(browser_sessions.user_id, session.user.id))
|
||||
.orderBy(desc(browser_sessions.captured_at))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
if (search) {
|
||||
query = database
|
||||
.select()
|
||||
.from(browser_sessions)
|
||||
.where(
|
||||
and(
|
||||
eq(browser_sessions.user_id, session.user.id),
|
||||
ilike(browser_sessions.name, `%${search}%`),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(browser_sessions.captured_at))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
}
|
||||
|
||||
const sessions = await query
|
||||
|
||||
// Get total count
|
||||
const [countResult] = await database
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(browser_sessions)
|
||||
.where(
|
||||
search
|
||||
? and(
|
||||
eq(browser_sessions.user_id, session.user.id),
|
||||
ilike(browser_sessions.name, `%${search}%`),
|
||||
)
|
||||
: eq(browser_sessions.user_id, session.user.id),
|
||||
)
|
||||
|
||||
const total = Number(countResult?.count || 0)
|
||||
|
||||
return jsonResponse({
|
||||
sessions,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
case "get": {
|
||||
const { session_id } = body
|
||||
|
||||
if (!session_id) {
|
||||
return jsonResponse({ error: "Missing session_id" }, 400)
|
||||
}
|
||||
|
||||
// Get session
|
||||
const [browserSession] = await database
|
||||
.select()
|
||||
.from(browser_sessions)
|
||||
.where(
|
||||
and(
|
||||
eq(browser_sessions.id, session_id),
|
||||
eq(browser_sessions.user_id, session.user.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!browserSession) {
|
||||
return jsonResponse({ error: "Session not found" }, 404)
|
||||
}
|
||||
|
||||
// Get tabs
|
||||
const tabs = await database
|
||||
.select()
|
||||
.from(browser_session_tabs)
|
||||
.where(eq(browser_session_tabs.session_id, session_id))
|
||||
.orderBy(browser_session_tabs.position)
|
||||
|
||||
return jsonResponse({ session: browserSession, tabs })
|
||||
}
|
||||
|
||||
case "update": {
|
||||
const { session_id, name, is_favorite } = body
|
||||
|
||||
if (!session_id) {
|
||||
return jsonResponse({ error: "Missing session_id" }, 400)
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
const [existing] = await database
|
||||
.select()
|
||||
.from(browser_sessions)
|
||||
.where(
|
||||
and(
|
||||
eq(browser_sessions.id, session_id),
|
||||
eq(browser_sessions.user_id, session.user.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!existing) {
|
||||
return jsonResponse({ error: "Session not found" }, 404)
|
||||
}
|
||||
|
||||
// Build update
|
||||
const updates: Partial<{
|
||||
name: string
|
||||
is_favorite: boolean
|
||||
}> = {}
|
||||
if (name !== undefined) updates.name = name
|
||||
if (is_favorite !== undefined) updates.is_favorite = is_favorite
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return jsonResponse({ error: "No updates provided" }, 400)
|
||||
}
|
||||
|
||||
const [updated] = await database
|
||||
.update(browser_sessions)
|
||||
.set(updates)
|
||||
.where(eq(browser_sessions.id, session_id))
|
||||
.returning()
|
||||
|
||||
return jsonResponse({ session: updated })
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
const { session_id } = body
|
||||
|
||||
if (!session_id) {
|
||||
return jsonResponse({ error: "Missing session_id" }, 400)
|
||||
}
|
||||
|
||||
// Delete (cascade will handle tabs)
|
||||
await database
|
||||
.delete(browser_sessions)
|
||||
.where(
|
||||
and(
|
||||
eq(browser_sessions.id, session_id),
|
||||
eq(browser_sessions.user_id, session.user.id),
|
||||
),
|
||||
)
|
||||
|
||||
return jsonResponse({ success: true })
|
||||
}
|
||||
|
||||
case "searchTabs": {
|
||||
const { query, limit = 100 } = body
|
||||
|
||||
if (!query?.trim()) {
|
||||
return jsonResponse({ error: "Missing query" }, 400)
|
||||
}
|
||||
|
||||
const searchTerm = `%${query.trim()}%`
|
||||
|
||||
// Search tabs across user's sessions
|
||||
const tabs = await database
|
||||
.select({
|
||||
tab: browser_session_tabs,
|
||||
session: browser_sessions,
|
||||
})
|
||||
.from(browser_session_tabs)
|
||||
.innerJoin(
|
||||
browser_sessions,
|
||||
eq(browser_session_tabs.session_id, browser_sessions.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(browser_sessions.user_id, session.user.id),
|
||||
or(
|
||||
ilike(browser_session_tabs.title, searchTerm),
|
||||
ilike(browser_session_tabs.url, searchTerm),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(browser_sessions.captured_at))
|
||||
.limit(Math.min(limit, 500))
|
||||
|
||||
return jsonResponse({
|
||||
results: tabs.map((t) => ({
|
||||
...t.tab,
|
||||
session_name: t.session.name,
|
||||
session_captured_at: t.session.captured_at,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
default:
|
||||
return jsonResponse({ error: "Unknown action" }, 400)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[browser-sessions] error", error)
|
||||
return jsonResponse({ error: "Operation failed" }, 500)
|
||||
}
|
||||
},
|
||||
|
||||
GET: async ({ request }: { request: Request }) => {
|
||||
const session = await getAuth().api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
if (!session?.user?.id) {
|
||||
return jsonResponse({ error: "Unauthorized" }, 401)
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const page = Math.max(1, parseInt(url.searchParams.get("page") || "1"))
|
||||
const limit = Math.min(
|
||||
100,
|
||||
Math.max(1, parseInt(url.searchParams.get("limit") || "50")),
|
||||
)
|
||||
const offset = (page - 1) * limit
|
||||
|
||||
const sessions = await db()
|
||||
.select()
|
||||
.from(browser_sessions)
|
||||
.where(eq(browser_sessions.user_id, session.user.id))
|
||||
.orderBy(desc(browser_sessions.captured_at))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
const [countResult] = await db()
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(browser_sessions)
|
||||
.where(eq(browser_sessions.user_id, session.user.id))
|
||||
|
||||
const total = Number(countResult?.count || 0)
|
||||
|
||||
return jsonResponse({
|
||||
sessions,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
46
packages/web/src/routes/api/canvas.$canvasId.ts
Normal file
46
packages/web/src/routes/api/canvas.$canvasId.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import {
|
||||
getCanvasOwner,
|
||||
getCanvasSnapshotById,
|
||||
} from "@/lib/canvas/db"
|
||||
import { resolveCanvasUser } from "@/lib/canvas/user-session"
|
||||
|
||||
const json = (data: unknown, status = 200, setCookie?: string) => {
|
||||
const headers = new Headers({ "content-type": "application/json" })
|
||||
if (setCookie) {
|
||||
headers.set("set-cookie", setCookie)
|
||||
}
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/canvas/$canvasId")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request, params }) => {
|
||||
try {
|
||||
const { userId, setCookie } = await resolveCanvasUser(request)
|
||||
const canvasId = params.canvasId
|
||||
|
||||
const owner = await getCanvasOwner(canvasId)
|
||||
if (!owner || owner.ownerId !== userId) {
|
||||
return json({ error: "Forbidden" }, 403, setCookie)
|
||||
}
|
||||
|
||||
const snapshot = await getCanvasSnapshotById(canvasId)
|
||||
if (!snapshot) {
|
||||
return json({ error: "Not found" }, 404, setCookie)
|
||||
}
|
||||
|
||||
return json(snapshot, 200, setCookie)
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error
|
||||
console.error("[api/canvas/:canvasId] GET", error)
|
||||
return json({ error: "Failed to load canvas" }, 500)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
178
packages/web/src/routes/api/canvas.images.$imageId.generate.ts
Normal file
178
packages/web/src/routes/api/canvas.images.$imageId.generate.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import {
|
||||
getCanvasImageRecord,
|
||||
getCanvasOwner,
|
||||
updateCanvasImage,
|
||||
} from "@/lib/canvas/db"
|
||||
import { generateGeminiImage, DEFAULT_GEMINI_IMAGE_MODEL } from "@/lib/ai/gemini-image"
|
||||
import { generateOpenAIImage } from "@/lib/ai/openai-image"
|
||||
import { resolveCanvasUser } from "@/lib/canvas/user-session"
|
||||
import { STYLE_PRESETS } from "@/features/canvas/styles-presets"
|
||||
import { checkUsageAllowed, recordUsage } from "@/lib/billing"
|
||||
|
||||
const json = (data: unknown, status = 200, setCookie?: string) => {
|
||||
const headers = new Headers({ "content-type": "application/json" })
|
||||
if (setCookie) {
|
||||
headers.set("set-cookie", setCookie)
|
||||
}
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
const applyStylePrompt = (styleId: string | null | undefined, prompt: string) => {
|
||||
if (!styleId || styleId === "default") {
|
||||
return { resolvedStyleId: "default", prompt: prompt.trim() }
|
||||
}
|
||||
const preset = STYLE_PRESETS.find((item) => item.id === styleId)
|
||||
if (!preset || preset.id === "default") {
|
||||
return { resolvedStyleId: preset?.id ?? "default", prompt: prompt.trim() }
|
||||
}
|
||||
const stylePrompt = preset.prompt.trim()
|
||||
const basePrompt = prompt.trim()
|
||||
const combined = stylePrompt ? `${stylePrompt}\n\n${basePrompt}` : basePrompt
|
||||
return { resolvedStyleId: preset.id, prompt: combined }
|
||||
}
|
||||
|
||||
const normalizeGeminiModelId = (modelId?: string | null) => {
|
||||
if (!modelId) return DEFAULT_GEMINI_IMAGE_MODEL
|
||||
if (
|
||||
modelId.includes("gemini-2.0-flash-exp-image-generation") ||
|
||||
modelId === "gemini-1.5-flash" ||
|
||||
modelId === "gemini-1.5-flash-latest"
|
||||
) {
|
||||
return DEFAULT_GEMINI_IMAGE_MODEL
|
||||
}
|
||||
return modelId
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/canvas/images/$imageId/generate")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request, params }) => {
|
||||
try {
|
||||
const { userId, setCookie } = await resolveCanvasUser(request)
|
||||
const imageId = params.imageId
|
||||
const record = await getCanvasImageRecord(imageId)
|
||||
if (!record) {
|
||||
return json({ error: "Not found" }, 404, setCookie)
|
||||
}
|
||||
|
||||
const owner = await getCanvasOwner(record.canvas_id)
|
||||
if (!owner || owner.ownerId !== userId) {
|
||||
return json({ error: "Forbidden" }, 403, setCookie)
|
||||
}
|
||||
|
||||
// Check usage limits
|
||||
const usageCheck = await checkUsageAllowed(request)
|
||||
if (!usageCheck.allowed) {
|
||||
return json(
|
||||
{
|
||||
error: "Usage limit exceeded",
|
||||
reason: usageCheck.reason,
|
||||
remaining: usageCheck.remaining,
|
||||
limit: usageCheck.limit,
|
||||
},
|
||||
429,
|
||||
setCookie,
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const prompt =
|
||||
typeof body.prompt === "string" && body.prompt.trim().length > 0
|
||||
? body.prompt
|
||||
: record.prompt
|
||||
|
||||
if (!prompt || !prompt.trim()) {
|
||||
return json({ error: "Prompt required" }, 400, setCookie)
|
||||
}
|
||||
|
||||
const basePrompt = prompt.trim()
|
||||
const modelId =
|
||||
typeof body.modelId === "string" && body.modelId.trim().length > 0
|
||||
? body.modelId
|
||||
: record.model_id
|
||||
const styleId =
|
||||
typeof body.styleId === "string" && body.styleId.trim().length > 0
|
||||
? body.styleId
|
||||
: record.style_id
|
||||
|
||||
const { prompt: styledPrompt, resolvedStyleId } = applyStylePrompt(styleId, basePrompt)
|
||||
|
||||
const temperature =
|
||||
typeof body.temperature === "number" && Number.isFinite(body.temperature)
|
||||
? body.temperature
|
||||
: undefined
|
||||
|
||||
const provider = modelId?.includes("gpt-image") || modelId?.includes("dall") ? "openai" : "gemini"
|
||||
const resolvedModelId =
|
||||
provider === "gemini" ? normalizeGeminiModelId(modelId) : modelId ?? undefined
|
||||
|
||||
let generation: {
|
||||
base64: string
|
||||
mimeType: string
|
||||
description?: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
if (provider === "openai") {
|
||||
const result = await generateOpenAIImage({
|
||||
prompt: styledPrompt,
|
||||
model: resolvedModelId,
|
||||
})
|
||||
generation = {
|
||||
base64: result.base64Image,
|
||||
mimeType: result.mimeType,
|
||||
description: result.revisedPrompt ?? styledPrompt,
|
||||
provider: "openai.dall-e-3",
|
||||
}
|
||||
} else {
|
||||
const result = await generateGeminiImage({
|
||||
prompt: styledPrompt,
|
||||
model: resolvedModelId,
|
||||
temperature,
|
||||
})
|
||||
generation = {
|
||||
base64: result.base64Image,
|
||||
mimeType: result.mimeType,
|
||||
description: styledPrompt,
|
||||
provider: "google.gemini",
|
||||
}
|
||||
}
|
||||
|
||||
const image = await updateCanvasImage({
|
||||
imageId,
|
||||
data: {
|
||||
prompt: basePrompt,
|
||||
modelId: provider === "gemini" ? resolvedModelId : modelId ?? record.model_id,
|
||||
modelUsed: provider === "gemini" ? resolvedModelId : modelId ?? record.model_id,
|
||||
styleId: resolvedStyleId,
|
||||
imageDataBase64: generation.base64,
|
||||
metadata: {
|
||||
provider: generation.provider,
|
||||
mimeType: generation.mimeType,
|
||||
description: generation.description ?? styledPrompt,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Record usage for paid users
|
||||
await recordUsage(request, 1, `canvas-${imageId}-${Date.now()}`)
|
||||
|
||||
return json({ image }, 200, setCookie)
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error
|
||||
console.error("[api/canvas/images/:id/generate] POST", error)
|
||||
const message =
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: "Gemini generation failed"
|
||||
return json({ error: message }, 500)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
96
packages/web/src/routes/api/canvas.images.$imageId.ts
Normal file
96
packages/web/src/routes/api/canvas.images.$imageId.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { resolveCanvasUser } from "@/lib/canvas/user-session"
|
||||
import {
|
||||
deleteCanvasImage,
|
||||
getCanvasImageRecord,
|
||||
getCanvasOwner,
|
||||
updateCanvasImage,
|
||||
} from "@/lib/canvas/db"
|
||||
|
||||
const json = (data: unknown, status = 200, setCookie?: string) => {
|
||||
const headers = new Headers({ "content-type": "application/json" })
|
||||
if (setCookie) {
|
||||
headers.set("set-cookie", setCookie)
|
||||
}
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/canvas/images/$imageId")({
|
||||
server: {
|
||||
handlers: {
|
||||
PATCH: async ({ request, params }) => {
|
||||
try {
|
||||
const { userId, setCookie } = await resolveCanvasUser(request)
|
||||
const imageId = params.imageId
|
||||
const record = await getCanvasImageRecord(imageId)
|
||||
if (!record) {
|
||||
return json({ error: "Not found" }, 404, setCookie)
|
||||
}
|
||||
|
||||
const owner = await getCanvasOwner(record.canvas_id)
|
||||
if (!owner || owner.ownerId !== userId) {
|
||||
return json({ error: "Forbidden" }, 403, setCookie)
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const image = await updateCanvasImage({
|
||||
imageId,
|
||||
data: {
|
||||
name: typeof body.name === "string" ? body.name : undefined,
|
||||
prompt: typeof body.prompt === "string" ? body.prompt : undefined,
|
||||
modelId: typeof body.modelId === "string" ? body.modelId : undefined,
|
||||
styleId: typeof body.styleId === "string" ? body.styleId : undefined,
|
||||
position:
|
||||
body.position &&
|
||||
typeof body.position.x === "number" &&
|
||||
typeof body.position.y === "number"
|
||||
? { x: body.position.x, y: body.position.y }
|
||||
: undefined,
|
||||
size:
|
||||
body.size &&
|
||||
typeof body.size.width === "number" &&
|
||||
typeof body.size.height === "number"
|
||||
? { width: body.size.width, height: body.size.height }
|
||||
: undefined,
|
||||
rotation:
|
||||
typeof body.rotation === "number" && Number.isFinite(body.rotation)
|
||||
? body.rotation
|
||||
: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
return json({ image }, 200, setCookie)
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error
|
||||
console.error("[api/canvas/images/:id] PATCH", error)
|
||||
return json({ error: "Failed to update image" }, 500)
|
||||
}
|
||||
},
|
||||
DELETE: async ({ request, params }) => {
|
||||
try {
|
||||
const { userId, setCookie } = await resolveCanvasUser(request)
|
||||
const imageId = params.imageId
|
||||
const record = await getCanvasImageRecord(imageId)
|
||||
if (!record) {
|
||||
return json({ error: "Not found" }, 404, setCookie)
|
||||
}
|
||||
|
||||
const owner = await getCanvasOwner(record.canvas_id)
|
||||
if (!owner || owner.ownerId !== userId) {
|
||||
return json({ error: "Forbidden" }, 403, setCookie)
|
||||
}
|
||||
|
||||
await deleteCanvasImage(imageId)
|
||||
return json({ id: imageId }, 200, setCookie)
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error
|
||||
console.error("[api/canvas/images/:id] DELETE", error)
|
||||
return json({ error: "Failed to delete image" }, 500)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
64
packages/web/src/routes/api/canvas.images.ts
Normal file
64
packages/web/src/routes/api/canvas.images.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createCanvasImage, getCanvasOwner } from "@/lib/canvas/db"
|
||||
import { resolveCanvasUser } from "@/lib/canvas/user-session"
|
||||
|
||||
const json = (data: unknown, status = 200, setCookie?: string) => {
|
||||
const headers = new Headers({ "content-type": "application/json" })
|
||||
if (setCookie) {
|
||||
headers.set("set-cookie", setCookie)
|
||||
}
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/canvas/images")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
try {
|
||||
const { userId, setCookie } = await resolveCanvasUser(request)
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const canvasId = typeof body.canvasId === "string" ? body.canvasId : null
|
||||
if (!canvasId) {
|
||||
return json({ error: "canvasId required" }, 400, setCookie)
|
||||
}
|
||||
|
||||
const owner = await getCanvasOwner(canvasId)
|
||||
if (!owner || owner.ownerId !== userId) {
|
||||
return json({ error: "Forbidden" }, 403, setCookie)
|
||||
}
|
||||
|
||||
const image = await createCanvasImage({
|
||||
canvasId,
|
||||
name: typeof body.name === "string" ? body.name : undefined,
|
||||
prompt: typeof body.prompt === "string" ? body.prompt : undefined,
|
||||
position:
|
||||
body.position &&
|
||||
typeof body.position.x === "number" &&
|
||||
typeof body.position.y === "number"
|
||||
? { x: body.position.x, y: body.position.y }
|
||||
: undefined,
|
||||
size:
|
||||
body.size &&
|
||||
typeof body.size.width === "number" &&
|
||||
typeof body.size.height === "number"
|
||||
? { width: body.size.width, height: body.size.height }
|
||||
: undefined,
|
||||
modelId: typeof body.modelId === "string" ? body.modelId : undefined,
|
||||
styleId: typeof body.styleId === "string" ? body.styleId : undefined,
|
||||
branchParentId:
|
||||
typeof body.branchParentId === "string" ? body.branchParentId : undefined,
|
||||
})
|
||||
|
||||
return json({ image }, 200, setCookie)
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error
|
||||
console.error("[api/canvas/images] POST", error)
|
||||
return json({ error: "Failed to create canvas image" }, 500)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
99
packages/web/src/routes/api/canvas.ts
Normal file
99
packages/web/src/routes/api/canvas.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import {
|
||||
createCanvasForUser,
|
||||
getCanvasOwner,
|
||||
listCanvasesForUser,
|
||||
updateCanvasRecord,
|
||||
} from "@/lib/canvas/db"
|
||||
import { resolveCanvasUser } from "@/lib/canvas/user-session"
|
||||
|
||||
const json = (data: unknown, status = 200, setCookie?: string) => {
|
||||
const headers = new Headers({ "content-type": "application/json" })
|
||||
if (setCookie) {
|
||||
headers.set("set-cookie", setCookie)
|
||||
}
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/canvas")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
try {
|
||||
const { userId, setCookie } = await resolveCanvasUser(request)
|
||||
const canvases = await listCanvasesForUser(userId)
|
||||
return json({ canvases }, 200, setCookie)
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error
|
||||
console.error("[api/canvas] GET", error)
|
||||
return json({ error: "Failed to load canvases" }, 500)
|
||||
}
|
||||
},
|
||||
POST: async ({ request }) => {
|
||||
try {
|
||||
const { userId, setCookie } = await resolveCanvasUser(request)
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const name =
|
||||
typeof body.name === "string" && body.name.trim().length > 0
|
||||
? body.name.trim()
|
||||
: undefined
|
||||
const snapshot = await createCanvasForUser({ userId, name })
|
||||
return json(snapshot, 201, setCookie)
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error
|
||||
console.error("[api/canvas] POST", error)
|
||||
return json({ error: "Failed to create canvas" }, 500)
|
||||
}
|
||||
},
|
||||
PATCH: async ({ request }) => {
|
||||
try {
|
||||
const { userId, setCookie } = await resolveCanvasUser(request)
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const canvasId = typeof body.canvasId === "string" ? body.canvasId : null
|
||||
if (!canvasId) {
|
||||
return json({ error: "canvasId required" }, 400, setCookie)
|
||||
}
|
||||
|
||||
const owner = await getCanvasOwner(canvasId)
|
||||
if (!owner || owner.ownerId !== userId) {
|
||||
return json({ error: "Forbidden" }, 403, setCookie)
|
||||
}
|
||||
|
||||
const updated = await updateCanvasRecord({
|
||||
canvasId,
|
||||
data: {
|
||||
name: typeof body.name === "string" ? body.name : undefined,
|
||||
width:
|
||||
typeof body.width === "number" && Number.isFinite(body.width)
|
||||
? body.width
|
||||
: undefined,
|
||||
height:
|
||||
typeof body.height === "number" && Number.isFinite(body.height)
|
||||
? body.height
|
||||
: undefined,
|
||||
defaultModel:
|
||||
typeof body.defaultModel === "string" ? body.defaultModel : undefined,
|
||||
defaultStyle:
|
||||
typeof body.defaultStyle === "string" ? body.defaultStyle : undefined,
|
||||
backgroundPrompt:
|
||||
body.backgroundPrompt === null
|
||||
? null
|
||||
: typeof body.backgroundPrompt === "string"
|
||||
? body.backgroundPrompt
|
||||
: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
return json({ canvas: updated }, 200, setCookie)
|
||||
} catch (error) {
|
||||
if (error instanceof Response) return error
|
||||
console.error("[api/canvas] PATCH", error)
|
||||
return json({ error: "Failed to update canvas" }, 500)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
56
packages/web/src/routes/api/chat-messages.ts
Normal file
56
packages/web/src/routes/api/chat-messages.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { getAuth } from "@/lib/auth"
|
||||
import {
|
||||
optionsResponse,
|
||||
prepareElectricUrl,
|
||||
proxyElectricRequest,
|
||||
} from "@/lib/electric-proxy"
|
||||
import { db } from "@/db/connection"
|
||||
|
||||
const serve = async ({ request }: { request: Request }) => {
|
||||
const session = await getAuth().api.getSession({ headers: request.headers })
|
||||
if (!session?.user?.id) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
// Get user's thread IDs first
|
||||
const userThreads = await db().query.chat_threads.findMany({
|
||||
where(fields, { eq }) {
|
||||
return eq(fields.user_id, session.user.id)
|
||||
},
|
||||
columns: { id: true },
|
||||
})
|
||||
|
||||
// threadIds are integers from DB, but validate for safety
|
||||
const threadIds = userThreads
|
||||
.map((t) => t.id)
|
||||
.filter((id): id is number => Number.isInteger(id))
|
||||
|
||||
const originUrl = prepareElectricUrl(request.url)
|
||||
originUrl.searchParams.set("table", "chat_messages")
|
||||
|
||||
// Filter messages by user's thread IDs (no subquery)
|
||||
if (threadIds.length > 0) {
|
||||
originUrl.searchParams.set(
|
||||
"where",
|
||||
`"thread_id" IN (${threadIds.join(",")})`,
|
||||
)
|
||||
} else {
|
||||
// User has no threads, return empty by filtering impossible condition
|
||||
originUrl.searchParams.set("where", `"thread_id" = -1`)
|
||||
}
|
||||
|
||||
return proxyElectricRequest(originUrl, request)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/chat-messages")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: serve,
|
||||
OPTIONS: ({ request }) => optionsResponse(request),
|
||||
},
|
||||
},
|
||||
})
|
||||
44
packages/web/src/routes/api/chat-threads.ts
Normal file
44
packages/web/src/routes/api/chat-threads.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { getAuth } from "@/lib/auth"
|
||||
import {
|
||||
optionsResponse,
|
||||
prepareElectricUrl,
|
||||
proxyElectricRequest,
|
||||
} from "@/lib/electric-proxy"
|
||||
|
||||
// Validate user ID contains only safe characters (alphanumeric, hyphens, underscores)
|
||||
const isValidUserId = (id: string): boolean => /^[a-zA-Z0-9_-]+$/.test(id)
|
||||
|
||||
const serve = async ({ request }: { request: Request }) => {
|
||||
const session = await getAuth().api.getSession({ headers: request.headers })
|
||||
if (!session?.user?.id) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
if (!isValidUserId(userId)) {
|
||||
return new Response(JSON.stringify({ error: "Invalid user ID" }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
const originUrl = prepareElectricUrl(request.url)
|
||||
originUrl.searchParams.set("table", "chat_threads")
|
||||
const filter = `"user_id" = '${userId}'`
|
||||
originUrl.searchParams.set("where", filter)
|
||||
|
||||
return proxyElectricRequest(originUrl, request)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/chat-threads")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: serve,
|
||||
OPTIONS: ({ request }) => optionsResponse(request),
|
||||
},
|
||||
},
|
||||
})
|
||||
191
packages/web/src/routes/api/chat/ai.ts
Normal file
191
packages/web/src/routes/api/chat/ai.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { streamText } from "ai"
|
||||
import { getAuth } from "@/lib/auth"
|
||||
import { db } from "@/db/connection"
|
||||
import {
|
||||
chat_messages,
|
||||
chat_threads,
|
||||
context_items,
|
||||
thread_context_items,
|
||||
} from "@/db/schema"
|
||||
import { getOpenRouter, getDefaultModel } from "@/lib/ai/provider"
|
||||
import { eq, inArray } from "drizzle-orm"
|
||||
import { checkUsageAllowed, recordUsage } from "@/lib/billing"
|
||||
|
||||
export const Route = createFileRoute("/api/chat/ai")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
const session = await getAuth().api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
if (!session?.user?.id) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
threadId?: number | string
|
||||
messages?: Array<{ role: "user" | "assistant"; content: string }>
|
||||
model?: string
|
||||
}
|
||||
|
||||
const threadId = Number(body.threadId)
|
||||
const messages = body.messages ?? []
|
||||
const model = body.model || getDefaultModel()
|
||||
|
||||
if (!threadId || messages.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing threadId or messages" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const database = db()
|
||||
|
||||
// Verify thread ownership
|
||||
const [thread] = await database
|
||||
.select()
|
||||
.from(chat_threads)
|
||||
.where(eq(chat_threads.id, threadId))
|
||||
.limit(1)
|
||||
|
||||
if (!thread || thread.user_id !== session.user.id) {
|
||||
return new Response(JSON.stringify({ error: "Forbidden" }), {
|
||||
status: 403,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
// Check usage limits
|
||||
const usageCheck = await checkUsageAllowed(request)
|
||||
if (!usageCheck.allowed) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Usage limit exceeded",
|
||||
reason: usageCheck.reason,
|
||||
remaining: usageCheck.remaining,
|
||||
limit: usageCheck.limit,
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Load context items linked to this thread
|
||||
const linkedItems = await database
|
||||
.select({ context_item_id: thread_context_items.context_item_id })
|
||||
.from(thread_context_items)
|
||||
.where(eq(thread_context_items.thread_id, threadId))
|
||||
|
||||
let contextContent = ""
|
||||
if (linkedItems.length > 0) {
|
||||
const itemIds = linkedItems.map((l) => l.context_item_id)
|
||||
const items = await database
|
||||
.select()
|
||||
.from(context_items)
|
||||
.where(inArray(context_items.id, itemIds))
|
||||
|
||||
// Build context content from website content
|
||||
const contextParts = items
|
||||
.filter((item) => item.content && !item.refreshing)
|
||||
.map((item) => {
|
||||
return `--- Content from ${item.name} (${item.url}) ---\n${item.content}\n--- End of ${item.name} ---`
|
||||
})
|
||||
|
||||
if (contextParts.length > 0) {
|
||||
contextContent = contextParts.join("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
const openrouter = getOpenRouter()
|
||||
console.log(
|
||||
"[ai] openrouter:",
|
||||
openrouter ? "configured" : "not configured",
|
||||
)
|
||||
console.log(
|
||||
"[ai] OPENROUTER_API_KEY set:",
|
||||
!!process.env.OPENROUTER_API_KEY,
|
||||
)
|
||||
if (!openrouter) {
|
||||
// Fallback to streaming-compatible demo response
|
||||
const lastUserMessage = messages
|
||||
.filter((m) => m.role === "user")
|
||||
.pop()
|
||||
const reply = `Demo reply: I received "${lastUserMessage?.content}". Configure OPENROUTER_API_KEY for real responses.`
|
||||
|
||||
// Save the assistant message
|
||||
await database.insert(chat_messages).values({
|
||||
thread_id: threadId,
|
||||
role: "assistant",
|
||||
content: reply,
|
||||
})
|
||||
|
||||
// Return a streaming-compatible response using AI SDK data stream format
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// AI SDK data stream format: 0: for text chunks
|
||||
controller.enqueue(encoder.encode(`0:${JSON.stringify(reply)}\n`))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
"x-vercel-ai-data-stream": "v1",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Use AI SDK streaming with OpenRouter
|
||||
console.log("[ai] calling streamText with model:", model)
|
||||
console.log("[ai] context content length:", contextContent.length)
|
||||
|
||||
// Build system prompt with context
|
||||
let systemPrompt = "You are a helpful assistant."
|
||||
if (contextContent) {
|
||||
systemPrompt = `You are a helpful assistant. You have access to the following context information that you should use to answer questions:\n\n${contextContent}\n\nUse the above context to help answer the user's questions when relevant.`
|
||||
}
|
||||
|
||||
try {
|
||||
const result = streamText({
|
||||
model: openrouter.chat(model),
|
||||
system: systemPrompt,
|
||||
messages: messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
async onFinish({ text }) {
|
||||
console.log("[ai] onFinish, text length:", text.length)
|
||||
// Save the assistant message when streaming completes
|
||||
await database.insert(chat_messages).values({
|
||||
thread_id: threadId,
|
||||
role: "assistant",
|
||||
content: text,
|
||||
})
|
||||
// Record usage for paid users
|
||||
await recordUsage(request, 1, `chat-${threadId}-${Date.now()}`)
|
||||
},
|
||||
})
|
||||
|
||||
console.log("[ai] returning stream response")
|
||||
// Return the streaming response (AI SDK v5 uses toTextStreamResponse)
|
||||
return result.toTextStreamResponse()
|
||||
} catch (error) {
|
||||
console.error("[ai] streamText error:", error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
106
packages/web/src/routes/api/chat/guest.ts
Normal file
106
packages/web/src/routes/api/chat/guest.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { streamText } from "ai"
|
||||
import { getOpenRouter, getDefaultModel } from "@/lib/ai/provider"
|
||||
import { db } from "@/db/connection"
|
||||
import { chat_threads, chat_messages } from "@/db/schema"
|
||||
|
||||
export const Route = createFileRoute("/api/chat/guest")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
messages?: Array<{ role: "user" | "assistant"; content: string }>
|
||||
model?: string
|
||||
threadId?: number
|
||||
}
|
||||
|
||||
const messages = body.messages ?? []
|
||||
const model = body.model || getDefaultModel()
|
||||
|
||||
if (messages.length === 0) {
|
||||
return new Response(JSON.stringify({ error: "Missing messages" }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
const database = db()
|
||||
let threadId = body.threadId
|
||||
|
||||
// Create thread if not provided
|
||||
if (!threadId) {
|
||||
const lastUserMessage = messages.filter((m) => m.role === "user").pop()
|
||||
const title = lastUserMessage?.content?.slice(0, 40) || "New chat"
|
||||
const [thread] = await database
|
||||
.insert(chat_threads)
|
||||
.values({ title, user_id: null })
|
||||
.returning({ id: chat_threads.id })
|
||||
threadId = thread.id
|
||||
}
|
||||
|
||||
// Save the user message
|
||||
const lastUserMessage = messages.filter((m) => m.role === "user").pop()
|
||||
if (lastUserMessage) {
|
||||
await database.insert(chat_messages).values({
|
||||
thread_id: threadId,
|
||||
role: "user",
|
||||
content: lastUserMessage.content,
|
||||
})
|
||||
}
|
||||
|
||||
const openrouter = getOpenRouter()
|
||||
if (!openrouter) {
|
||||
const reply = `Demo reply: I received "${lastUserMessage?.content}". Configure OPENROUTER_API_KEY for real responses.`
|
||||
|
||||
// Save assistant message
|
||||
await database.insert(chat_messages).values({
|
||||
thread_id: threadId,
|
||||
role: "assistant",
|
||||
content: reply,
|
||||
})
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(JSON.stringify({ threadId }) + "\n"))
|
||||
controller.enqueue(encoder.encode(reply))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/plain; charset=utf-8" },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const result = streamText({
|
||||
model: openrouter.chat(model),
|
||||
system: "You are a helpful assistant.",
|
||||
messages: messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
async onFinish({ text }) {
|
||||
// Save assistant message when streaming completes
|
||||
await database.insert(chat_messages).values({
|
||||
thread_id: threadId!,
|
||||
role: "assistant",
|
||||
content: text,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// Return threadId in a custom header so client can track it
|
||||
const response = result.toTextStreamResponse()
|
||||
response.headers.set("X-Thread-Id", String(threadId))
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error("[guest-ai] streamText error:", error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
139
packages/web/src/routes/api/chat/mutations.ts
Normal file
139
packages/web/src/routes/api/chat/mutations.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { getAuth } from "@/lib/auth"
|
||||
import { db } from "@/db/connection"
|
||||
import { chat_threads, chat_messages } from "@/db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
export const Route = createFileRoute("/api/chat/mutations")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
const session = await getAuth().api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
if (!session?.user?.id) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
const database = db()
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const { action } = body as { action?: string }
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case "createThread": {
|
||||
const title =
|
||||
(typeof body.title === "string" && body.title.trim()) ||
|
||||
"New chat"
|
||||
const [thread] = await database
|
||||
.insert(chat_threads)
|
||||
.values({
|
||||
title,
|
||||
user_id: session.user.id,
|
||||
})
|
||||
.returning()
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ thread }),
|
||||
defaultJsonHeaders(200),
|
||||
)
|
||||
}
|
||||
case "addMessage": {
|
||||
const threadId = Number(body.threadId)
|
||||
const role =
|
||||
typeof body.role === "string" ? body.role.trim() : "user"
|
||||
const content =
|
||||
typeof body.content === "string" ? body.content.trim() : ""
|
||||
|
||||
if (!threadId || !content || !role) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing threadId/content/role" }),
|
||||
defaultJsonHeaders(400),
|
||||
)
|
||||
}
|
||||
|
||||
const owner = await database.query.chat_threads.findFirst({
|
||||
where(fields, { eq }) {
|
||||
return eq(fields.id, threadId)
|
||||
},
|
||||
})
|
||||
|
||||
if (!owner || owner.user_id !== session.user.id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Forbidden" }),
|
||||
defaultJsonHeaders(403),
|
||||
)
|
||||
}
|
||||
|
||||
const [message] = await database
|
||||
.insert(chat_messages)
|
||||
.values({
|
||||
thread_id: threadId,
|
||||
role,
|
||||
content,
|
||||
})
|
||||
.returning()
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ message }),
|
||||
defaultJsonHeaders(200),
|
||||
)
|
||||
}
|
||||
case "renameThread": {
|
||||
const threadId = Number(body.threadId)
|
||||
const title =
|
||||
typeof body.title === "string" ? body.title.trim() : ""
|
||||
if (!threadId || !title) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing threadId/title" }),
|
||||
defaultJsonHeaders(400),
|
||||
)
|
||||
}
|
||||
|
||||
const [thread] = await database
|
||||
.update(chat_threads)
|
||||
.set({ title })
|
||||
.where(eq(chat_threads.id, threadId))
|
||||
.returning()
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ thread }),
|
||||
defaultJsonHeaders(200),
|
||||
)
|
||||
}
|
||||
case "deleteAllThreads": {
|
||||
// Delete all threads for the current user (messages cascade)
|
||||
await database
|
||||
.delete(chat_threads)
|
||||
.where(eq(chat_threads.user_id, session.user.id))
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true }),
|
||||
defaultJsonHeaders(200),
|
||||
)
|
||||
}
|
||||
default:
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Unknown action" }),
|
||||
defaultJsonHeaders(400),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[chat/mutations] error", error)
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Mutation failed" }),
|
||||
defaultJsonHeaders(500),
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const defaultJsonHeaders = (status: number) => ({
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
349
packages/web/src/routes/api/context-items.ts
Normal file
349
packages/web/src/routes/api/context-items.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { getAuth } from "@/lib/auth"
|
||||
import { db } from "@/db/connection"
|
||||
import { context_items, thread_context_items } from "@/db/schema"
|
||||
import { eq, and, inArray, desc } from "drizzle-orm"
|
||||
|
||||
interface ContextItemsBody {
|
||||
action?: string
|
||||
url?: string
|
||||
threadId?: number | string
|
||||
itemId?: number | string
|
||||
}
|
||||
|
||||
const defaultJsonHeaders = (status: number) => ({
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
|
||||
// Fetch webpage content as markdown
|
||||
async function fetchAndUpdateContent(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
db: any,
|
||||
itemId: number,
|
||||
url: string,
|
||||
) {
|
||||
try {
|
||||
// Use Jina Reader API for converting webpages to markdown
|
||||
const response = await fetch(`https://r.jina.ai/${url}`, {
|
||||
headers: {
|
||||
Accept: "text/markdown",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`)
|
||||
}
|
||||
|
||||
const content = await response.text()
|
||||
|
||||
await db
|
||||
.update(context_items)
|
||||
.set({
|
||||
content,
|
||||
refreshing: false,
|
||||
updated_at: new Date(),
|
||||
})
|
||||
.where(eq(context_items.id, itemId))
|
||||
} catch (error) {
|
||||
console.error(`[fetchAndUpdateContent] Failed for ${url}:`, error)
|
||||
// Mark as not refreshing even on error
|
||||
await db
|
||||
.update(context_items)
|
||||
.set({ refreshing: false })
|
||||
.where(eq(context_items.id, itemId))
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/context-items")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }: { request: Request }) => {
|
||||
const session = await getAuth().api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
if (!session?.user?.id) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
const database = db()
|
||||
const body = (await request.json().catch(() => ({}))) as ContextItemsBody
|
||||
const { action } = body
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case "addUrl": {
|
||||
const url = typeof body.url === "string" ? body.url.trim() : ""
|
||||
const threadId = body.threadId ? Number(body.threadId) : null
|
||||
|
||||
if (!url) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing url" }),
|
||||
defaultJsonHeaders(400),
|
||||
)
|
||||
}
|
||||
|
||||
// Parse URL to get display name
|
||||
let parsedUrl: URL
|
||||
try {
|
||||
parsedUrl = new URL(url)
|
||||
} catch {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Invalid URL" }),
|
||||
defaultJsonHeaders(400),
|
||||
)
|
||||
}
|
||||
|
||||
const name = parsedUrl.hostname + parsedUrl.pathname
|
||||
|
||||
// Create context item with refreshing=true
|
||||
const [item] = await database
|
||||
.insert(context_items)
|
||||
.values({
|
||||
user_id: session.user.id,
|
||||
type: "url",
|
||||
url,
|
||||
name,
|
||||
refreshing: true,
|
||||
})
|
||||
.returning()
|
||||
|
||||
// If threadId provided, link to thread
|
||||
if (threadId) {
|
||||
await database.insert(thread_context_items).values({
|
||||
thread_id: threadId,
|
||||
context_item_id: item.id,
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch content in background and update
|
||||
fetchAndUpdateContent(database, item.id, url).catch(console.error)
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ item }),
|
||||
defaultJsonHeaders(200),
|
||||
)
|
||||
}
|
||||
|
||||
case "refreshUrl": {
|
||||
const itemId = Number(body.itemId)
|
||||
if (!itemId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing itemId" }),
|
||||
defaultJsonHeaders(400),
|
||||
)
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
const [item] = await database
|
||||
.select()
|
||||
.from(context_items)
|
||||
.where(eq(context_items.id, itemId))
|
||||
.limit(1)
|
||||
|
||||
if (!item || item.user_id !== session.user.id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Forbidden" }),
|
||||
defaultJsonHeaders(403),
|
||||
)
|
||||
}
|
||||
|
||||
if (!item.url) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Item has no URL" }),
|
||||
defaultJsonHeaders(400),
|
||||
)
|
||||
}
|
||||
|
||||
// Mark as refreshing
|
||||
await database
|
||||
.update(context_items)
|
||||
.set({ refreshing: true })
|
||||
.where(eq(context_items.id, itemId))
|
||||
|
||||
// Fetch content
|
||||
fetchAndUpdateContent(database, itemId, item.url).catch(console.error)
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true }),
|
||||
defaultJsonHeaders(200),
|
||||
)
|
||||
}
|
||||
|
||||
case "deleteItem": {
|
||||
const itemId = Number(body.itemId)
|
||||
if (!itemId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing itemId" }),
|
||||
defaultJsonHeaders(400),
|
||||
)
|
||||
}
|
||||
|
||||
// Verify ownership and delete
|
||||
await database
|
||||
.delete(context_items)
|
||||
.where(
|
||||
and(
|
||||
eq(context_items.id, itemId),
|
||||
eq(context_items.user_id, session.user.id),
|
||||
),
|
||||
)
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true }),
|
||||
defaultJsonHeaders(200),
|
||||
)
|
||||
}
|
||||
|
||||
case "linkToThread": {
|
||||
const itemId = Number(body.itemId)
|
||||
const threadId = Number(body.threadId)
|
||||
|
||||
if (!itemId || !threadId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing itemId/threadId" }),
|
||||
defaultJsonHeaders(400),
|
||||
)
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
const [item] = await database
|
||||
.select()
|
||||
.from(context_items)
|
||||
.where(eq(context_items.id, itemId))
|
||||
.limit(1)
|
||||
|
||||
if (!item || item.user_id !== session.user.id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Forbidden" }),
|
||||
defaultJsonHeaders(403),
|
||||
)
|
||||
}
|
||||
|
||||
await database
|
||||
.insert(thread_context_items)
|
||||
.values({
|
||||
thread_id: threadId,
|
||||
context_item_id: itemId,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true }),
|
||||
defaultJsonHeaders(200),
|
||||
)
|
||||
}
|
||||
|
||||
case "unlinkFromThread": {
|
||||
const itemId = Number(body.itemId)
|
||||
const threadId = Number(body.threadId)
|
||||
|
||||
if (!itemId || !threadId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing itemId/threadId" }),
|
||||
defaultJsonHeaders(400),
|
||||
)
|
||||
}
|
||||
|
||||
await database
|
||||
.delete(thread_context_items)
|
||||
.where(
|
||||
and(
|
||||
eq(thread_context_items.context_item_id, itemId),
|
||||
eq(thread_context_items.thread_id, threadId),
|
||||
),
|
||||
)
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true }),
|
||||
defaultJsonHeaders(200),
|
||||
)
|
||||
}
|
||||
|
||||
case "getItems": {
|
||||
const items = await database
|
||||
.select()
|
||||
.from(context_items)
|
||||
.where(eq(context_items.user_id, session.user.id))
|
||||
.orderBy(desc(context_items.created_at))
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ items }),
|
||||
defaultJsonHeaders(200),
|
||||
)
|
||||
}
|
||||
|
||||
case "getThreadItems": {
|
||||
const threadId = Number(body.threadId)
|
||||
if (!threadId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing threadId" }),
|
||||
defaultJsonHeaders(400),
|
||||
)
|
||||
}
|
||||
|
||||
const links = await database
|
||||
.select()
|
||||
.from(thread_context_items)
|
||||
.where(eq(thread_context_items.thread_id, threadId))
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const itemIds = links.map((l: any) => l.context_item_id)
|
||||
if (itemIds.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ items: [] }),
|
||||
defaultJsonHeaders(200),
|
||||
)
|
||||
}
|
||||
|
||||
const items = await database
|
||||
.select()
|
||||
.from(context_items)
|
||||
.where(inArray(context_items.id, itemIds))
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ items }),
|
||||
defaultJsonHeaders(200),
|
||||
)
|
||||
}
|
||||
|
||||
default:
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Unknown action" }),
|
||||
defaultJsonHeaders(400),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[context-items] error", error)
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Operation failed" }),
|
||||
defaultJsonHeaders(500),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
GET: async ({ request }: { request: Request }) => {
|
||||
const session = await getAuth().api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
if (!session?.user?.id) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
const items = await db()
|
||||
.select()
|
||||
.from(context_items)
|
||||
.where(eq(context_items.user_id, session.user.id))
|
||||
.orderBy(desc(context_items.created_at))
|
||||
|
||||
return new Response(JSON.stringify({ items }), defaultJsonHeaders(200))
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
71
packages/web/src/routes/api/flowglad/$.ts
Normal file
71
packages/web/src/routes/api/flowglad/$.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createRequestHandler } from "@flowglad/server"
|
||||
import { getFlowgladServer } from "@/lib/flowglad"
|
||||
|
||||
const json = (data: { error?: unknown; data?: unknown }, status = 200) =>
|
||||
new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/api/flowglad/$")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request, params }) => {
|
||||
const flowglad = getFlowgladServer(request)
|
||||
if (!flowglad) {
|
||||
return json({ error: "Flowglad not configured" }, 500)
|
||||
}
|
||||
|
||||
const pathString = params._splat ?? ""
|
||||
const path = pathString.split("/").filter(Boolean)
|
||||
const url = new URL(request.url)
|
||||
const query = Object.fromEntries(url.searchParams)
|
||||
|
||||
try {
|
||||
const handler = createRequestHandler({ flowgladServer: flowglad })
|
||||
const result = await handler({
|
||||
path,
|
||||
method: "GET",
|
||||
query,
|
||||
})
|
||||
|
||||
return json({ error: result.error, data: result.data }, result.status)
|
||||
} catch (error) {
|
||||
console.error("[flowglad] GET error:", error)
|
||||
if (error instanceof Error && error.message === "Unauthenticated") {
|
||||
return json({ error: "Unauthorized" }, 401)
|
||||
}
|
||||
return json({ error: "Internal error" }, 500)
|
||||
}
|
||||
},
|
||||
POST: async ({ request, params }) => {
|
||||
const flowglad = getFlowgladServer(request)
|
||||
if (!flowglad) {
|
||||
return json({ error: "Flowglad not configured" }, 500)
|
||||
}
|
||||
|
||||
const pathString = params._splat ?? ""
|
||||
const path = pathString.split("/").filter(Boolean)
|
||||
const body = await request.json().catch(() => ({}))
|
||||
|
||||
try {
|
||||
const handler = createRequestHandler({ flowgladServer: flowglad })
|
||||
const result = await handler({
|
||||
path,
|
||||
method: "POST",
|
||||
body,
|
||||
})
|
||||
|
||||
return json({ error: result.error, data: result.data }, result.status)
|
||||
} catch (error) {
|
||||
console.error("[flowglad] POST error:", error)
|
||||
if (error instanceof Error && error.message === "Unauthenticated") {
|
||||
return json({ error: "Unauthorized" }, 401)
|
||||
}
|
||||
return json({ error: "Internal error" }, 500)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
172
packages/web/src/routes/api/profile.ts
Normal file
172
packages/web/src/routes/api/profile.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { getDb } from "@/db/connection"
|
||||
import { users, streams } from "@/db/schema"
|
||||
import { getAuth } from "@/lib/auth"
|
||||
import { randomUUID } from "crypto"
|
||||
|
||||
const resolveDatabaseUrl = (request: Request) => {
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server") as {
|
||||
getServerContext: () => { cloudflare?: { env?: Record<string, string> } } | null
|
||||
}
|
||||
const ctx = getServerContext()
|
||||
const url = ctx?.cloudflare?.env?.DATABASE_URL
|
||||
if (url) return url
|
||||
} catch {}
|
||||
if (process.env.DATABASE_URL) return process.env.DATABASE_URL
|
||||
throw new Error("DATABASE_URL is not configured")
|
||||
}
|
||||
|
||||
// GET current user profile
|
||||
const getProfile = async ({ request }: { request: Request }) => {
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const database = getDb(resolveDatabaseUrl(request))
|
||||
const user = await database.query.users.findFirst({
|
||||
where: eq(users.id, session.user.id),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: "User not found" }), {
|
||||
status: 404,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
// Also get stream info
|
||||
const stream = await database.query.streams.findFirst({
|
||||
where: eq(streams.user_id, user.id),
|
||||
})
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
image: user.image,
|
||||
stream: stream
|
||||
? {
|
||||
id: stream.id,
|
||||
title: stream.title,
|
||||
is_live: stream.is_live,
|
||||
hls_url: stream.hls_url,
|
||||
stream_key: stream.stream_key,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Profile GET error:", error)
|
||||
return new Response(JSON.stringify({ error: "Internal server error" }), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PUT update profile (name, username)
|
||||
const updateProfile = async ({ request }: { request: Request }) => {
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { name, username } = body as { name?: string; username?: string }
|
||||
|
||||
const database = getDb(resolveDatabaseUrl(request))
|
||||
|
||||
// Validate username format
|
||||
if (username !== undefined) {
|
||||
if (username.length < 3) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Username must be at least 3 characters" }),
|
||||
{ status: 400, headers: { "content-type": "application/json" } }
|
||||
)
|
||||
}
|
||||
if (!/^[a-z0-9_-]+$/.test(username)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Username can only contain lowercase letters, numbers, hyphens, and underscores" }),
|
||||
{ status: 400, headers: { "content-type": "application/json" } }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if username is taken
|
||||
const existing = await database.query.users.findFirst({
|
||||
where: eq(users.username, username),
|
||||
})
|
||||
if (existing && existing.id !== session.user.id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Username is already taken" }),
|
||||
{ status: 409, headers: { "content-type": "application/json" } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Update user
|
||||
const updates: Record<string, string> = { updatedAt: new Date().toISOString() }
|
||||
if (name !== undefined) updates.name = name
|
||||
if (username !== undefined) updates.username = username
|
||||
|
||||
await database
|
||||
.update(users)
|
||||
.set(updates)
|
||||
.where(eq(users.id, session.user.id))
|
||||
|
||||
// If username is set for first time, create a stream record
|
||||
if (username) {
|
||||
const existingStream = await database.query.streams.findFirst({
|
||||
where: eq(streams.user_id, session.user.id),
|
||||
})
|
||||
|
||||
if (!existingStream) {
|
||||
await database.insert(streams).values({
|
||||
id: randomUUID(),
|
||||
user_id: session.user.id,
|
||||
title: `${name || username}'s Stream`,
|
||||
stream_key: randomUUID().replace(/-/g, ""),
|
||||
is_live: false,
|
||||
viewer_count: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Profile PUT error:", error)
|
||||
return new Response(JSON.stringify({ error: "Internal server error" }), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/profile")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: getProfile,
|
||||
PUT: updateProfile,
|
||||
},
|
||||
},
|
||||
})
|
||||
130
packages/web/src/routes/api/stream.ts
Normal file
130
packages/web/src/routes/api/stream.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { getDb } from "@/db/connection"
|
||||
import { streams } from "@/db/schema"
|
||||
import { getAuth } from "@/lib/auth"
|
||||
|
||||
const resolveDatabaseUrl = (request: Request) => {
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server") as {
|
||||
getServerContext: () => { cloudflare?: { env?: Record<string, string> } } | null
|
||||
}
|
||||
const ctx = getServerContext()
|
||||
const url = ctx?.cloudflare?.env?.DATABASE_URL
|
||||
if (url) return url
|
||||
} catch {}
|
||||
if (process.env.DATABASE_URL) return process.env.DATABASE_URL
|
||||
throw new Error("DATABASE_URL is not configured")
|
||||
}
|
||||
|
||||
// GET current user's stream
|
||||
const getStream = async ({ request }: { request: Request }) => {
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const database = getDb(resolveDatabaseUrl(request))
|
||||
const stream = await database.query.streams.findFirst({
|
||||
where: eq(streams.user_id, session.user.id),
|
||||
})
|
||||
|
||||
if (!stream) {
|
||||
return new Response(JSON.stringify({ error: "No stream configured" }), {
|
||||
status: 404,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(stream), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Stream GET error:", error)
|
||||
return new Response(JSON.stringify({ error: "Internal server error" }), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PUT update stream settings
|
||||
const updateStream = async ({ request }: { request: Request }) => {
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { title, description, hls_url, is_live } = body as {
|
||||
title?: string
|
||||
description?: string
|
||||
hls_url?: string
|
||||
is_live?: boolean
|
||||
}
|
||||
|
||||
const database = getDb(resolveDatabaseUrl(request))
|
||||
|
||||
const stream = await database.query.streams.findFirst({
|
||||
where: eq(streams.user_id, session.user.id),
|
||||
})
|
||||
|
||||
if (!stream) {
|
||||
return new Response(JSON.stringify({ error: "No stream configured" }), {
|
||||
status: 404,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = { updated_at: new Date() }
|
||||
if (title !== undefined) updates.title = title
|
||||
if (description !== undefined) updates.description = description
|
||||
if (hls_url !== undefined) updates.hls_url = hls_url
|
||||
if (is_live !== undefined) {
|
||||
updates.is_live = is_live
|
||||
if (is_live && !stream.started_at) {
|
||||
updates.started_at = new Date()
|
||||
} else if (!is_live) {
|
||||
updates.ended_at = new Date()
|
||||
}
|
||||
}
|
||||
|
||||
await database
|
||||
.update(streams)
|
||||
.set(updates)
|
||||
.where(eq(streams.id, stream.id))
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Stream PUT error:", error)
|
||||
return new Response(JSON.stringify({ error: "Internal server error" }), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/stream")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: getStream,
|
||||
PUT: updateStream,
|
||||
},
|
||||
},
|
||||
})
|
||||
101
packages/web/src/routes/api/streams.$username.ts
Normal file
101
packages/web/src/routes/api/streams.$username.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { getDb } from "@/db/connection"
|
||||
import { users, streams } from "@/db/schema"
|
||||
|
||||
const resolveDatabaseUrl = (request: Request) => {
|
||||
try {
|
||||
const { getServerContext } = require("@tanstack/react-start/server") as {
|
||||
getServerContext: () => { cloudflare?: { env?: Record<string, string> } } | null
|
||||
}
|
||||
const ctx = getServerContext()
|
||||
const url = ctx?.cloudflare?.env?.DATABASE_URL
|
||||
if (url) {
|
||||
return url
|
||||
}
|
||||
} catch {
|
||||
// probably not running inside server context
|
||||
}
|
||||
|
||||
if (process.env.DATABASE_URL) {
|
||||
return process.env.DATABASE_URL
|
||||
}
|
||||
|
||||
throw new Error("DATABASE_URL is not configured")
|
||||
}
|
||||
|
||||
const serve = async ({
|
||||
request,
|
||||
params,
|
||||
}: {
|
||||
request: Request
|
||||
params: { username: string }
|
||||
}) => {
|
||||
const { username } = params
|
||||
|
||||
if (!username) {
|
||||
return new Response(JSON.stringify({ error: "Username required" }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const database = getDb(resolveDatabaseUrl(request))
|
||||
|
||||
const user = await database.query.users.findFirst({
|
||||
where: eq(users.username, username),
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
return new Response(JSON.stringify({ error: "User not found" }), {
|
||||
status: 404,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
const stream = await database.query.streams.findFirst({
|
||||
where: eq(streams.user_id, user.id),
|
||||
})
|
||||
|
||||
const data = {
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
image: user.image,
|
||||
},
|
||||
stream: stream
|
||||
? {
|
||||
id: stream.id,
|
||||
title: stream.title,
|
||||
description: stream.description,
|
||||
is_live: stream.is_live,
|
||||
viewer_count: stream.viewer_count,
|
||||
hls_url: stream.hls_url,
|
||||
thumbnail_url: stream.thumbnail_url,
|
||||
started_at: stream.started_at?.toISOString() ?? null,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Stream API error:", error)
|
||||
return new Response(JSON.stringify({ error: "Internal server error" }), {
|
||||
status: 500,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/streams/$username")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: serve,
|
||||
},
|
||||
},
|
||||
})
|
||||
136
packages/web/src/routes/api/usage-events.create.ts
Normal file
136
packages/web/src/routes/api/usage-events.create.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { getFlowgladServer } from "@/lib/flowglad"
|
||||
import { getAuth } from "@/lib/auth"
|
||||
|
||||
const json = (
|
||||
data: { error?: string; success?: boolean; currentBalance?: number },
|
||||
status = 200,
|
||||
) =>
|
||||
new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
|
||||
export const Route = createFileRoute("/api/usage-events/create")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
try {
|
||||
// Check authentication
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
|
||||
if (!session?.user) {
|
||||
return json({ error: "Unauthorized" }, 401)
|
||||
}
|
||||
|
||||
// Get request body
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
meterSlug?: string
|
||||
amount?: number
|
||||
}
|
||||
const { meterSlug, amount } = body
|
||||
|
||||
// Validate input
|
||||
if (!meterSlug || typeof meterSlug !== "string") {
|
||||
return json({ error: "meterSlug is required" }, 400)
|
||||
}
|
||||
|
||||
if (!amount || typeof amount !== "number" || amount <= 0) {
|
||||
return json({ error: "amount must be a positive number" }, 400)
|
||||
}
|
||||
|
||||
if (
|
||||
meterSlug !== "free_requests" &&
|
||||
meterSlug !== "premium_requests"
|
||||
) {
|
||||
return json(
|
||||
{
|
||||
error:
|
||||
"meterSlug must be either 'free_requests' or 'premium_requests'",
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
// Get Flowglad server instance
|
||||
const flowglad = getFlowgladServer(request)
|
||||
if (!flowglad) {
|
||||
return json({ error: "Flowglad not configured" }, 500)
|
||||
}
|
||||
|
||||
// Get billing info
|
||||
const billing = await flowglad.getBilling()
|
||||
|
||||
// Check if user has active subscription
|
||||
const hasActiveSubscription =
|
||||
billing.currentSubscriptions &&
|
||||
billing.currentSubscriptions.length > 0
|
||||
|
||||
if (!hasActiveSubscription) {
|
||||
return json({ error: "No active subscription found" }, 400)
|
||||
}
|
||||
|
||||
// Get current balance
|
||||
const balanceInfo = billing.checkUsageBalance?.(meterSlug)
|
||||
const currentBalance = balanceInfo?.availableBalance ?? 0
|
||||
|
||||
// Validate balance
|
||||
if (currentBalance < amount) {
|
||||
return json(
|
||||
{
|
||||
error: `Maximum usage exceeded. Your balance is ${currentBalance}.`,
|
||||
currentBalance,
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
// Get subscription
|
||||
const subscription = billing.currentSubscriptions![0]
|
||||
|
||||
// Find usage price for the meter
|
||||
const usagePrice = billing.pricingModel?.products
|
||||
?.flatMap((p) => p.prices || [])
|
||||
?.find((p) => p.type === "usage" && p.slug === meterSlug) as
|
||||
| { id: string }
|
||||
| undefined
|
||||
|
||||
if (!usagePrice) {
|
||||
return json(
|
||||
{
|
||||
error: `No usage price found for meter: ${meterSlug}`,
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
// Create usage event
|
||||
const transactionId = `manual-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
|
||||
await flowglad.createUsageEvent({
|
||||
subscriptionId: subscription.id,
|
||||
priceId: usagePrice.id,
|
||||
amount,
|
||||
transactionId,
|
||||
})
|
||||
|
||||
return json({ success: true }, 200)
|
||||
} catch (error) {
|
||||
console.error("[api/usage-events] POST error:", error)
|
||||
return json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Internal server error",
|
||||
},
|
||||
500,
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
119
packages/web/src/routes/api/usage-events.ts
Normal file
119
packages/web/src/routes/api/usage-events.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { flowglad } from "@/lib/flowglad"
|
||||
import { getAuth } from "@/lib/auth"
|
||||
import { findUsagePriceByMeterSlug } from "@/lib/billing-helpers"
|
||||
|
||||
const json = (data: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/usage-events
|
||||
*
|
||||
* Creates a usage event for the current customer.
|
||||
*
|
||||
* Body: {
|
||||
* usageMeterSlug: string; // e.g., 'ai_requests'
|
||||
* amount: number; // e.g., 1
|
||||
* transactionId?: string; // Optional: for idempotency
|
||||
* }
|
||||
*/
|
||||
export const Route = createFileRoute("/api/usage-events")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
try {
|
||||
// Authenticate user
|
||||
const session = await getAuth().api.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
|
||||
if (!session?.user) {
|
||||
return json({ error: "Unauthorized" }, 401)
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Parse and validate request body
|
||||
const body = await request.json().catch(() => ({}))
|
||||
|
||||
const { usageMeterSlug, amount, transactionId } = body as {
|
||||
usageMeterSlug?: string
|
||||
amount?: number
|
||||
transactionId?: string
|
||||
}
|
||||
|
||||
if (!usageMeterSlug || typeof usageMeterSlug !== "string") {
|
||||
return json({ error: "usageMeterSlug is required" }, 400)
|
||||
}
|
||||
|
||||
if (typeof amount !== "number" || amount <= 0 || !Number.isInteger(amount)) {
|
||||
return json({ error: "amount must be a positive integer" }, 400)
|
||||
}
|
||||
|
||||
// Get Flowglad server
|
||||
const flowgladServer = flowglad(userId)
|
||||
if (!flowgladServer) {
|
||||
return json({ error: "Billing not configured" }, 500)
|
||||
}
|
||||
|
||||
// Get billing info
|
||||
const billing = await flowgladServer.getBilling()
|
||||
|
||||
if (!billing.customer) {
|
||||
return json({ error: "Customer not found" }, 404)
|
||||
}
|
||||
|
||||
// Get current subscription
|
||||
const currentSubscription = billing.currentSubscriptions?.[0]
|
||||
if (!currentSubscription) {
|
||||
return json({ error: "No active subscription found" }, 404)
|
||||
}
|
||||
|
||||
// Find usage price for the meter
|
||||
const usagePrice = findUsagePriceByMeterSlug(
|
||||
usageMeterSlug,
|
||||
billing.pricingModel,
|
||||
)
|
||||
|
||||
if (!usagePrice) {
|
||||
return json(
|
||||
{
|
||||
error: `Usage price not found for meter: ${usageMeterSlug}`,
|
||||
},
|
||||
404,
|
||||
)
|
||||
}
|
||||
|
||||
// Generate transaction ID if not provided (for idempotency)
|
||||
const finalTransactionId =
|
||||
transactionId ??
|
||||
`usage_${Date.now()}_${Math.random().toString(36).substring(7)}`
|
||||
|
||||
// Create usage event
|
||||
const usageEvent = await flowgladServer.createUsageEvent({
|
||||
subscriptionId: currentSubscription.id,
|
||||
priceSlug: usagePrice.slug!,
|
||||
amount,
|
||||
transactionId: finalTransactionId,
|
||||
})
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
usageEvent,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[usage-events] Error:", error)
|
||||
return json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : "Failed to create usage event",
|
||||
},
|
||||
500,
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
33
packages/web/src/routes/api/users.ts
Normal file
33
packages/web/src/routes/api/users.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { getAuth } from "@/lib/auth"
|
||||
import {
|
||||
optionsResponse,
|
||||
prepareElectricUrl,
|
||||
proxyElectricRequest,
|
||||
} from "@/lib/electric-proxy"
|
||||
|
||||
const serve = async ({ request }: { request: Request }) => {
|
||||
const auth = getAuth()
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
const originUrl = prepareElectricUrl(request.url)
|
||||
originUrl.searchParams.set("table", "users")
|
||||
|
||||
return proxyElectricRequest(originUrl, request)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/users")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: serve,
|
||||
OPTIONS: ({ request }) => optionsResponse(request),
|
||||
},
|
||||
},
|
||||
})
|
||||
291
packages/web/src/routes/auth.tsx
Normal file
291
packages/web/src/routes/auth.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Mail, Apple, Github } from "lucide-react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
export const Route = createFileRoute("/auth")({
|
||||
component: AuthPage,
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
type Step = "email" | "otp"
|
||||
|
||||
function ChromeIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<line x1="21.17" y1="8" x2="12" y2="8" />
|
||||
<line x1="3.95" y1="6.06" x2="8.54" y2="14" />
|
||||
<line x1="10.88" y1="21.94" x2="15.46" y2="14" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function AuthPage() {
|
||||
const [step, setStep] = useState<Step>("email")
|
||||
const emailInputRef = useRef<HTMLInputElement>(null)
|
||||
const otpInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (step === "email") {
|
||||
emailInputRef.current?.focus()
|
||||
} else {
|
||||
otpInputRef.current?.focus()
|
||||
}
|
||||
}, [step])
|
||||
|
||||
const [email, setEmail] = useState("")
|
||||
const [otp, setOtp] = useState("")
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const handleSendOTP = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!email.trim()) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError("")
|
||||
|
||||
console.log("[auth-page] Sending OTP to:", email)
|
||||
|
||||
try {
|
||||
const result = await authClient.emailOtp.sendVerificationOtp({
|
||||
email,
|
||||
type: "sign-in",
|
||||
})
|
||||
|
||||
console.log("[auth-page] OTP result:", result)
|
||||
|
||||
if (result.error) {
|
||||
console.error("[auth-page] OTP error:", result.error)
|
||||
setError(result.error.message || "Failed to send code")
|
||||
} else {
|
||||
console.log("[auth-page] OTP sent successfully, moving to OTP step")
|
||||
setStep("otp")
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[auth-page] Send OTP exception:", err)
|
||||
setError(err instanceof Error ? err.message : "Failed to send verification code")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerifyOTP = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!otp.trim()) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError("")
|
||||
|
||||
console.log("[auth-page] Verifying OTP for:", email)
|
||||
|
||||
try {
|
||||
const result = await authClient.signIn.emailOtp({
|
||||
email,
|
||||
otp,
|
||||
})
|
||||
|
||||
console.log("[auth-page] Verify result:", result)
|
||||
|
||||
if (result.error) {
|
||||
console.error("[auth-page] Verify error:", result.error)
|
||||
setError(result.error.message || "Invalid code")
|
||||
} else {
|
||||
console.log("[auth-page] Sign in successful, redirecting...")
|
||||
window.location.href = "/"
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[auth-page] Verify OTP exception:", err)
|
||||
setError(err instanceof Error ? err.message : "Failed to verify code")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResend = async () => {
|
||||
setIsLoading(true)
|
||||
setError("")
|
||||
setOtp("")
|
||||
|
||||
console.log("[auth-page] Resending OTP to:", email)
|
||||
|
||||
try {
|
||||
const result = await authClient.emailOtp.sendVerificationOtp({
|
||||
email,
|
||||
type: "sign-in",
|
||||
})
|
||||
|
||||
console.log("[auth-page] Resend result:", result)
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error.message || "Failed to resend code")
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[auth-page] Resend exception:", err)
|
||||
setError("Failed to resend code")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
setStep("email")
|
||||
setOtp("")
|
||||
setError("")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] flex items-center justify-center px-4 py-10 text-white">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="rounded-3xl border border-white/10 bg-black/70 px-8 py-10 shadow-[0_10px_40px_rgba(0,0,0,0.45)]">
|
||||
<header className="space-y-2 text-left">
|
||||
<span className="inline-flex items-center gap-2 text-xs uppercase tracking-[0.35em] text-white/40">
|
||||
<Mail className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Welcome to Linsa!
|
||||
</span>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
{step === "email" ? "Any Generation. Instantly." : "Enter your code"}
|
||||
</h1>
|
||||
<p className="text-sm text-white/70">
|
||||
{step === "email"
|
||||
? "Text, images/video on canvas. Fancy context management. Just think it and it's there."
|
||||
: `We sent a 6-digit code to ${email}`}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{step === "email" ? (
|
||||
<form onSubmit={handleSendOTP} className="mt-8 space-y-5">
|
||||
<div className="space-y-2 text-left">
|
||||
<p className="text-sm font-medium text-white">
|
||||
Enter your email and we'll send you a verification code.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="block text-left text-xs font-semibold uppercase tracking-wide text-white/60">
|
||||
Email
|
||||
<input
|
||||
ref={emailInputRef}
|
||||
type="email"
|
||||
placeholder="you@gmail.com"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-2 w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white placeholder:text-white/40 focus:border-white/40 focus:outline-none focus:ring-0"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !email.trim()}
|
||||
className="w-full rounded-2xl bg-white px-4 py-3 text-sm font-semibold text-black transition hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isLoading ? "Sending code..." : "Send verification code"}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleVerifyOTP} className="mt-8 space-y-5">
|
||||
<label className="block text-left text-xs font-semibold uppercase tracking-wide text-white/60">
|
||||
Verification Code
|
||||
<input
|
||||
ref={otpInputRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="000000"
|
||||
required
|
||||
maxLength={6}
|
||||
value={otp}
|
||||
onChange={(e) => setOtp(e.target.value.replace(/\D/g, ""))}
|
||||
className="mt-2 w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-center text-2xl font-mono tracking-[0.5em] text-white placeholder:text-white/40 focus:border-white/40 focus:outline-none focus:ring-0"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || otp.length !== 6}
|
||||
className="w-full rounded-2xl bg-white px-4 py-3 text-sm font-semibold text-black transition hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isLoading ? "Verifying..." : "Sign in"}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
className="text-white/60 hover:text-white transition"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
disabled={isLoading}
|
||||
className="text-white/60 hover:text-white transition disabled:opacity-50"
|
||||
>
|
||||
Resend code
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-8 border-t border-white/10 pt-6">
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-white/40">
|
||||
Coming soon
|
||||
</p>
|
||||
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="flex items-center justify-center gap-2 rounded-2xl border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium text-white/70 transition hover:bg-white/10 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Apple className="h-4 w-4" aria-hidden="true" />
|
||||
Apple
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="flex items-center justify-center gap-2 rounded-2xl border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium text-white/70 transition hover:bg-white/10 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChromeIcon className="h-4 w-4" />
|
||||
Google
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="flex items-center justify-center gap-2 rounded-2xl border border-white/10 bg-white/5 px-3 py-2 text-xs font-medium text-white/70 transition hover:bg-white/10 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Github className="h-4 w-4" aria-hidden="true" />
|
||||
GitHub
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
7
packages/web/src/routes/blocks.tsx
Normal file
7
packages/web/src/routes/blocks.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import BlockPage from "@/components/blocks/BlockPage"
|
||||
|
||||
export const Route = createFileRoute("/blocks")({
|
||||
ssr: false,
|
||||
component: BlockPage,
|
||||
})
|
||||
95
packages/web/src/routes/canvas.$canvasId.tsx
Normal file
95
packages/web/src/routes/canvas.$canvasId.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { createFileRoute, Link } from "@tanstack/react-router"
|
||||
|
||||
import { BladeCanvasExperience } from "@/features/canvas/BladeCanvasExperience"
|
||||
import { fetchCanvasSnapshot } from "@/lib/canvas/client"
|
||||
import type { SerializedCanvas } from "@/lib/canvas/types"
|
||||
|
||||
export const Route = createFileRoute("/canvas/$canvasId")({
|
||||
ssr: false,
|
||||
component: CanvasDetailPage,
|
||||
})
|
||||
|
||||
function CanvasDetailPage() {
|
||||
const { canvasId } = Route.useParams()
|
||||
const [snapshot, setSnapshot] = useState<SerializedCanvas | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await fetchCanvasSnapshot(canvasId)
|
||||
if (active) {
|
||||
setSnapshot(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[canvas] failed to load snapshot", err)
|
||||
if (active) {
|
||||
setError("Unable to open this canvas")
|
||||
setSnapshot(null)
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [canvasId])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-[#03050a] text-white/70">
|
||||
<p className="text-xs uppercase tracking-[0.4em]">Loading canvas…</p>
|
||||
<Link
|
||||
to="/canvas"
|
||||
className="text-[11px] uppercase tracking-[0.3em] text-white/40 hover:text-white"
|
||||
>
|
||||
Back to projects
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !snapshot) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-[#03050a] text-white">
|
||||
<p className="text-lg text-white/80">{error ?? "Canvas not found."}</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-white/10 px-6 py-2 text-xs font-semibold uppercase tracking-[0.3em] text-white backdrop-blur transition hover:bg-white/20"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<Link
|
||||
to="/canvas"
|
||||
className="rounded-full border border-white/30 px-6 py-2 text-xs font-semibold uppercase tracking-[0.3em] text-white/80 transition hover:border-white hover:text-white"
|
||||
>
|
||||
Projects
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen overflow-hidden bg-[#01040d]">
|
||||
<BladeCanvasExperience
|
||||
initialCanvas={snapshot.canvas}
|
||||
initialImages={snapshot.images}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
238
packages/web/src/routes/canvas.index.tsx
Normal file
238
packages/web/src/routes/canvas.index.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
|
||||
|
||||
import {
|
||||
createCanvasProject,
|
||||
fetchCanvasList,
|
||||
} from "@/lib/canvas/client"
|
||||
import type {
|
||||
SerializedCanvas,
|
||||
SerializedCanvasSummary,
|
||||
} from "@/lib/canvas/types"
|
||||
|
||||
export const Route = createFileRoute("/canvas/")({
|
||||
ssr: false,
|
||||
component: CanvasProjectsPage,
|
||||
})
|
||||
|
||||
function summarize(snapshot: SerializedCanvas): SerializedCanvasSummary {
|
||||
return {
|
||||
canvas: snapshot.canvas,
|
||||
previewImage: snapshot.images[0] ?? null,
|
||||
imageCount: snapshot.images.length,
|
||||
}
|
||||
}
|
||||
|
||||
function CanvasProjectsPage() {
|
||||
const [projects, setProjects] = useState<SerializedCanvasSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
const loadProjects = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await fetchCanvasList()
|
||||
setProjects(data)
|
||||
} catch (err) {
|
||||
console.error("[canvas] failed to load projects", err)
|
||||
setError("Failed to load projects")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadProjects()
|
||||
}, [loadProjects])
|
||||
|
||||
const handleCreateProject = useCallback(async () => {
|
||||
if (creating) {
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
setError(null)
|
||||
try {
|
||||
const snapshot = await createCanvasProject()
|
||||
const summary = summarize(snapshot)
|
||||
setProjects((prev) => [summary, ...prev])
|
||||
navigate({ to: "/canvas/$canvasId", params: { canvasId: snapshot.canvas.id } })
|
||||
} catch (err) {
|
||||
console.error("[canvas] failed to create project", err)
|
||||
setError("Unable to create a new project")
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}, [creating, navigate])
|
||||
|
||||
const showSkeletonGrid = loading && projects.length === 0
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#030611] px-6 py-10 text-white">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-8">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4 border-b border-white/5 pb-6">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.4em] text-white/50">
|
||||
Canvas
|
||||
</p>
|
||||
<h1 className="mt-2 text-4xl font-semibold tracking-tight">
|
||||
My Projects
|
||||
</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm text-white/60">
|
||||
Choose a canvas to continue exploring ideas. Each project preserves
|
||||
its own layout, prompts, and styles.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 text-sm text-white/60">
|
||||
{loading ? (
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-white/40">
|
||||
Loading…
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-white/90 px-5 py-2 text-sm font-semibold uppercase tracking-[0.3em] text-slate-900 transition hover:bg-white disabled:cursor-not-allowed disabled:bg-white/40"
|
||||
onClick={handleCreateProject}
|
||||
disabled={creating}
|
||||
>
|
||||
{creating ? "Creating" : "New Project"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<div className="flex items-center justify-between rounded-xl border border-red-400/40 bg-red-500/10 px-4 py-3 text-sm text-red-100">
|
||||
<span>{error}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-red-200/40 px-3 py-1 text-xs uppercase tracking-[0.2em]"
|
||||
onClick={() => void loadProjects()}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showSkeletonGrid ? (
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-64 rounded-3xl border border-white/5 bg-white/5/50 animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ProjectsGrid projects={projects} />
|
||||
)}
|
||||
|
||||
{!projects.length && !loading ? (
|
||||
<div className="rounded-3xl border border-dashed border-white/20 bg-white/5/20 px-10 py-12 text-center text-white/70">
|
||||
<p className="text-lg font-semibold">You don't have any projects yet.</p>
|
||||
<p className="mt-2 text-sm">
|
||||
Start a new canvas to begin planning, brainstorming, or designing.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-6 rounded-full border border-white/40 px-5 py-2 text-xs font-semibold uppercase tracking-[0.3em]"
|
||||
onClick={handleCreateProject}
|
||||
disabled={creating}
|
||||
>
|
||||
{creating ? "Creating…" : "Create your first project"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectsGrid({
|
||||
projects,
|
||||
}: {
|
||||
projects: SerializedCanvasSummary[]
|
||||
}) {
|
||||
if (!projects.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<CanvasProjectCard key={project.canvas.id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CanvasProjectCard({
|
||||
project,
|
||||
}: {
|
||||
project: SerializedCanvasSummary
|
||||
}) {
|
||||
const previewUrl = useMemo(() => {
|
||||
const preview = project.previewImage
|
||||
if (!preview) {
|
||||
return null
|
||||
}
|
||||
if (preview.imageUrl) {
|
||||
return preview.imageUrl
|
||||
}
|
||||
if (preview.imageData) {
|
||||
const mime =
|
||||
preview.metadata && typeof preview.metadata.mimeType === "string"
|
||||
? (preview.metadata.mimeType as string)
|
||||
: "image/png"
|
||||
return `data:${mime};base64,${preview.imageData}`
|
||||
}
|
||||
return null
|
||||
}, [project.previewImage])
|
||||
|
||||
const imageCountLabel = project.imageCount === 1 ? "image" : "images"
|
||||
const updatedAt = useMemo(() => {
|
||||
const date = new Date(project.canvas.updatedAt)
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}, [project.canvas.updatedAt])
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/canvas/$canvasId"
|
||||
params={{ canvasId: project.canvas.id }}
|
||||
className="group relative flex h-64 flex-col overflow-hidden rounded-3xl border border-white/10 bg-white/5 shadow-2xl ring-1 ring-white/5 transition hover:-translate-y-1 hover:border-white/30 hover:ring-white/20"
|
||||
>
|
||||
<div className="relative flex-1 bg-gradient-to-br from-slate-800 via-slate-900 to-black">
|
||||
{previewUrl ? (
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center transition duration-500 group-hover:scale-105"
|
||||
style={{ backgroundImage: `url(${previewUrl})` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-xs uppercase tracking-[0.3em] text-white/40">
|
||||
No preview yet
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/40 to-transparent" />
|
||||
</div>
|
||||
<div className="relative z-10 space-y-1 px-4 pb-4 pt-3">
|
||||
<div className="flex items-center justify-between text-xs uppercase tracking-[0.3em] text-white/60">
|
||||
<span>{updatedAt}</span>
|
||||
<span>
|
||||
{project.imageCount} {imageCountLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-white">
|
||||
{project.canvas.name}
|
||||
</p>
|
||||
<p className="text-xs text-white/60">
|
||||
Tap to open canvas
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
10
packages/web/src/routes/canvas.tsx
Normal file
10
packages/web/src/routes/canvas.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createFileRoute, Outlet } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/canvas")({
|
||||
ssr: false,
|
||||
component: CanvasLayout,
|
||||
})
|
||||
|
||||
function CanvasLayout() {
|
||||
return <Outlet />
|
||||
}
|
||||
25
packages/web/src/routes/chat.tsx
Normal file
25
packages/web/src/routes/chat.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import {
|
||||
chatThreadsCollection,
|
||||
chatMessagesCollection,
|
||||
} from "@/lib/collections"
|
||||
import { ChatPage } from "@/components/chat/ChatPage"
|
||||
|
||||
export const Route = createFileRoute("/chat")({
|
||||
ssr: false,
|
||||
beforeLoad: async () => {
|
||||
const session = await authClient.getSession()
|
||||
if (!session.data?.session) {
|
||||
throw redirect({ to: "/login" })
|
||||
}
|
||||
},
|
||||
loader: async () => {
|
||||
await Promise.all([
|
||||
chatThreadsCollection.preload(),
|
||||
chatMessagesCollection.preload(),
|
||||
])
|
||||
return null
|
||||
},
|
||||
component: ChatPage,
|
||||
})
|
||||
10
packages/web/src/routes/demo/api.names.ts
Normal file
10
packages/web/src/routes/demo/api.names.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { json } from "@tanstack/react-start"
|
||||
|
||||
export const Route = createFileRoute("/demo/api/names")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: () => json(["Alice", "Bob", "Charlie"]),
|
||||
},
|
||||
},
|
||||
})
|
||||
44
packages/web/src/routes/demo/start.api-request.tsx
Normal file
44
packages/web/src/routes/demo/start.api-request.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
function getNames() {
|
||||
return fetch("/demo/api/names").then((res) => res.json() as Promise<string[]>)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/demo/start/api-request")({
|
||||
component: Home,
|
||||
})
|
||||
|
||||
function Home() {
|
||||
const [names, setNames] = useState<Array<string>>([])
|
||||
|
||||
useEffect(() => {
|
||||
getNames().then(setNames)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center min-h-screen p-4 text-white"
|
||||
style={{
|
||||
backgroundColor: "#000",
|
||||
backgroundImage:
|
||||
"radial-gradient(ellipse 60% 60% at 0% 100%, #444 0%, #222 60%, #000 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-2xl p-8 rounded-xl backdrop-blur-md bg-black/50 shadow-xl border-8 border-black/10">
|
||||
<h1 className="text-2xl mb-4">Start API Request Demo - Names List</h1>
|
||||
<ul className="mb-4 space-y-2">
|
||||
{names.map((name) => (
|
||||
<li
|
||||
key={name}
|
||||
className="bg-white/10 border border-white/20 rounded-lg p-3 backdrop-blur-sm shadow-md"
|
||||
>
|
||||
<span className="text-lg text-white">{name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
packages/web/src/routes/demo/start.server-funcs.tsx
Normal file
41
packages/web/src/routes/demo/start.server-funcs.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useState } from "react"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { createServerFn } from "@tanstack/react-start"
|
||||
|
||||
const getCurrentServerTime = createServerFn({
|
||||
method: "GET",
|
||||
}).handler(async () => await new Date().toISOString())
|
||||
|
||||
export const Route = createFileRoute("/demo/start/server-funcs")({
|
||||
component: Home,
|
||||
loader: async () => await getCurrentServerTime(),
|
||||
})
|
||||
|
||||
function Home() {
|
||||
const originalTime = Route.useLoaderData()
|
||||
const [time, setTime] = useState(originalTime)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center min-h-screen bg-gradient-to-br from-zinc-800 to-black p-4 text-white"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(50% 50% at 20% 60%, #23272a 0%, #18181b 50%, #000000 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-2xl p-8 rounded-xl backdrop-blur-md bg-black/50 shadow-xl border-8 border-black/10">
|
||||
<h1 className="text-2xl mb-4">Start Server Functions - Server Time</h1>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-xl">Starting Time: {originalTime}</div>
|
||||
<div className="text-xl">Current Time: {time}</div>
|
||||
<button
|
||||
className="bg-blue-500 hover:bg-blue-600 disabled:bg-blue-500/50 disabled:cursor-not-allowed text-white font-bold py-3 px-4 rounded-lg transition-colors"
|
||||
onClick={async () => setTime(await getCurrentServerTime())}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
packages/web/src/routes/demo/start.ssr.data-only.tsx
Normal file
41
packages/web/src/routes/demo/start.ssr.data-only.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { getPunkSongs } from "@/data/demo.punk-songs"
|
||||
|
||||
export const Route = createFileRoute("/demo/start/ssr/data-only")({
|
||||
ssr: "data-only",
|
||||
component: RouteComponent,
|
||||
loader: async () => await getPunkSongs(),
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
const punkSongs = Route.useLoaderData()
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center min-h-screen bg-gradient-to-br from-zinc-800 to-black p-4 text-white"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(50% 50% at 20% 60%, #1a1a1a 0%, #0a0a0a 50%, #000000 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-2xl p-8 rounded-xl backdrop-blur-md bg-black/50 shadow-xl border-8 border-black/10">
|
||||
<h1 className="text-3xl font-bold mb-6 text-pink-400">
|
||||
Data Only SSR - Punk Songs
|
||||
</h1>
|
||||
<ul className="space-y-3">
|
||||
{punkSongs.map((song) => (
|
||||
<li
|
||||
key={song.id}
|
||||
className="bg-white/10 border border-white/20 rounded-lg p-4 backdrop-blur-sm shadow-md"
|
||||
>
|
||||
<span className="text-lg text-white font-medium">
|
||||
{song.name}
|
||||
</span>
|
||||
<span className="text-white/60"> - {song.artist}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
packages/web/src/routes/demo/start.ssr.full-ssr.tsx
Normal file
40
packages/web/src/routes/demo/start.ssr.full-ssr.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { getPunkSongs } from "@/data/demo.punk-songs"
|
||||
|
||||
export const Route = createFileRoute("/demo/start/ssr/full-ssr")({
|
||||
component: RouteComponent,
|
||||
loader: async () => await getPunkSongs(),
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
const punkSongs = Route.useLoaderData()
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center min-h-screen bg-gradient-to-br from-zinc-800 to-black p-4 text-white"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(50% 50% at 20% 60%, #1a1a1a 0%, #0a0a0a 50%, #000000 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-2xl p-8 rounded-xl backdrop-blur-md bg-black/50 shadow-xl border-8 border-black/10">
|
||||
<h1 className="text-3xl font-bold mb-6 text-purple-400">
|
||||
Full SSR - Punk Songs
|
||||
</h1>
|
||||
<ul className="space-y-3">
|
||||
{punkSongs.map((song) => (
|
||||
<li
|
||||
key={song.id}
|
||||
className="bg-white/10 border border-white/20 rounded-lg p-4 backdrop-blur-sm shadow-md"
|
||||
>
|
||||
<span className="text-lg text-white font-medium">
|
||||
{song.name}
|
||||
</span>
|
||||
<span className="text-white/60"> - {song.artist}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
43
packages/web/src/routes/demo/start.ssr.index.tsx
Normal file
43
packages/web/src/routes/demo/start.ssr.index.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/demo/start/ssr/")({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center min-h-screen bg-gradient-to-br from-zinc-900 to-black p-4 text-white"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(50% 50% at 20% 60%, #1a1a1a 0%, #0a0a0a 50%, #000000 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-2xl p-8 rounded-xl backdrop-blur-md bg-black/50 shadow-xl border-8 border-black/10">
|
||||
<h1 className="text-4xl font-bold mb-8 text-center bg-gradient-to-r from-pink-500 via-purple-500 to-green-400 bg-clip-text text-transparent">
|
||||
SSR Demos
|
||||
</h1>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Link
|
||||
to="/demo/start/ssr/spa-mode"
|
||||
className="text-2xl font-bold py-6 px-8 rounded-lg bg-gradient-to-r from-pink-600 to-pink-500 hover:from-pink-700 hover:to-pink-600 text-white text-center shadow-lg transform transition-all hover:scale-105 hover:shadow-pink-500/50 border-2 border-pink-400"
|
||||
>
|
||||
SPA Mode
|
||||
</Link>
|
||||
<Link
|
||||
to="/demo/start/ssr/full-ssr"
|
||||
className="text-2xl font-bold py-6 px-8 rounded-lg bg-gradient-to-r from-purple-600 to-purple-500 hover:from-purple-700 hover:to-purple-600 text-white text-center shadow-lg transform transition-all hover:scale-105 hover:shadow-purple-500/50 border-2 border-purple-400"
|
||||
>
|
||||
Full SSR
|
||||
</Link>
|
||||
<Link
|
||||
to="/demo/start/ssr/data-only"
|
||||
className="text-2xl font-bold py-6 px-8 rounded-lg bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white text-center shadow-lg transform transition-all hover:scale-105 hover:shadow-green-500/50 border-2 border-green-400"
|
||||
>
|
||||
Data Only
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
packages/web/src/routes/demo/start.ssr.spa-mode.tsx
Normal file
47
packages/web/src/routes/demo/start.ssr.spa-mode.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { getPunkSongs } from "@/data/demo.punk-songs"
|
||||
|
||||
export const Route = createFileRoute("/demo/start/ssr/spa-mode")({
|
||||
ssr: false,
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
const [punkSongs, setPunkSongs] = useState<
|
||||
Awaited<ReturnType<typeof getPunkSongs>>
|
||||
>([])
|
||||
|
||||
useEffect(() => {
|
||||
getPunkSongs().then(setPunkSongs)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center min-h-screen bg-gradient-to-br from-zinc-800 to-black p-4 text-white"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(50% 50% at 20% 60%, #1a1a1a 0%, #0a0a0a 50%, #000000 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-2xl p-8 rounded-xl backdrop-blur-md bg-black/50 shadow-xl border-8 border-black/10">
|
||||
<h1 className="text-3xl font-bold mb-6 text-green-400">
|
||||
SPA Mode - Punk Songs
|
||||
</h1>
|
||||
<ul className="space-y-3">
|
||||
{punkSongs.map((song) => (
|
||||
<li
|
||||
key={song.id}
|
||||
className="bg-white/10 border border-white/20 rounded-lg p-4 backdrop-blur-sm shadow-md"
|
||||
>
|
||||
<span className="text-lg text-white font-medium">
|
||||
{song.name}
|
||||
</span>
|
||||
<span className="text-white/60"> - {song.artist}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
packages/web/src/routes/i.1focus-demo.tsx
Normal file
10
packages/web/src/routes/i.1focus-demo.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/i/1focus-demo")({
|
||||
beforeLoad: () => {
|
||||
throw redirect({
|
||||
href: "https://pub-43de6862e2764ff2970a4b87f1fc7578.r2.dev/1f-demo.mp4",
|
||||
})
|
||||
},
|
||||
component: () => null,
|
||||
})
|
||||
31
packages/web/src/routes/index.tsx
Normal file
31
packages/web/src/routes/index.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { ShaderBackground } from "@/components/ShaderBackground"
|
||||
|
||||
function LandingPage() {
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-black text-white">
|
||||
<ShaderBackground />
|
||||
<div className="relative z-10 flex flex-col items-center">
|
||||
<h1 className="text-6xl font-bold tracking-tight drop-shadow-2xl">
|
||||
Linsa
|
||||
</h1>
|
||||
<p className="mt-4 text-xl text-white/80 drop-shadow-lg">
|
||||
Save anything privately. Share it.
|
||||
</p>
|
||||
<p className="mt-8 text-sm text-white/50">Coming Soon</p>
|
||||
<a
|
||||
href="https://x.com/linsa_io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-6 text-sm text-white/60 transition-colors hover:text-white"
|
||||
>
|
||||
@linsa_io
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: LandingPage,
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user