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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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