codenuk_frontend_mine/src/components/ai-side-panel.tsx

216 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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.",
"Ecommerce 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>
)
}