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-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@tldraw/tldraw": "^3.15.4",
|
||||
"axios": "^1.11.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@ -28,6 +29,7 @@
|
||||
"next": "15.4.6",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"svg-path-parser": "^1.1.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
@ -37,6 +39,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/svg-path-parser": "^1.1.6",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.4.6",
|
||||
"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
|
||||
})
|
||||
|
||||
const { signup } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
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 { Badge } from "@/components/ui/badge"
|
||||
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 { CustomTemplateForm } from "@/components/custom-template-form"
|
||||
import { EditTemplateForm } from "@/components/edit-template-form"
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog"
|
||||
import { DatabaseTemplate, TemplateFeature } from "@/lib/template-service"
|
||||
import AICustomFeatureCreator from "@/components/ai/AICustomFeatureCreator"
|
||||
import WireframeCanvas from "@/components/wireframe-canvas"
|
||||
import PromptSidePanel from "@/components/prompt-side-panel"
|
||||
|
||||
interface Template {
|
||||
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
|
||||
export function MainDashboard() {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null)
|
||||
const [selectedFeatures, setSelectedFeatures] = useState<TemplateFeature[]>([])
|
||||
const [finalProjectData, setFinalProjectData] = useState<any>(null)
|
||||
const [techStackRecommendations, setTechStackRecommendations] = useState<any>(null)
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(() => {
|
||||
// Only access localStorage after component mounts to prevent hydration mismatch
|
||||
return 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 = [
|
||||
{ id: 1, name: "Project Type", description: "Choose template" },
|
||||
{ id: 2, name: "Features", description: "Select features" },
|
||||
{ id: 3, name: "Business Context", description: "Define requirements" },
|
||||
{ id: 4, name: "Generate", description: "Create project" },
|
||||
{ id: 5, name: "Architecture", description: "Review & deploy" },
|
||||
{ id: 3, name: "AI Mockup", description: "Generate wireframes" },
|
||||
{ id: 4, name: "Business Context", description: "Define requirements" },
|
||||
{ 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 = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
@ -971,27 +1266,36 @@ export function MainDashboard() {
|
||||
) : null
|
||||
case 3:
|
||||
return selectedTemplate ? (
|
||||
<BusinessQuestionsStep
|
||||
<AIMockupStep
|
||||
template={selectedTemplate}
|
||||
selected={selectedFeatures}
|
||||
selectedFeatures={selectedFeatures}
|
||||
onNext={() => setCurrentStep(4)}
|
||||
onBack={() => setCurrentStep(2)}
|
||||
onDone={(data, recs) => { setFinalProjectData(data); setTechStackRecommendations(recs); setCurrentStep(4) }}
|
||||
/>
|
||||
) : null
|
||||
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 (
|
||||
<TechStackSummaryStep
|
||||
recommendations={techStackRecommendations}
|
||||
completeData={finalProjectData}
|
||||
onBack={() => setCurrentStep(3)}
|
||||
onGenerate={() => setCurrentStep(5)}
|
||||
onBack={() => setCurrentStep(4)}
|
||||
onGenerate={() => setCurrentStep(6)}
|
||||
/>
|
||||
)
|
||||
case 5:
|
||||
case 6:
|
||||
return (
|
||||
<ArchitectureDesignerStep
|
||||
recommendations={techStackRecommendations}
|
||||
onBack={() => setCurrentStep(4)}
|
||||
onBack={() => setCurrentStep(5)}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
@ -1003,50 +1307,64 @@ export function MainDashboard() {
|
||||
<div className="min-h-screen bg-black">
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="bg-white/5 border-b border-white/10">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="py-4">
|
||||
<nav className="flex justify-center">
|
||||
<ol className="flex items-center space-x-8 text-white/60">
|
||||
{steps.map((step, index) => (
|
||||
<li key={step.id} className="flex items-center">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`flex items-center justify-center w-10 h-10 rounded-full border-2 transition-all ${currentStep >= step.id
|
||||
? "bg-orange-500 border-orange-500 text-white shadow-lg"
|
||||
: currentStep === step.id - 1
|
||||
? "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"
|
||||
}`}
|
||||
{mounted && (
|
||||
<div className="bg-white/5 border-b border-white/10">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="py-4">
|
||||
<nav className="flex justify-center">
|
||||
<ol className="flex items-center space-x-8 text-white/60">
|
||||
{steps.map((step, index) => (
|
||||
<li key={`step-${step.id}`} className="flex items-center">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`flex items-center justify-center w-10 h-10 rounded-full border-2 transition-all ${currentStep >= step.id
|
||||
? "bg-orange-500 border-orange-500 text-white shadow-lg"
|
||||
: currentStep === step.id - 1
|
||||
? "border-orange-300 text-orange-400"
|
||||
: "border-white/30 text-white/40"
|
||||
}`}
|
||||
>
|
||||
{step.name}
|
||||
</p>
|
||||
<p className="text-xs text-white/40">{step.description}</p>
|
||||
<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}
|
||||
</p>
|
||||
<p className="text-xs text-white/40">{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<ArrowRight
|
||||
className={`ml-8 h-5 w-5 ${currentStep > step.id ? "text-orange-400" : "text-white/40"}`}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
{index < steps.length - 1 && (
|
||||
<ArrowRight
|
||||
key={`arrow-${step.id}`}
|
||||
className={`ml-8 h-5 w-5 ${currentStep > step.id ? "text-orange-400" : "text-white/40"}`}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<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>
|
||||
</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,
|
||||
}
|
||||
@ -56,4 +56,4 @@ function Button({
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants }
|
||||
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"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
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",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 { 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