216 lines
6.5 KiB
TypeScript
216 lines
6.5 KiB
TypeScript
"use client"
|
||
|
||
import { useMemo, useState } from "react"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Textarea } from "@/components/ui/textarea"
|
||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||
import { cn } from "@/lib/utils"
|
||
|
||
type Block = {
|
||
id: string
|
||
label: string
|
||
x: number
|
||
y: number
|
||
w: number
|
||
h: number
|
||
}
|
||
|
||
export type WireframePlan = {
|
||
version: number
|
||
blocks: Block[]
|
||
}
|
||
|
||
export function AISidePanel({
|
||
onGenerate,
|
||
onClear,
|
||
className,
|
||
}: {
|
||
onGenerate: (plan: WireframePlan) => void
|
||
onClear: () => void
|
||
className?: string
|
||
}) {
|
||
const [collapsed, setCollapsed] = useState(false)
|
||
const [prompt, setPrompt] = useState(
|
||
"Dashboard with header, left sidebar, 3 stats cards, a line chart and a data table, plus footer.",
|
||
)
|
||
const [version, setVersion] = useState(1)
|
||
|
||
const examples = useMemo(
|
||
() => [
|
||
"Landing page with header, hero, 3x2 feature grid, and footer.",
|
||
"Settings screen: header, list of toggles, and save button.",
|
||
"E‑commerce product page: header, 2-column gallery/details, reviews, sticky add-to-cart.",
|
||
"Dashboard: header, left sidebar, 3 stats cards, line chart, data table, footer.",
|
||
"Signup page: header, 2-column form, callout, submit button.",
|
||
],
|
||
[],
|
||
)
|
||
|
||
function parsePromptToPlan(input: string): WireframePlan {
|
||
const W = 1200
|
||
const H = 800
|
||
const P = 24
|
||
const blocks: Block[] = []
|
||
let y = P
|
||
let x = P
|
||
let width = W - 2 * P
|
||
|
||
const lower = input.toLowerCase()
|
||
const addBlock = (label: string, bx: number, by: number, bw: number, bh: number) => {
|
||
blocks.push({
|
||
id: `${label}-${Math.random().toString(36).slice(2, 8)}`,
|
||
label,
|
||
x: Math.round(bx),
|
||
y: Math.round(by),
|
||
w: Math.round(bw),
|
||
h: Math.round(bh),
|
||
})
|
||
}
|
||
|
||
if (/\bheader\b/.test(lower) || /\bnavbar\b/.test(lower)) {
|
||
addBlock("Header", x, y, width, 72)
|
||
y += 72 + P
|
||
}
|
||
|
||
let hasSidebar = false
|
||
if (/\bsidebar\b/.test(lower) || /\bleft sidebar\b/.test(lower)) {
|
||
hasSidebar = true
|
||
addBlock("Sidebar", x, y, 260, H - y - P)
|
||
x += 260 + P
|
||
width = W - x - P
|
||
}
|
||
|
||
if (/\bhero\b/.test(lower)) {
|
||
addBlock("Hero", x, y, width, 200)
|
||
y += 200 + P
|
||
}
|
||
|
||
if (/stats?\b/.test(lower) || /\bcards?\b/.test(lower)) {
|
||
const cols = /4/.test(lower) ? 4 : 3
|
||
const gap = P
|
||
const cardW = (width - gap * (cols - 1)) / cols
|
||
const cardH = 100
|
||
for (let i = 0; i < cols; i++) {
|
||
addBlock(`Card ${i + 1}`, x + i * (cardW + gap), y, cardW, cardH)
|
||
}
|
||
y += cardH + P
|
||
}
|
||
|
||
const gridMatch = lower.match(/(\d)\s*x\s*(\d)/)
|
||
if (gridMatch) {
|
||
const cols = Number.parseInt(gridMatch[1], 10)
|
||
const rows = Number.parseInt(gridMatch[2], 10)
|
||
const gap = P
|
||
const cellW = (width - gap * (cols - 1)) / cols
|
||
const cellH = 120
|
||
for (let r = 0; r < rows; r++) {
|
||
for (let c = 0; c < cols; c++) {
|
||
addBlock(`Feature ${r * cols + c + 1}`, x + c * (cellW + gap), y + r * (cellH + gap), cellW, cellH)
|
||
}
|
||
}
|
||
y += rows * cellH + (rows - 1) * gap + P
|
||
}
|
||
|
||
if (/\b2[-\s]?column\b/.test(lower) || /gallery\/details/.test(lower) || /gallery/.test(lower)) {
|
||
const gap = P
|
||
const colW = (width - gap) / 2
|
||
const colH = 260
|
||
addBlock("Left Column", x, y, colW, colH)
|
||
addBlock("Right Column", x + colW + gap, y, colW, colH)
|
||
y += colH + P
|
||
}
|
||
|
||
if (/\bchart\b/.test(lower) || /\bline chart\b/.test(lower)) {
|
||
addBlock("Chart", x, y, width, 220)
|
||
y += 220 + P
|
||
}
|
||
if (/\btable\b/.test(lower)) {
|
||
addBlock("Data Table", x, y, width, 260)
|
||
y += 260 + P
|
||
}
|
||
|
||
if (/\bform\b/.test(lower) || /\bsignup\b/.test(lower) || /\blogin\b/.test(lower)) {
|
||
const gap = P
|
||
const twoCol = /\b2[-\s]?column\b/.test(lower)
|
||
if (twoCol) {
|
||
const colW = (width - gap) / 2
|
||
addBlock("Form Left", x, y, colW, 260)
|
||
addBlock("Form Right", x + colW + gap, y, colW, 260)
|
||
y += 260 + P
|
||
} else {
|
||
addBlock("Form", x, y, width, 220)
|
||
y += 220 + P
|
||
}
|
||
}
|
||
|
||
if (/\bfooter\b/.test(lower)) {
|
||
addBlock("Footer", P, H - 80 - P, W - 2 * P, 80)
|
||
}
|
||
|
||
return { version: Date.now() + version, blocks }
|
||
}
|
||
|
||
const handleGenerate = () => {
|
||
const plan = parsePromptToPlan(prompt)
|
||
setVersion((v) => v + 1)
|
||
onGenerate(plan)
|
||
}
|
||
|
||
return (
|
||
<aside
|
||
className={cn(
|
||
"h-full border-l bg-white dark:bg-neutral-900 flex flex-col",
|
||
collapsed ? "w-12" : "w-96",
|
||
className,
|
||
)}
|
||
aria-label="AI prompt side panel"
|
||
>
|
||
<div className="flex items-center justify-between px-3 py-2 border-b">
|
||
<h2 className={cn("text-sm font-medium text-balance", collapsed && "sr-only")}>AI Wireframe</h2>
|
||
<Button variant="ghost" size="icon" onClick={() => setCollapsed((c) => !c)} aria-label="Toggle panel">
|
||
{collapsed ? <span aria-hidden>›</span> : <span aria-hidden>‹</span>}
|
||
</Button>
|
||
</div>
|
||
|
||
{!collapsed && (
|
||
<div className="flex flex-col gap-3 p-3">
|
||
<label className="text-xs font-medium">Prompt</label>
|
||
<Textarea
|
||
value={prompt}
|
||
onChange={(e) => setPrompt(e.target.value)}
|
||
placeholder="Describe your screen: Landing with header, hero, 3x2 features, footer"
|
||
className="min-h-28"
|
||
/>
|
||
<div className="flex gap-2">
|
||
<Button onClick={handleGenerate} className="flex-1">
|
||
Generate
|
||
</Button>
|
||
<Button variant="secondary" onClick={onClear}>
|
||
Clear
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="pt-1">
|
||
<p className="text-xs font-medium mb-2">Examples</p>
|
||
<ScrollArea className="h-40 border rounded">
|
||
<ul className="p-2 space-y-2">
|
||
{examples.map((ex) => (
|
||
<li key={ex}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPrompt(ex)}
|
||
className="text-left text-xs w-full hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded px-2 py-1"
|
||
>
|
||
{ex}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</ScrollArea>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</aside>
|
||
)
|
||
}
|