diff --git a/package-lock.json b/package-lock.json index d161b7a..d843e0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,8 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-masonry-css": "^1.0.16", + "react-zoom-pan-pinch": "^3.7.0", + "roughjs": "^4.6.6", "serve": "^14.2.6" }, "devDependencies": { @@ -5166,6 +5168,12 @@ "dev": true, "license": "ISC" }, + "node_modules/hachure-fill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", + "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "license": "MIT" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -6966,6 +6974,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7036,6 +7050,22 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "license": "MIT" + }, + "node_modules/points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "license": "MIT", + "dependencies": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -7308,6 +7338,20 @@ "react": ">=16.0.0" } }, + "node_modules/react-zoom-pan-pinch": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.7.0.tgz", + "integrity": "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA==", + "license": "MIT", + "engines": { + "node": ">=8", + "npm": ">=5" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -7491,6 +7535,18 @@ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" } }, + "node_modules/roughjs": { + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", + "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "license": "MIT", + "dependencies": { + "hachure-fill": "^0.5.2", + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", diff --git a/package.json b/package.json index 43e5b7e..212010c 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-masonry-css": "^1.0.16", + "react-zoom-pan-pinch": "^3.7.0", + "roughjs": "^4.6.6", "serve": "^14.2.6" }, "devDependencies": { diff --git a/src/app/api/canvas/route.ts b/src/app/api/canvas/route.ts new file mode 100644 index 0000000..63dc57d --- /dev/null +++ b/src/app/api/canvas/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getDb } from '@/lib/db' + +export async function GET(req: NextRequest) { + const projectId = req.nextUrl.searchParams.get('projectId') + const db = getDb() + + if (!projectId) { + // Auto-create or fetch default project + let project = db.prepare('SELECT id FROM projects ORDER BY id LIMIT 1').get() as { id: number } | undefined + if (!project) { + const result = db.prepare( + 'INSERT INTO projects (name, site_dir, provider, framework) VALUES (?, ?, ?, ?)' + ).run('Default Project', `project-${Date.now()}`, 'gemini', 'html') + project = { id: result.lastInsertRowid as number } + } + const row = db.prepare('SELECT state FROM canvas_state WHERE project_id = ?').get(project.id) as { state: string } | undefined + if (!row) { + const defaultState = JSON.stringify({ nodes: [], viewport: { x: 0, y: 0, zoom: 1 } }) + db.prepare('INSERT INTO canvas_state (project_id, state) VALUES (?, ?)').run(project.id, defaultState) + return NextResponse.json({ projectId: project.id, state: JSON.parse(defaultState) }) + } + return NextResponse.json({ projectId: project.id, state: JSON.parse(row.state) }) + } + + const row = db.prepare('SELECT state FROM canvas_state WHERE project_id = ?').get(Number(projectId)) as { state: string } | undefined + if (!row) { + return NextResponse.json({ error: 'Canvas not found' }, { status: 404 }) + } + return NextResponse.json({ projectId: Number(projectId), state: JSON.parse(row.state) }) +} + +export async function PUT(req: NextRequest) { + const { projectId, state } = await req.json() + const db = getDb() + + const existing = db.prepare('SELECT id FROM canvas_state WHERE project_id = ?').get(projectId) + if (existing) { + db.prepare('UPDATE canvas_state SET state = ?, updated_at = datetime(?) WHERE project_id = ?') + .run(JSON.stringify(state), new Date().toISOString(), projectId) + } else { + db.prepare('INSERT INTO canvas_state (project_id, state) VALUES (?, ?)') + .run(projectId, JSON.stringify(state)) + } + + return NextResponse.json({ ok: true }) +} + +// POST handler for navigator.sendBeacon (which always sends POST) +export async function POST(req: NextRequest) { + return PUT(req) +} diff --git a/src/app/api/uploads/[filename]/route.ts b/src/app/api/uploads/[filename]/route.ts new file mode 100644 index 0000000..456a8f8 --- /dev/null +++ b/src/app/api/uploads/[filename]/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from 'next/server' +import { readFile } from 'fs/promises' +import path from 'path' + +const UPLOADS_DIR = path.join(process.cwd(), 'data', 'uploads') + +export async function GET( + req: NextRequest, + context: { params: Promise<{ filename: string }> } +) { + const params = await context.params + const filename = params.filename + + // Directory traversal protection: ensure resolved path stays within UPLOADS_DIR + const filepath = path.resolve(path.join(UPLOADS_DIR, filename)) + if (!filepath.startsWith(UPLOADS_DIR)) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + try { + const buffer = await readFile(filepath) + + // Determine Content-Type based on file extension + const ext = path.extname(filename).toLowerCase() + const contentTypeMap: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', + } + const contentType = contentTypeMap[ext] || 'application/octet-stream' + + return new NextResponse(buffer, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=31536000', // Cache for 1 year since filenames include timestamps + }, + }) + } catch (error) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }) + } +} diff --git a/src/app/api/uploads/route.ts b/src/app/api/uploads/route.ts new file mode 100644 index 0000000..2449810 --- /dev/null +++ b/src/app/api/uploads/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server' +import { writeFile, mkdir } from 'fs/promises' +import path from 'path' + +const UPLOADS_DIR = path.join(process.cwd(), 'data', 'uploads') + +export async function POST(req: NextRequest) { + const formData = await req.formData() + const file = formData.get('file') as File | null + + if (!file) { + return NextResponse.json({ error: 'No file provided' }, { status: 400 }) + } + + await mkdir(UPLOADS_DIR, { recursive: true }) + + const timestamp = Date.now() + const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_') + const filename = `${timestamp}-${safeName}` + const filepath = path.join(UPLOADS_DIR, filename) + + const buffer = Buffer.from(await file.arrayBuffer()) + await writeFile(filepath, buffer) + + return NextResponse.json({ path: `uploads/${filename}` }) +} diff --git a/src/app/canvas/page.tsx b/src/app/canvas/page.tsx new file mode 100644 index 0000000..40c00bc --- /dev/null +++ b/src/app/canvas/page.tsx @@ -0,0 +1,486 @@ +'use client' + +import { useState, useEffect, useCallback, useRef } from 'react' +import Canvas from '@/components/canvas/Canvas' +import CanvasNodeComponent from '@/components/canvas/CanvasNode' +import CanvasToolbar from '@/components/canvas/CanvasToolbar' +import Minimap from '@/components/canvas/Minimap' +import Sidebar from '@/components/canvas/Sidebar' +import { CanvasState, CanvasNode, createEmptyState, VIEWPORT_SIZES } from '@/lib/canvas-types' +import type { ShapeType, WidgetType } from '@/lib/canvas-types' +import { addNode, removeNode, updateNode, moveNode, bringToFront, sendToBack, createUndoRedoManager } from '@/lib/canvas-state' +import Header from '@/components/Header' + +export default function CanvasPage() { + const [state, setState] = useState(createEmptyState()) + const [projectId, setProjectId] = useState(null) + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [zoom, setZoom] = useState(1) + const [viewportX, setViewportX] = useState(0) + const [viewportY, setViewportY] = useState(0) + const [sidebarCollapsed, setSidebarCollapsed] = useState(false) + const [provider, setProvider] = useState('claude') + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; nodeId: string } | null>(null) + const [isDraggingNode, setIsDraggingNode] = useState(false) + const [multiDragOffset, setMultiDragOffset] = useState<{ dx: number; dy: number; sourceId: string } | null>(null) + const [externalPrompt, setExternalPrompt] = useState(null) + const containerRef = useRef(null) + const undoMgr = useRef(createUndoRedoManager()) + const saveTimer = useRef | null>(null) + + useEffect(() => { + fetch('/api/canvas') + .then(r => r.json()) + .then(data => { + setProjectId(data.projectId) + setState(data.state) + undoMgr.current.push(data.state) + }) + // Fetch provider from settings + fetch('/api/settings') + .then(r => r.json()) + .then(s => { if (s.ai_provider) setProvider(s.ai_provider) }) + .catch(() => {}) + }, []) + + const saveState = useCallback((newState: CanvasState) => { + if (saveTimer.current) clearTimeout(saveTimer.current) + saveTimer.current = setTimeout(() => { + if (projectId === null) return + fetch('/api/canvas', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ projectId, state: newState }), + }) + }, 500) + }, [projectId]) + + const pushState = useCallback((newState: CanvasState) => { + setState(newState) + undoMgr.current.push(newState) + saveState(newState) + }, [saveState]) + + const handleUndo = useCallback(() => { + const prev = undoMgr.current.undo() + if (prev) { setState(prev); saveState(prev) } + }, [saveState]) + + const handleRedo = useCallback(() => { + const next = undoMgr.current.redo() + if (next) { setState(next); saveState(next) } + }, [saveState]) + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'z' && (e.metaKey || e.ctrlKey) && e.shiftKey) { + e.preventDefault(); handleRedo() + } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); handleUndo() + } else if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIds.size > 0) { + e.preventDefault() + let s = state + for (const id of selectedIds) s = removeNode(s, id) + pushState(s) + setSelectedIds(new Set()) + } else if (e.key === 'Escape') { + setSelectedIds(new Set()) + setContextMenu(null) + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [state, selectedIds, pushState, handleUndo, handleRedo]) + + useEffect(() => { + const handler = () => { + if (projectId === null) return + const blob = new Blob([JSON.stringify({ projectId, state })], { type: 'application/json' }) + navigator.sendBeacon('/api/canvas', blob) + } + window.addEventListener('beforeunload', handler) + return () => window.removeEventListener('beforeunload', handler) + }, [projectId, state]) + + // Toolbar → Sidebar triggers + const handleAddWebsite = useCallback(() => { + if (sidebarCollapsed) setSidebarCollapsed(false) + setExternalPrompt('') + }, [sidebarCollapsed]) + + const handlePromptSubmit = useCallback((text: string) => { + if (sidebarCollapsed) setSidebarCollapsed(false) + setExternalPrompt(text) + }, [sidebarCollapsed]) + + // Node creation handlers + const handleAddShape = useCallback((shapeType: ShapeType, x: number, y: number) => { + const node: CanvasNode = { + id: crypto.randomUUID(), + type: 'shape', + x, y, + width: 150, + height: 100, + zIndex: state.nodes.length, + data: { shapeType, label: shapeType }, + } + pushState(addNode(state, node)) + }, [state, pushState]) + + const handleAddWidget = useCallback((widgetType: WidgetType, x: number, y: number) => { + const node: CanvasNode = { + id: crypto.randomUUID(), + type: 'widget', + x, y, + width: 120, + height: 40, + zIndex: state.nodes.length, + data: { widgetType, label: widgetType }, + } + pushState(addNode(state, node)) + }, [state, pushState]) + + const handleAddDocument = useCallback(() => { + const node: CanvasNode = { + id: crypto.randomUUID(), + type: 'document', + x: 200, + y: 200, + width: 300, + height: 200, + zIndex: state.nodes.length, + data: { markdown: '# New Document\n\nDouble-click to edit...', title: 'New Document' }, + } + pushState(addNode(state, node)) + }, [state, pushState]) + + const handleAddImage = useCallback(async (file: File) => { + const formData = new FormData() + formData.append('file', file) + const res = await fetch('/api/uploads', { method: 'POST', body: formData }) + const { path } = await res.json() + const node: CanvasNode = { + id: crypto.randomUUID(), + type: 'image', + x: 200, + y: 200, + width: 300, + height: 200, + zIndex: state.nodes.length, + data: { src: path, alt: file.name }, + } + pushState(addNode(state, node)) + }, [state, pushState]) + + // Task 11: Image drag-and-drop + const handleFileDrop = useCallback(async (e: React.DragEvent) => { + e.preventDefault() + const file = e.dataTransfer.files[0] + if (!file || !file.type.startsWith('image/')) return + const formData = new FormData() + formData.append('file', file) + const res = await fetch('/api/uploads', { method: 'POST', body: formData }) + const { path } = await res.json() + const node: CanvasNode = { + id: crypto.randomUUID(), + type: 'image', + x: 200, y: 200, + width: 300, height: 200, + zIndex: 0, + data: { src: path }, + } + pushState(addNode(state, node)) + }, [state, pushState]) + + const handleSiteReady = useCallback((siteDir: string, sessionId?: string, prov?: string) => { + const viewport = 'desktop' as const + const { width, height } = VIEWPORT_SIZES[viewport] + const node: CanvasNode = { + id: crypto.randomUUID(), + type: 'artboard', + x: state.nodes.length * 100, + y: 100, + width, height, + zIndex: 0, + data: { name: siteDir.replace('site-', ''), siteDir, viewport, provider: (prov || 'gemini') as 'gemini' | 'claude', sessionId }, + } + pushState(addNode(state, node)) + }, [state, pushState]) + + // Derive selectedNode from selectedIds + const selectedNode = state.nodes.find(n => selectedIds.has(n.id)) ?? null + const selectedId = selectedNode?.id ?? null + + const handleUpdateNode = useCallback((updates: Partial) => { + if (!selectedId) return + pushState(updateNode(state, selectedId, updates)) + }, [state, selectedId, pushState]) + + const handleUpdateNodeData = useCallback((data: any) => { + if (!selectedId) return + pushState(updateNode(state, selectedId, { data })) + }, [state, selectedId, pushState]) + + const handleDeleteNode = useCallback(() => { + if (!selectedId) return + pushState(removeNode(state, selectedId)) + setSelectedIds(new Set()) + }, [state, selectedId, pushState]) + + const handleBringToFront = useCallback(() => { + if (!selectedId) return + pushState(bringToFront(state, selectedId)) + }, [state, selectedId, pushState]) + + const handleSendToBack = useCallback(() => { + if (!selectedId) return + pushState(sendToBack(state, selectedId)) + }, [state, selectedId, pushState]) + + // Task 12: Context menu handlers + const handleContextMenu = useCallback((nodeId: string, x: number, y: number) => { + setContextMenu({ x, y, nodeId }) + }, []) + + const closeContextMenu = useCallback(() => setContextMenu(null), []) + + const containerWidth = containerRef.current?.clientWidth ?? 800 + const containerHeight = containerRef.current?.clientHeight ?? 600 + + const handleSelectProject = useCallback(async (project: any) => { + // Load project as artboard on canvas + const siteDir = project.site_dir + const viewport = 'desktop' as const + const { width, height } = VIEWPORT_SIZES[viewport] + const node: CanvasNode = { + id: crypto.randomUUID(), + type: 'artboard', + x: state.nodes.length * 200, + y: 100, + width, height, + zIndex: 0, + data: { + name: project.name || siteDir.replace('site-', ''), + siteDir, + viewport, + provider: (project.provider || 'claude') as 'gemini' | 'claude', + sessionId: project.session_id, + }, + } + pushState(addNode(state, node)) + }, [state, pushState]) + + const handleSettingsChange = useCallback((settings: Record) => { + if (settings.ai_provider) setProvider(settings.ai_provider) + }, []) + + return ( +
+
{}} + onDeleteProject={() => {}} + onSettingsChange={handleSettingsChange} + /> +
+ setSidebarCollapsed(prev => !prev)} + onArtboardReady={(siteDir, sessionId, prov) => { + const viewport = 'desktop' as const + const { width, height } = VIEWPORT_SIZES[viewport] + const node: CanvasNode = { + id: crypto.randomUUID(), + type: 'artboard', + x: state.nodes.length * 200, + y: 100, + width, height, + zIndex: 0, + data: { + name: siteDir.replace('site-', ''), + siteDir, + viewport, + provider: (prov || 'claude') as 'gemini' | 'claude', + sessionId, + }, + } + pushState(addNode(state, node)) + }} + onUpdateNode={handleUpdateNode} + onUpdateNodeData={handleUpdateNodeData} + onDeleteNode={handleDeleteNode} + onBringToFront={handleBringToFront} + onSendToBack={handleSendToBack} + onEditInChat={undefined} + externalPrompt={externalPrompt} + onExternalPromptConsumed={() => setExternalPrompt(null)} + /> +
e.preventDefault()} + onDrop={handleFileDrop} + > + { setViewportX(x); setViewportY(y); setZoom(z) }} + onLassoUpdate={(rect) => { + if (!rect) { + setSelectedIds(new Set()) + return + } + const ids = new Set() + for (const node of state.nodes) { + if ( + node.x < rect.x + rect.width && + node.x + node.width > rect.x && + node.y < rect.y + rect.height && + node.y + node.height > rect.y + ) { + ids.add(node.id) + } + } + setSelectedIds(ids) + }} + > + {state.nodes.length === 0 && ( +
+

Start by adding pins and generating your first page in the Setup tab

+

← Use the sidebar to get started

+
+ )} + {state.nodes.map(node => ( + { + setSelectedIds(prev => { + if (shift) { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + } + if (prev.has(id)) return prev + return new Set([id]) + }) + }} + onMove={(id, deltaX, deltaY) => { + // Move all selected nodes by the same delta + if (selectedIds.has(id) && selectedIds.size > 1) { + const movedNode = state.nodes.find(n => n.id === id) + if (!movedNode) return + const dx = deltaX - movedNode.x + const dy = deltaY - movedNode.y + let s = state + for (const selectedId of selectedIds) { + const n = s.nodes.find(nd => nd.id === selectedId) + if (n) s = moveNode(s, selectedId, n.x + dx, n.y + dy) + } + pushState(s) + } else { + pushState(moveNode(state, id, deltaX, deltaY)) + } + setMultiDragOffset(null) + }} + onDragOffsetChange={(dx, dy) => { + if (selectedIds.size > 1 && selectedIds.has(node.id)) { + if (dx === 0 && dy === 0) { + setMultiDragOffset(null) + } else { + setMultiDragOffset({ dx, dy, sourceId: node.id }) + } + } + }} + onResize={(id, width, height, x, y) => { + pushState(updateNode(state, id, { width, height, x, y })) + }} + onUpdateData={(data) => { + pushState(updateNode(state, node.id, { data })) + }} + onToggleExclude={(id) => { + const n = state.nodes.find(x => x.id === id) + if (n) pushState(updateNode(state, id, { excludeFromExport: !n.excludeFromExport })) + }} + onContextMenu={handleContextMenu} + onDragStateChange={setIsDraggingNode} + /> + ))} +
+ + { setViewportX(-x * zoom); setViewportY(-y * zoom) }} + /> + {contextMenu && ( +
e.stopPropagation()} + > + + +
+ +
+ )} +
+
+
+ ) +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 27e6ef0..2dc7602 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,429 +1,5 @@ -"use client"; - -import { useState, useCallback, useEffect, useRef } from "react"; -import Header from "@/components/Header"; -import PinBoard from "@/components/PinBoard"; -import GitHubPanel from "@/components/GitHubPanel"; -import PresetsPanel from "@/components/PresetsPanel"; -import GeneratePanel from "@/components/GeneratePanel"; -import PreviewFrame from "@/components/PreviewFrame"; -import RefinementChat from "@/components/RefinementChat"; -import ViteSetupTerminal from "@/components/ViteSetupTerminal"; -import CreativeDirectorTerminal from "@/components/CreativeDirectorTerminal"; -import BriefEditor from "@/components/BriefEditor"; -import ClaudeTerminal from "@/components/ClaudeTerminal"; -import type { Brief } from "@/lib/brief"; - -type Phase = "empty" | "preparing" | "brief" | "building" | "preview" | "vite-booting"; - -interface ChatMessage { - role: "user" | "assistant"; - content: string; -} - -function loadSession(): { siteDir: string | null; sessionId?: string; provider: string; previewUrl?: string; isVite?: boolean; phase?: Phase } { - if (typeof window === "undefined") return { siteDir: null, provider: "gemini" }; - try { - const saved = sessionStorage.getItem("pinlaunch_session"); - if (saved) return JSON.parse(saved); - } catch {} - return { siteDir: null, provider: "gemini" }; -} +import { redirect } from 'next/navigation' export default function Home() { - const [siteDir, setSiteDir] = useState(() => loadSession().siteDir); - const [sessionId, setSessionId] = useState(() => loadSession().sessionId); - const [provider, setProvider] = useState(() => loadSession().provider); - const [previewUrl, setPreviewUrl] = useState(() => loadSession().previewUrl); - const [isVite, setIsVite] = useState(() => loadSession().isVite || false); - const [phase, setPhase] = useState(() => loadSession().phase || "empty"); - const [refreshTrigger, setRefreshTrigger] = useState(0); - const [refinementMessages, setRefinementMessages] = useState([]); - - // Brief state - const [brief, setBrief] = useState(null); - const [briefSiteDir, setBriefSiteDir] = useState(null); - const [userPrompt, setUserPrompt] = useState(""); - - // Preview tab state (for switching between preview, brief, and build log) - type PreviewTab = "preview" | "brief" | "log"; - const [previewTab, setPreviewTab] = useState("preview"); - const [buildLog, setBuildLog] = useState<{ id: number; type: string; content: string; meta?: string }[]>([]); - const buildLogIdRef = useRef(0); - - // Fetch initial provider from settings - useEffect(() => { - fetch("/api/settings") - .then((r) => r.json()) - .then((s) => { if (s.ai_provider) setProvider(s.ai_provider); }) - .catch(() => {}); - }, []); - - const handleSettingsChange = useCallback((settings: Record) => { - if (settings.ai_provider) setProvider(settings.ai_provider); - }, []); - - // On load: check for stale Vite sessions or restore brief state - useEffect(() => { - if (phase === "preview" && isVite && siteDir) { - fetch(`/api/vite/status?siteDir=${siteDir}`) - .then((r) => r.json()) - .then((data) => { - if (data.running) { - setPreviewUrl(data.url); - } else { - setPhase("vite-booting"); - } - }) - .catch(() => { - setSiteDir(null); - setPhase("empty"); - }); - } else if (phase === "brief" && siteDir) { - // Reload brief from disk - fetch(`/api/brief?siteDir=${siteDir}`) - .then((r) => r.ok ? r.json() : null) - .then((b) => { - if (b) { setBrief(b); setBriefSiteDir(siteDir); } - else setPhase("empty"); - }) - .catch(() => setPhase("empty")); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Persist session - useEffect(() => { - try { - sessionStorage.setItem("pinlaunch_session", JSON.stringify({ siteDir, sessionId, provider, previewUrl, isVite, phase })); - } catch {} - }, [siteDir, sessionId, provider, previewUrl, isVite, phase]); - - // --- Handlers --- - - const handlePrepareBrief = useCallback((prompt: string) => { - setUserPrompt(prompt); - setPhase("preparing"); - setBrief(null); - setBriefSiteDir(null); - }, []); - - const handleBriefReady = useCallback((result: { brief: Brief; siteDir: string; sessionId?: string }) => { - setBrief(result.brief); - setBriefSiteDir(result.siteDir); - setSiteDir(result.siteDir); - setSessionId(result.sessionId); - setPhase("brief"); - }, []); - - const handleBuild = useCallback((editedBrief: Brief) => { - setBrief(editedBrief); - setBuildLog([]); - buildLogIdRef.current = 0; - setPhase("building"); - }, []); - - const handleRegenerateBrief = useCallback(() => { - setPhase("preparing"); - setBrief(null); - }, []); - - const handleBuildComplete = useCallback((result: { - previewUrl: string; - fileCount: number; - files: string[]; - outputDir: string; - sessionId?: string; - isVite?: boolean; - }) => { - const dirName = result.outputDir.split("/").pop() || result.outputDir; - setSiteDir(dirName); - setSessionId(result.sessionId); - setPreviewUrl(result.previewUrl); - const vite = result.isVite || false; - setIsVite(vite); - setRefinementMessages([]); - - setPreviewTab("preview"); - if (vite && !result.previewUrl?.startsWith("http://localhost")) { - setPhase("vite-booting"); - } else { - setPhase("preview"); - } - setRefreshTrigger((n) => n + 1); - }, []); - - const handleFileChange = useCallback(() => { - setRefreshTrigger((n) => n + 1); - }, []); - - const handleRefinementMessage = useCallback((msg: ChatMessage) => { - setRefinementMessages((prev) => [...prev, msg]); - }, []); - - const handleSelectProject = useCallback(async (project: any) => { - setSiteDir(project.site_dir); - setSessionId(project.session_id || undefined); - setProvider(project.provider); - setRefinementMessages([]); - - const projectIsVite = project.framework === "React (Vite)"; - setIsVite(projectIsVite); - - // Check if project has a brief but no built site - try { - const briefRes = await fetch(`/api/brief?siteDir=${project.site_dir}`); - if (briefRes.ok) { - const b = await briefRes.json(); - setBrief(b); - setBriefSiteDir(project.site_dir); - } - } catch {} - - if (projectIsVite) { - try { - const res = await fetch(`/api/vite/status?siteDir=${project.site_dir}`); - const data = await res.json(); - if (data.running) { - setPreviewUrl(data.url); - setPhase("preview"); - } else { - setPreviewUrl(undefined); - setPhase("vite-booting"); - } - } catch { - setPreviewUrl(undefined); - setPhase("vite-booting"); - } - } else { - setPreviewUrl(`/api/preview/${project.site_dir}/`); - setPhase("preview"); - setRefreshTrigger((n) => n + 1); - } - }, []); - - const handleDeleteProject = useCallback((deletedSiteDir: string) => { - // If the currently viewed project was deleted, reset to empty state - if (siteDir === deletedSiteDir) { - setSiteDir(null); - setSessionId(undefined); - setPreviewUrl(undefined); - setIsVite(false); - setPhase("empty"); - setBrief(null); - setBriefSiteDir(null); - setRefinementMessages([]); - setBuildLog([]); - try { sessionStorage.removeItem("pinlaunch_session"); } catch {} - } - }, [siteDir]); - - const handleNewProject = useCallback(() => { - setSiteDir(null); - setSessionId(undefined); - setPreviewUrl(undefined); - setIsVite(false); - setPhase("empty"); - setBrief(null); - setBriefSiteDir(null); - setRefinementMessages([]); - try { sessionStorage.removeItem("pinlaunch_session"); } catch {} - }, []); - - // --- Render --- - - const renderRightPanel = () => { - switch (phase) { - case "preparing": - return ( - { - setPhase("empty"); - // Could show error toast - console.error("Brief generation failed:", msg); - }} - /> - ); - - case "brief": - return brief ? ( - - ) : null; - - case "building": - return ( -
-
-
- - - -
- builder -
-
- { - setPhase("brief"); - console.error("Build failed:", msg); - }} - onFileChange={handleFileChange} - onLogEntry={(entry) => { - setBuildLog((prev) => [...prev, { id: ++buildLogIdRef.current, ...entry }]); - }} - /> -
-
- ); - - case "vite-booting": - return ( -
-
- Starting dev server... -
-
-
- { - setPreviewUrl(result.previewUrl); - setIsVite(true); - setPhase("preview"); - setRefreshTrigger((n) => n + 1); - }} - onError={() => { - setPreviewUrl(`/api/preview/${siteDir}/`); - setIsVite(false); - setPhase("preview"); - }} - /> -
-
-
- ); - - case "preview": - return ( -
- {/* Tab bar — only show if we have a brief or build log */} - {(brief || buildLog.length > 0) && ( -
- {(["preview", "brief", "log"] as PreviewTab[]).map((tab) => { - if (tab === "brief" && !brief) return null; - if (tab === "log" && buildLog.length === 0) return null; - const labels: Record = { preview: "Preview", brief: "Brief", log: "Build Log" }; - return ( - - ); - })} -
- )} -
- {previewTab === "preview" && ( - - )} - {previewTab === "brief" && brief && ( - - )} - {previewTab === "log" && ( -
- {buildLog.map((entry) => ( -
- {entry.type === "system" &&
{entry.content}
} - {entry.type === "tool-start" && ( -
- {entry.content} - {entry.meta && {entry.meta}} -
- )} - {entry.type === "tool-result" &&
{entry.content}
} - {entry.type === "text" &&
{entry.content}
} - {entry.type === "code-preview" && ( -
{entry.content}
- )} - {entry.type === "cost" && ( -
- Cost: {entry.content} - {entry.meta && {entry.meta}} -
- )} -
- ))} - {buildLog.length === 0 &&
No build log available
} -
- )} -
-
- ); - - default: // "empty" - return ; - } - }; - - return ( - <> -
-
-
- {/* Left panel */} -
- - - - - {siteDir && phase === "preview" && ( - - )} -
- - {/* Right panel */} -
- {renderRightPanel()} -
-
-
- - ); + redirect('/canvas') } diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 1648536..04b2b5a 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -18,7 +18,7 @@ export default function Header({ onSelectProject, onNewProject, onDeleteProject, <>
-
+ {/* Logo mark */}
@@ -28,7 +28,7 @@ export default function Header({ onSelectProject, onNewProject, onDeleteProject,

PinLaunch

-
+
{onSelectProject && onNewProject && ( diff --git a/src/components/canvas/ArtboardNode.tsx b/src/components/canvas/ArtboardNode.tsx new file mode 100644 index 0000000..fccf0b6 --- /dev/null +++ b/src/components/canvas/ArtboardNode.tsx @@ -0,0 +1,113 @@ +'use client' + +import { useState, useCallback, useMemo } from 'react' +import type { ArtboardNodeData } from '@/lib/canvas-types' + +interface ArtboardNodeProps { + width: number + height: number + data: ArtboardNodeData + selected: boolean + isVisible: boolean + onMouseDown: (e: React.MouseEvent) => void + onToggleExclude: () => void + onEditInChat: () => void +} + +export default function ArtboardNode({ width, height, data, selected, isVisible, onMouseDown, onToggleExclude, onEditInChat }: ArtboardNodeProps) { + const [interactive, setInteractive] = useState(false) + + const previewUrl = useMemo( + () => `/api/preview/${data.siteDir}/?t=${Date.now()}`, + [data.siteDir] + ) + + const handleDoubleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + setInteractive(true) + }, []) + + const handleExitInteractive = useCallback(() => { + setInteractive(false) + }, []) + + return ( +
+
+ {data.name} + {data.viewport} + + +
+ +
+ {isVisible ? ( + <> +