Updated frontend codebase
This commit is contained in:
parent
ff99b4df53
commit
81ef6e8954
1972
package-lock.json
generated
1972
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -21,6 +21,7 @@
|
|||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
|
"@tldraw/tldraw": "^3.15.4",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@ -28,6 +29,7 @@
|
|||||||
"next": "15.4.6",
|
"next": "15.4.6",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"svg-path-parser": "^1.1.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
@ -37,6 +39,7 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/svg-path-parser": "^1.1.6",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.4.6",
|
"eslint-config-next": "15.4.6",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|||||||
215
src/components/ai-side-panel.tsx
Normal file
215
src/components/ai-side-panel.tsx
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
"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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -31,7 +31,6 @@ export function SignUpForm({ onSignUpSuccess }: SignUpFormProps) {
|
|||||||
role: "user" // default role, adjust as needed
|
role: "user" // default role, adjust as needed
|
||||||
})
|
})
|
||||||
|
|
||||||
const { signup } = useAuth()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
|||||||
@ -7,13 +7,15 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { ArrowRight, Plus, Globe, BarChart3, Zap, Code, Search, Star, Clock, Users, Layers, AlertCircle, Edit, Trash2 } from "lucide-react"
|
import { ArrowRight, Plus, Globe, BarChart3, Zap, Code, Search, Star, Clock, Users, Layers, AlertCircle, Edit, Trash2, Palette } from "lucide-react"
|
||||||
import { useTemplates } from "@/hooks/useTemplates"
|
import { useTemplates } from "@/hooks/useTemplates"
|
||||||
import { CustomTemplateForm } from "@/components/custom-template-form"
|
import { CustomTemplateForm } from "@/components/custom-template-form"
|
||||||
import { EditTemplateForm } from "@/components/edit-template-form"
|
import { EditTemplateForm } from "@/components/edit-template-form"
|
||||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
|
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
|
||||||
import { DatabaseTemplate, TemplateFeature } from "@/lib/template-service"
|
import { DatabaseTemplate, TemplateFeature } from "@/lib/template-service"
|
||||||
import AICustomFeatureCreator from "@/components/ai/AICustomFeatureCreator"
|
import AICustomFeatureCreator from "@/components/ai/AICustomFeatureCreator"
|
||||||
|
import WireframeCanvas from "@/components/wireframe-canvas"
|
||||||
|
import PromptSidePanel from "@/components/prompt-side-panel"
|
||||||
|
|
||||||
interface Template {
|
interface Template {
|
||||||
id: string
|
id: string
|
||||||
@ -934,22 +936,315 @@ function TechStackSummaryStep({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI Mockup Step Component
|
||||||
|
function AIMockupStep({
|
||||||
|
template,
|
||||||
|
selectedFeatures,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
template: Template;
|
||||||
|
selectedFeatures: TemplateFeature[];
|
||||||
|
onNext: () => void;
|
||||||
|
onBack: () => void
|
||||||
|
}) {
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
const [wireframeData, setWireframeData] = useState<any>(null)
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
|
const [selectedDevice, setSelectedDevice] = useState<'desktop' | 'tablet' | 'mobile'>('desktop')
|
||||||
|
|
||||||
|
// Load state from localStorage after component mounts
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
|
||||||
|
// Load device type
|
||||||
|
const savedDevice = localStorage.getItem('wireframe_device_type')
|
||||||
|
if (savedDevice && ['desktop', 'tablet', 'mobile'].includes(savedDevice)) {
|
||||||
|
setSelectedDevice(savedDevice as 'desktop' | 'tablet' | 'mobile')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load wireframe data
|
||||||
|
const savedWireframeData = localStorage.getItem('wireframe_data')
|
||||||
|
if (savedWireframeData) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(savedWireframeData)
|
||||||
|
setWireframeData(parsed)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse saved wireframe data:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleWireframeGenerated = (data: any) => {
|
||||||
|
setWireframeData(data)
|
||||||
|
setIsGenerating(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWireframeGenerationStart = () => {
|
||||||
|
setIsGenerating(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeviceChange = (device: 'desktop' | 'tablet' | 'mobile') => {
|
||||||
|
console.log('DEBUG: AIMockupStep handleDeviceChange called with:', device)
|
||||||
|
console.log('DEBUG: Previous selectedDevice state:', selectedDevice)
|
||||||
|
|
||||||
|
setSelectedDevice(device)
|
||||||
|
|
||||||
|
// Save to localStorage (only after mounting)
|
||||||
|
if (mounted) {
|
||||||
|
localStorage.setItem('wireframe_device_type', device)
|
||||||
|
console.log('DEBUG: Saved device type to localStorage:', device)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('DEBUG: New selectedDevice state:', device)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save wireframe data to localStorage when it changes (only after mounting)
|
||||||
|
useEffect(() => {
|
||||||
|
if (wireframeData && mounted) {
|
||||||
|
localStorage.setItem('wireframe_data', JSON.stringify(wireframeData))
|
||||||
|
}
|
||||||
|
}, [wireframeData, mounted])
|
||||||
|
|
||||||
|
// Debug: Log when selectedDevice prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('DEBUG: AIMockupStep selectedDevice state changed to:', selectedDevice)
|
||||||
|
}, [selectedDevice])
|
||||||
|
|
||||||
|
// Debug: Log when selectedDevice prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('DEBUG: WireframeCanvas selectedDevice prop changed to:', selectedDevice)
|
||||||
|
}, [selectedDevice])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<h1 className="text-4xl font-bold text-white">AI Wireframe Mockup</h1>
|
||||||
|
<p className="text-xl text-white/60 max-w-3xl mx-auto">
|
||||||
|
Generate and customize wireframes for {template.title} using AI-powered design tools
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-center gap-2 text-orange-400">
|
||||||
|
<Palette className="h-5 w-5" />
|
||||||
|
<span className="text-sm font-medium">AI-Powered Wireframe Generation</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Wireframe Canvas and Panel */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-xl overflow-hidden" style={{ height: '600px' }}>
|
||||||
|
<div className="flex h-full">
|
||||||
|
{/* Main Canvas Area */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<WireframeCanvas
|
||||||
|
className="h-full w-full"
|
||||||
|
onWireframeGenerated={handleWireframeGenerated}
|
||||||
|
onGenerationStart={handleWireframeGenerationStart}
|
||||||
|
selectedDevice={selectedDevice}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Prompt Panel */}
|
||||||
|
<PromptSidePanel
|
||||||
|
className="border-l border-white/10"
|
||||||
|
selectedDevice={selectedDevice}
|
||||||
|
onDeviceChange={handleDeviceChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Wireframe Status and Controls */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-xl p-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-white mb-2">
|
||||||
|
{wireframeData ? '✅' : '⏳'}
|
||||||
|
</div>
|
||||||
|
<div className="text-white/80 font-medium">
|
||||||
|
{wireframeData ? 'Wireframe Generated' : 'Ready to Generate'}
|
||||||
|
</div>
|
||||||
|
<div className="text-white/60 text-sm">
|
||||||
|
{wireframeData ? 'Click Continue to proceed' : 'Use the AI panel to create wireframes'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-white mb-2">
|
||||||
|
{selectedFeatures.length}
|
||||||
|
</div>
|
||||||
|
<div className="text-white/80 font-medium">Features Selected</div>
|
||||||
|
<div className="text-white/60 text-sm">
|
||||||
|
{template.title} template
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-white mb-2">
|
||||||
|
{isGenerating ? '🔄' : '🎨'}
|
||||||
|
</div>
|
||||||
|
<div className="text-white/80 font-medium">
|
||||||
|
{isGenerating ? 'Generating...' : 'AI Ready'}
|
||||||
|
</div>
|
||||||
|
<div className="text-white/60 text-sm">
|
||||||
|
{isGenerating ? 'Creating wireframe layout' : 'Claude AI powered'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="text-center py-6">
|
||||||
|
<div className="space-x-4">
|
||||||
|
<Button variant="outline" onClick={onBack} className="border-white/20 text-white hover:bg-white/10">
|
||||||
|
← Back to Features
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!wireframeData}
|
||||||
|
className={`bg-orange-500 hover:bg-orange-400 text-black font-semibold py-2 rounded-lg shadow ${
|
||||||
|
!wireframeData ? 'opacity-50 cursor-not-allowed' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Continue to Business Context →
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="text-white/60 text-sm mt-2">
|
||||||
|
{wireframeData
|
||||||
|
? 'Wireframe generated successfully! Continue to define business requirements.'
|
||||||
|
: 'Generate a wireframe first to continue to the next step.'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Main Dashboard Component
|
// Main Dashboard Component
|
||||||
export function MainDashboard() {
|
export function MainDashboard() {
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
const [currentStep, setCurrentStep] = useState(1)
|
const [currentStep, setCurrentStep] = useState(1)
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null)
|
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(() => {
|
||||||
const [selectedFeatures, setSelectedFeatures] = useState<TemplateFeature[]>([])
|
// Only access localStorage after component mounts to prevent hydration mismatch
|
||||||
const [finalProjectData, setFinalProjectData] = useState<any>(null)
|
return null
|
||||||
const [techStackRecommendations, setTechStackRecommendations] = useState<any>(null)
|
})
|
||||||
|
const [selectedFeatures, setSelectedFeatures] = useState<TemplateFeature[]>(() => {
|
||||||
|
// Only access localStorage after component mounts to prevent hydration mismatch
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
const [finalProjectData, setFinalProjectData] = useState<any>(() => {
|
||||||
|
// Only access localStorage after component mounts to prevent hydration mismatch
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
const [techStackRecommendations, setTechStackRecommendations] = useState<any>(() => {
|
||||||
|
// Only access localStorage after component mounts to prevent hydration mismatch
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load state from localStorage after component mounts
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
|
||||||
|
// Load current step
|
||||||
|
const savedStep = localStorage.getItem('dashboard_current_step')
|
||||||
|
if (savedStep) {
|
||||||
|
setCurrentStep(parseInt(savedStep) || 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load selected template
|
||||||
|
const savedTemplate = localStorage.getItem('dashboard_selected_template')
|
||||||
|
if (savedTemplate) {
|
||||||
|
try {
|
||||||
|
setSelectedTemplate(JSON.parse(savedTemplate))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse saved template:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load selected features
|
||||||
|
const savedFeatures = localStorage.getItem('dashboard_selected_features')
|
||||||
|
if (savedFeatures) {
|
||||||
|
try {
|
||||||
|
setSelectedFeatures(JSON.parse(savedFeatures))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse saved features:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load final project data
|
||||||
|
const savedProjectData = localStorage.getItem('dashboard_final_project_data')
|
||||||
|
if (savedProjectData) {
|
||||||
|
try {
|
||||||
|
setFinalProjectData(JSON.parse(savedProjectData))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse saved project data:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load tech stack recommendations
|
||||||
|
const savedRecommendations = localStorage.getItem('dashboard_tech_stack_recommendations')
|
||||||
|
if (savedRecommendations) {
|
||||||
|
try {
|
||||||
|
setTechStackRecommendations(JSON.parse(savedRecommendations))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse saved recommendations:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{ id: 1, name: "Project Type", description: "Choose template" },
|
{ id: 1, name: "Project Type", description: "Choose template" },
|
||||||
{ id: 2, name: "Features", description: "Select features" },
|
{ id: 2, name: "Features", description: "Select features" },
|
||||||
{ id: 3, name: "Business Context", description: "Define requirements" },
|
{ id: 3, name: "AI Mockup", description: "Generate wireframes" },
|
||||||
{ id: 4, name: "Generate", description: "Create project" },
|
{ id: 4, name: "Business Context", description: "Define requirements" },
|
||||||
{ id: 5, name: "Architecture", description: "Review & deploy" },
|
{ id: 5, name: "Generate", description: "Create project" },
|
||||||
|
{ id: 6, name: "Architecture", description: "Review & deploy" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Save state to localStorage when it changes (only after mounting)
|
||||||
|
useEffect(() => {
|
||||||
|
if (mounted) {
|
||||||
|
localStorage.setItem('dashboard_current_step', currentStep.toString())
|
||||||
|
}
|
||||||
|
}, [currentStep, mounted])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mounted) {
|
||||||
|
if (selectedTemplate) {
|
||||||
|
localStorage.setItem('dashboard_selected_template', JSON.stringify(selectedTemplate))
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('dashboard_selected_template')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedTemplate, mounted])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mounted) {
|
||||||
|
if (selectedFeatures.length > 0) {
|
||||||
|
localStorage.setItem('dashboard_selected_features', JSON.stringify(selectedFeatures))
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('dashboard_selected_features')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedFeatures, mounted])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mounted) {
|
||||||
|
if (finalProjectData) {
|
||||||
|
localStorage.setItem('dashboard_final_project_data', JSON.stringify(finalProjectData))
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('dashboard_final_project_data')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [finalProjectData, mounted])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mounted) {
|
||||||
|
if (techStackRecommendations) {
|
||||||
|
localStorage.setItem('dashboard_tech_stack_recommendations', JSON.stringify(techStackRecommendations))
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('dashboard_tech_stack_recommendations')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [techStackRecommendations, mounted])
|
||||||
|
|
||||||
const renderStep = () => {
|
const renderStep = () => {
|
||||||
switch (currentStep) {
|
switch (currentStep) {
|
||||||
case 1:
|
case 1:
|
||||||
@ -971,27 +1266,36 @@ export function MainDashboard() {
|
|||||||
) : null
|
) : null
|
||||||
case 3:
|
case 3:
|
||||||
return selectedTemplate ? (
|
return selectedTemplate ? (
|
||||||
<BusinessQuestionsStep
|
<AIMockupStep
|
||||||
template={selectedTemplate}
|
template={selectedTemplate}
|
||||||
selected={selectedFeatures}
|
selectedFeatures={selectedFeatures}
|
||||||
|
onNext={() => setCurrentStep(4)}
|
||||||
onBack={() => setCurrentStep(2)}
|
onBack={() => setCurrentStep(2)}
|
||||||
onDone={(data, recs) => { setFinalProjectData(data); setTechStackRecommendations(recs); setCurrentStep(4) }}
|
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
case 4:
|
case 4:
|
||||||
|
return selectedTemplate ? (
|
||||||
|
<BusinessQuestionsStep
|
||||||
|
template={selectedTemplate}
|
||||||
|
selected={selectedFeatures}
|
||||||
|
onBack={() => setCurrentStep(3)}
|
||||||
|
onDone={(data, recs) => { setFinalProjectData(data); setTechStackRecommendations(recs); setCurrentStep(5) }}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
case 5:
|
||||||
return (
|
return (
|
||||||
<TechStackSummaryStep
|
<TechStackSummaryStep
|
||||||
recommendations={techStackRecommendations}
|
recommendations={techStackRecommendations}
|
||||||
completeData={finalProjectData}
|
completeData={finalProjectData}
|
||||||
onBack={() => setCurrentStep(3)}
|
onBack={() => setCurrentStep(4)}
|
||||||
onGenerate={() => setCurrentStep(5)}
|
onGenerate={() => setCurrentStep(6)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
case 5:
|
case 6:
|
||||||
return (
|
return (
|
||||||
<ArchitectureDesignerStep
|
<ArchitectureDesignerStep
|
||||||
recommendations={techStackRecommendations}
|
recommendations={techStackRecommendations}
|
||||||
onBack={() => setCurrentStep(4)}
|
onBack={() => setCurrentStep(5)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
default:
|
default:
|
||||||
@ -1003,50 +1307,64 @@ export function MainDashboard() {
|
|||||||
<div className="min-h-screen bg-black">
|
<div className="min-h-screen bg-black">
|
||||||
|
|
||||||
{/* Progress Steps */}
|
{/* Progress Steps */}
|
||||||
<div className="bg-white/5 border-b border-white/10">
|
{mounted && (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="bg-white/5 border-b border-white/10">
|
||||||
<div className="py-4">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<nav className="flex justify-center">
|
<div className="py-4">
|
||||||
<ol className="flex items-center space-x-8 text-white/60">
|
<nav className="flex justify-center">
|
||||||
{steps.map((step, index) => (
|
<ol className="flex items-center space-x-8 text-white/60">
|
||||||
<li key={step.id} className="flex items-center">
|
{steps.map((step, index) => (
|
||||||
<div className="flex items-center">
|
<li key={`step-${step.id}`} className="flex items-center">
|
||||||
<div
|
<div className="flex items-center">
|
||||||
className={`flex items-center justify-center w-10 h-10 rounded-full border-2 transition-all ${currentStep >= step.id
|
<div
|
||||||
? "bg-orange-500 border-orange-500 text-white shadow-lg"
|
className={`flex items-center justify-center w-10 h-10 rounded-full border-2 transition-all ${currentStep >= step.id
|
||||||
: currentStep === step.id - 1
|
? "bg-orange-500 border-orange-500 text-white shadow-lg"
|
||||||
? "border-orange-300 text-orange-400"
|
: currentStep === step.id - 1
|
||||||
: "border-white/30 text-white/40"
|
? "border-orange-300 text-orange-400"
|
||||||
}`}
|
: "border-white/30 text-white/40"
|
||||||
>
|
}`}
|
||||||
<span className="text-sm font-semibold">{step.id}</span>
|
|
||||||
</div>
|
|
||||||
<div className="ml-4">
|
|
||||||
<p
|
|
||||||
className={`text-sm font-semibold ${currentStep >= step.id ? "text-orange-400" : "text-white/60"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{step.name}
|
<span className="text-sm font-semibold">{step.id}</span>
|
||||||
</p>
|
</div>
|
||||||
<p className="text-xs text-white/40">{step.description}</p>
|
<div className="ml-4">
|
||||||
|
<p
|
||||||
|
className={`text-sm font-semibold ${currentStep >= step.id ? "text-orange-400" : "text-white/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-white/40">{step.description}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{index < steps.length - 1 && (
|
||||||
{index < steps.length - 1 && (
|
<ArrowRight
|
||||||
<ArrowRight
|
key={`arrow-${step.id}`}
|
||||||
className={`ml-8 h-5 w-5 ${currentStep > step.id ? "text-orange-400" : "text-white/40"}`}
|
className={`ml-8 h-5 w-5 ${currentStep > step.id ? "text-orange-400" : "text-white/40"}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="py-8">
|
<main className="py-8">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">{renderStep()}</div>
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{!mounted ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<div className="animate-spin w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full mx-auto"></div>
|
||||||
|
<p className="text-white/60">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
renderStep()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
250
src/components/prompt-side-panel.tsx
Normal file
250
src/components/prompt-side-panel.tsx
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState, useEffect } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { SelectDevice } from "@/components/ui/select-device"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { getHealthUrl, config } from "@/lib/config"
|
||||||
|
|
||||||
|
export function PromptSidePanel({
|
||||||
|
className,
|
||||||
|
selectedDevice = 'desktop',
|
||||||
|
onDeviceChange
|
||||||
|
}: {
|
||||||
|
className?: string
|
||||||
|
selectedDevice?: 'desktop' | 'tablet' | 'mobile'
|
||||||
|
onDeviceChange?: (device: 'desktop' | 'tablet' | 'mobile') => void
|
||||||
|
}) {
|
||||||
|
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 [isGenerating, setIsGenerating] = useState(false)
|
||||||
|
const [backendStatus, setBackendStatus] = useState<'connected' | 'disconnected' | 'checking'>('checking')
|
||||||
|
|
||||||
|
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.",
|
||||||
|
"Admin panel: header, left navigation, main content area with data tables, and footer.",
|
||||||
|
"Product catalog: header, search bar, filter sidebar, 4x3 product grid, pagination.",
|
||||||
|
"Blog layout: header, featured post hero, 2-column article list, sidebar with categories.",
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check backend connection status
|
||||||
|
useEffect(() => {
|
||||||
|
const checkBackendStatus = async () => {
|
||||||
|
try {
|
||||||
|
setBackendStatus('checking')
|
||||||
|
const response = await fetch(getHealthUrl(), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setBackendStatus('connected')
|
||||||
|
} else {
|
||||||
|
setBackendStatus('disconnected')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Backend health check failed:', error)
|
||||||
|
setBackendStatus('disconnected')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkBackendStatus()
|
||||||
|
|
||||||
|
// Check status every 10 seconds
|
||||||
|
const interval = setInterval(checkBackendStatus, config.ui.statusCheckInterval)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const dispatchGenerate = async (text: string) => {
|
||||||
|
setIsGenerating(true)
|
||||||
|
|
||||||
|
// Dispatch the event for the canvas to handle with device information
|
||||||
|
window.dispatchEvent(new CustomEvent("tldraw:generate", {
|
||||||
|
detail: {
|
||||||
|
prompt: text,
|
||||||
|
device: selectedDevice
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Wait a bit to show the loading state
|
||||||
|
setTimeout(() => setIsGenerating(false), 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispatchClear = () => {
|
||||||
|
window.dispatchEvent(new Event("tldraw:clear"))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeviceChange = (device: 'desktop' | 'tablet' | 'mobile') => {
|
||||||
|
console.log('DEBUG: PromptSidePanel handleDeviceChange called with:', device)
|
||||||
|
console.log('DEBUG: Current selectedDevice prop:', selectedDevice)
|
||||||
|
console.log('DEBUG: onDeviceChange function exists:', !!onDeviceChange)
|
||||||
|
|
||||||
|
if (onDeviceChange) {
|
||||||
|
console.log('DEBUG: Calling onDeviceChange with:', device)
|
||||||
|
onDeviceChange(device)
|
||||||
|
} else {
|
||||||
|
console.warn('DEBUG: onDeviceChange function not provided')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBackendStatusIcon = () => {
|
||||||
|
switch (backendStatus) {
|
||||||
|
case 'connected':
|
||||||
|
return '🟢'
|
||||||
|
case 'disconnected':
|
||||||
|
return '🔴'
|
||||||
|
case 'checking':
|
||||||
|
return '🟡'
|
||||||
|
default:
|
||||||
|
return '⚪'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBackendStatusText = () => {
|
||||||
|
switch (backendStatus) {
|
||||||
|
case 'connected':
|
||||||
|
return 'AI Backend Connected'
|
||||||
|
case 'disconnected':
|
||||||
|
return 'AI Backend Disconnected'
|
||||||
|
case 'checking':
|
||||||
|
return 'Checking Backend...'
|
||||||
|
default:
|
||||||
|
return 'Unknown Status'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"h-full border-l bg-white dark:bg-neutral-900 flex flex-col",
|
||||||
|
collapsed ? "w-15" : "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">
|
||||||
|
{/* Backend Status */}
|
||||||
|
<div className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-2 rounded-lg text-xs",
|
||||||
|
backendStatus === 'connected' ? 'bg-green-50 text-green-700 border border-green-200' :
|
||||||
|
backendStatus === 'disconnected' ? 'bg-red-50 text-red-700 border border-red-200' :
|
||||||
|
'bg-yellow-50 text-yellow-700 border border-yellow-200'
|
||||||
|
)}>
|
||||||
|
<span>{getBackendStatusIcon()}</span>
|
||||||
|
<span className="font-medium">{getBackendStatusText()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
disabled={isGenerating}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium">Device Type</label>
|
||||||
|
<SelectDevice
|
||||||
|
value={selectedDevice}
|
||||||
|
onValueChange={handleDeviceChange}
|
||||||
|
disabled={isGenerating}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="font-medium">Current:</span>
|
||||||
|
<span className={cn(
|
||||||
|
"px-2 py-1 rounded-full text-xs font-medium",
|
||||||
|
selectedDevice === "desktop" && "bg-blue-100 text-blue-700",
|
||||||
|
selectedDevice === "tablet" && "bg-green-100 text-green-700",
|
||||||
|
selectedDevice === "mobile" && "bg-purple-100 text-purple-700"
|
||||||
|
)}>
|
||||||
|
{selectedDevice.charAt(0).toUpperCase() + selectedDevice.slice(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{selectedDevice === "desktop" && "Desktop layout with full navigation and sidebar"}
|
||||||
|
{selectedDevice === "tablet" && "Tablet layout with responsive navigation"}
|
||||||
|
{selectedDevice === "mobile" && "Mobile-first layout with stacked elements"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => dispatchGenerate(prompt)}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isGenerating || backendStatus !== 'connected'}
|
||||||
|
>
|
||||||
|
{isGenerating ? 'Generating...' : `Generate for ${selectedDevice}`}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={dispatchClear} disabled={isGenerating}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{backendStatus === 'disconnected' && (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-amber-700">
|
||||||
|
<strong>Backend not connected.</strong> Make sure your Flask backend is running on port 5000.
|
||||||
|
The system will use fallback generation instead.
|
||||||
|
</p>
|
||||||
|
</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"
|
||||||
|
disabled={isGenerating}
|
||||||
|
>
|
||||||
|
{ex}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Features Info */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-blue-700">
|
||||||
|
<strong>AI-Powered Generation:</strong> Claude AI analyzes your prompts and creates professional wireframe layouts with proper spacing, proportions, and UX best practices.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 pt-2 border-t border-blue-200">
|
||||||
|
<p className="text-xs text-blue-600">
|
||||||
|
<strong>Device-Specific Generation:</strong><br/>
|
||||||
|
• <strong>Desktop:</strong> Uses single-device API for faster generation<br/>
|
||||||
|
• <strong>Tablet/Mobile:</strong> Uses multi-device API for responsive layouts
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PromptSidePanel
|
||||||
72
src/components/prompt-toolbar.tsx
Normal file
72
src/components/prompt-toolbar.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
export default function PromptToolbar({
|
||||||
|
busy,
|
||||||
|
onGenerate,
|
||||||
|
onClear,
|
||||||
|
onExample,
|
||||||
|
}: {
|
||||||
|
busy?: boolean
|
||||||
|
onGenerate: (prompt: string) => void
|
||||||
|
onClear: () => void
|
||||||
|
onExample: (text: string) => void
|
||||||
|
}) {
|
||||||
|
const [prompt, setPrompt] = useState("Dashboard with header, sidebar, 3x2 cards grid, and footer")
|
||||||
|
|
||||||
|
const submit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!prompt.trim() || busy) return
|
||||||
|
onGenerate(prompt.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
const examples = [
|
||||||
|
"Marketing landing page with hero, 3x2 features grid, signup form, and footer",
|
||||||
|
"Simple login screen with header and centered form",
|
||||||
|
"Ecommerce product grid 4x2 with header, sidebar filters, and footer",
|
||||||
|
"Admin dashboard with header, sidebar, 2x2 cards and a form",
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<form onSubmit={submit} className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
aria-label="Prompt"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
placeholder="e.g., Dashboard with header, sidebar, 3x2 cards grid, and footer"
|
||||||
|
className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy ? "Generating…" : "Generate"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClear}
|
||||||
|
className="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-800 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-600">Try:</span>
|
||||||
|
{examples.map((ex) => (
|
||||||
|
<button
|
||||||
|
key={ex}
|
||||||
|
onClick={() => onExample(ex)}
|
||||||
|
className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs text-gray-800 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
{ex}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
src/components/ui/accordion.tsx
Normal file
66
src/components/ui/accordion.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||||
|
import { ChevronDownIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Accordion({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||||
|
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot="accordion-item"
|
||||||
|
className={cn("border-b last:border-b-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
data-slot="accordion-content"
|
||||||
|
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||||
157
src/components/ui/alert-dialog.tsx
Normal file
157
src/components/ui/alert-dialog.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
109
src/components/ui/breadcrumb.tsx
Normal file
109
src/components/ui/breadcrumb.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("inline-flex items-center gap-1.5", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
asChild,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"a"> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "a"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="breadcrumb-link"
|
||||||
|
className={cn("hover:text-foreground transition-colors", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("text-foreground font-normal", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRight />}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
||||||
353
src/components/ui/chart.tsx
Normal file
353
src/components/ui/chart.tsx
Normal file
@ -0,0 +1,353 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as RechartsPrimitive from "recharts"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: "", dark: ".dark" } as const
|
||||||
|
|
||||||
|
export type ChartConfig = {
|
||||||
|
[k in string]: {
|
||||||
|
label?: React.ReactNode
|
||||||
|
icon?: React.ComponentType
|
||||||
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useChart must be used within a <ChartContainer />")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartContainer({
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
config: ChartConfig
|
||||||
|
children: React.ComponentProps<
|
||||||
|
typeof RechartsPrimitive.ResponsiveContainer
|
||||||
|
>["children"]
|
||||||
|
}) {
|
||||||
|
const uniqueId = React.useId()
|
||||||
|
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-slot="chart"
|
||||||
|
data-chart={chartId}
|
||||||
|
className={cn(
|
||||||
|
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(
|
||||||
|
([, config]) => config.theme || config.color
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color =
|
||||||
|
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||||
|
itemConfig.color
|
||||||
|
return color ? ` --color-${key}: ${color};` : null
|
||||||
|
})
|
||||||
|
.join("\n")}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||||
|
|
||||||
|
function ChartTooltipContent({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
hideLabel?: boolean
|
||||||
|
hideIndicator?: boolean
|
||||||
|
indicator?: "line" | "dot" | "dashed"
|
||||||
|
nameKey?: string
|
||||||
|
labelKey?: string
|
||||||
|
}) {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload
|
||||||
|
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === "string"
|
||||||
|
? config[label as keyof typeof config]?.label || label
|
||||||
|
: itemConfig?.label
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>
|
||||||
|
{labelFormatter(value, payload)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||||
|
}, [
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
payload,
|
||||||
|
hideLabel,
|
||||||
|
labelClassName,
|
||||||
|
config,
|
||||||
|
labelKey,
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload.map((item, index) => {
|
||||||
|
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
const indicatorColor = color || item.payload.fill || item.color
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.dataKey}
|
||||||
|
className={cn(
|
||||||
|
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||||
|
indicator === "dot" && "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||||
|
{
|
||||||
|
"h-2.5 w-2.5": indicator === "dot",
|
||||||
|
"w-1": indicator === "line",
|
||||||
|
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||||
|
indicator === "dashed",
|
||||||
|
"my-0.5": nestLabel && indicator === "dashed",
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color-bg": indicatorColor,
|
||||||
|
"--color-border": indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 justify-between leading-none",
|
||||||
|
nestLabel ? "items-end" : "items-center"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label || item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value && (
|
||||||
|
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||||
|
{item.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend
|
||||||
|
|
||||||
|
function ChartLegendContent({
|
||||||
|
className,
|
||||||
|
hideIcon = false,
|
||||||
|
payload,
|
||||||
|
verticalAlign = "bottom",
|
||||||
|
nameKey,
|
||||||
|
}: React.ComponentProps<"div"> &
|
||||||
|
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||||
|
hideIcon?: boolean
|
||||||
|
nameKey?: string
|
||||||
|
}) {
|
||||||
|
const { config } = useChart()
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload.map((item) => {
|
||||||
|
const key = `${nameKey || item.dataKey || "value"}`
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className={cn(
|
||||||
|
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to extract item config from a payload.
|
||||||
|
function getPayloadConfigFromPayload(
|
||||||
|
config: ChartConfig,
|
||||||
|
payload: unknown,
|
||||||
|
key: string
|
||||||
|
) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
"payload" in payload &&
|
||||||
|
typeof payload.payload === "object" &&
|
||||||
|
payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
let configLabelKey: string = key
|
||||||
|
|
||||||
|
if (
|
||||||
|
key in payload &&
|
||||||
|
typeof payload[key as keyof typeof payload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[
|
||||||
|
key as keyof typeof payloadPayload
|
||||||
|
] as string
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config
|
||||||
|
? config[configLabelKey]
|
||||||
|
: config[key as keyof typeof config]
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
}
|
||||||
58
src/components/ui/scroll-area.tsx
Normal file
58
src/components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
65
src/components/ui/select-device.tsx
Normal file
65
src/components/ui/select-device.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type Device = "desktop" | "tablet" | "mobile"
|
||||||
|
|
||||||
|
export interface SelectDeviceProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
value: Device
|
||||||
|
onValueChange: (value: Device) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectDevice({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectDeviceProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 p-1 bg-neutral-100 rounded-lg",
|
||||||
|
disabled && "opacity-50 cursor-not-allowed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onValueChange("desktop")}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 px-3 py-1 text-xs rounded-md transition-colors",
|
||||||
|
value === "desktop" ? "bg-white shadow text-blue-600" : "text-neutral-600 hover:bg-white/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Desktop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onValueChange("tablet")}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 px-3 py-1 text-xs rounded-md transition-colors",
|
||||||
|
value === "tablet" ? "bg-white shadow text-blue-600" : "text-neutral-600 hover:bg-white/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Tablet
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onValueChange("mobile")}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 px-3 py-1 text-xs rounded-md transition-colors",
|
||||||
|
value === "mobile" ? "bg-white shadow text-blue-600" : "text-neutral-600 hover:bg-white/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Mobile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
116
src/components/ui/table.tsx
Normal file
116
src/components/ui/table.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
@ -2,23 +2,17 @@ import * as React from "react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export interface TextareaProps
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
return (
|
||||||
|
<textarea
|
||||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
data-slot="textarea"
|
||||||
({ className, ...props }, ref) => {
|
className={cn(
|
||||||
return (
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
<textarea
|
className
|
||||||
className={cn(
|
)}
|
||||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
{...props}
|
||||||
className
|
/>
|
||||||
)}
|
)
|
||||||
ref={ref}
|
}
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Textarea.displayName = "Textarea"
|
|
||||||
|
|
||||||
export { Textarea }
|
export { Textarea }
|
||||||
|
|||||||
1394
src/components/wireframe-canvas.tsx
Normal file
1394
src/components/wireframe-canvas.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
|||||||
"use client"
|
'use client'
|
||||||
|
|
||||||
import React, { createContext, useContext, useEffect, useState } from 'react'
|
import React, { createContext, useContext, useEffect, useState } from 'react'
|
||||||
import { safeLocalStorage } from '@/lib/utils'
|
import { safeLocalStorage } from '@/lib/utils'
|
||||||
|
|||||||
137
src/lib/config.ts
Normal file
137
src/lib/config.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
// Configuration for the wireframe generator
|
||||||
|
export const config = {
|
||||||
|
// Backend API configuration
|
||||||
|
backend: {
|
||||||
|
baseUrl: process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8021',
|
||||||
|
endpoints: {
|
||||||
|
health: '/health',
|
||||||
|
generateWireframe: '/generate-wireframe',
|
||||||
|
generateWireframeDesktop: '/generate-wireframe/desktop',
|
||||||
|
generateWireframeTablet: '/generate-wireframe/tablet',
|
||||||
|
generateWireframeMobile: '/generate-wireframe/mobile',
|
||||||
|
generateAllDevices: '/generate-all-devices',
|
||||||
|
wireframes: '/api/wireframes',
|
||||||
|
wireframe: (id?: string) => id ? `/api/wireframes/${id}` : '/api/wireframes',
|
||||||
|
},
|
||||||
|
timeout: 30000, // 30 seconds
|
||||||
|
},
|
||||||
|
|
||||||
|
// User Authentication Service
|
||||||
|
auth: {
|
||||||
|
baseUrl: process.env.NEXT_PUBLIC_AUTH_URL || 'http://localhost:8011',
|
||||||
|
endpoints: {
|
||||||
|
health: '/health',
|
||||||
|
register: '/api/auth/register',
|
||||||
|
login: '/api/auth/login',
|
||||||
|
logout: '/api/auth/logout',
|
||||||
|
refresh: '/api/auth/refresh',
|
||||||
|
profile: '/api/auth/me',
|
||||||
|
preferences: '/api/auth/preferences',
|
||||||
|
projects: '/api/auth/projects',
|
||||||
|
},
|
||||||
|
tokenKey: 'auth_token',
|
||||||
|
refreshTokenKey: 'refresh_token',
|
||||||
|
},
|
||||||
|
|
||||||
|
// UI configuration
|
||||||
|
ui: {
|
||||||
|
maxPromptLength: 1000,
|
||||||
|
statusCheckInterval: 10000, // 10 seconds
|
||||||
|
generationTimeout: 30000, // 30 seconds
|
||||||
|
},
|
||||||
|
|
||||||
|
// Wireframe defaults
|
||||||
|
wireframe: {
|
||||||
|
defaultPageSize: { width: 1200, height: 800 },
|
||||||
|
defaultSpacing: { gap: 16, padding: 20 },
|
||||||
|
minElementSize: { width: 80, height: 40 },
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// Helper function to get full API URL
|
||||||
|
export const getApiUrl = (endpoint: string): string => {
|
||||||
|
return `${config.backend.baseUrl}${endpoint}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get full Auth Service URL
|
||||||
|
export const getAuthUrl = (endpoint: string): string => {
|
||||||
|
return `${config.auth.baseUrl}${endpoint}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get health check URL
|
||||||
|
export const getHealthUrl = (): string => {
|
||||||
|
return getApiUrl(config.backend.endpoints.health)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get auth health check URL
|
||||||
|
export const getAuthHealthUrl = (): string => {
|
||||||
|
return getAuthUrl(config.auth.endpoints.health)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get wireframe generation URL for specific device
|
||||||
|
export const getWireframeGenerationUrl = (device: 'desktop' | 'tablet' | 'mobile' = 'desktop'): string => {
|
||||||
|
switch (device) {
|
||||||
|
case 'tablet':
|
||||||
|
return getApiUrl(config.backend.endpoints.generateWireframeTablet)
|
||||||
|
case 'mobile':
|
||||||
|
return getApiUrl(config.backend.endpoints.generateWireframeMobile)
|
||||||
|
case 'desktop':
|
||||||
|
default:
|
||||||
|
return getApiUrl(config.backend.endpoints.generateWireframeDesktop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get universal wireframe generation URL (backward compatibility)
|
||||||
|
export const getUniversalWireframeGenerationUrl = (): string => {
|
||||||
|
return getApiUrl(config.backend.endpoints.generateWireframe)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get all devices generation URL
|
||||||
|
export const getAllDevicesGenerationUrl = (): string => {
|
||||||
|
return getApiUrl(config.backend.endpoints.generateAllDevices)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get wireframe persistence URLs
|
||||||
|
export const getWireframeUrl = (id?: string): string => {
|
||||||
|
if (id) {
|
||||||
|
return getApiUrl(config.backend.endpoints.wireframe(id))
|
||||||
|
}
|
||||||
|
return getApiUrl(config.backend.endpoints.wireframes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get wireframe by ID URL
|
||||||
|
export const getWireframeByIdUrl = (id: string): string => {
|
||||||
|
return getApiUrl(config.backend.endpoints.wireframe(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication helper functions
|
||||||
|
export const getAuthHeaders = (): HeadersInit => {
|
||||||
|
const token = localStorage.getItem(config.auth.tokenKey)
|
||||||
|
return token ? { 'Authorization': `Bearer ${token}` } : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAuthHeadersWithContentType = (): HeadersInit => {
|
||||||
|
const token = localStorage.getItem(config.auth.tokenKey)
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isAuthenticated = (): boolean => {
|
||||||
|
const token = localStorage.getItem(config.auth.tokenKey)
|
||||||
|
return !!token
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCurrentUser = (): any => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem(config.auth.tokenKey)
|
||||||
|
if (!token) return null
|
||||||
|
|
||||||
|
// Decode JWT token to get user info
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]))
|
||||||
|
return payload
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user