Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
d59d762
Add canvas workspace design spec
csacsi Mar 25, 2026
dfc7f53
Add canvas workspace implementation plan
csacsi Mar 25, 2026
8299ed5
chore: add .worktrees/ and .superpowers/ to .gitignore
csacsi Mar 25, 2026
15c3b67
feat(canvas): add TypeScript types for canvas state model
csacsi Mar 25, 2026
7640dd4
feat(canvas): add state management with CRUD and undo/redo
csacsi Mar 25, 2026
3873f4f
feat(canvas): add canvas_state table to SQLite schema
csacsi Mar 25, 2026
78fc6a7
feat(canvas): add GET/PUT/POST API route for canvas state
csacsi Mar 25, 2026
a3b3ebf
feat(canvas): add image upload API route
csacsi Mar 25, 2026
4fb267e
feat(canvas): add react-zoom-pan-pinch and roughjs dependencies
csacsi Mar 25, 2026
39c63fc
feat(canvas): add Canvas component with pan/zoom and dot grid
csacsi Mar 25, 2026
6fcb447
feat(canvas): add /canvas page with pan/zoom canvas and state management
csacsi Mar 25, 2026
f062608
feat(canvas): redirect / to /canvas
csacsi Mar 25, 2026
2d1bd0b
fix(canvas): correct TypeScript type for saveTimer ref
csacsi Mar 25, 2026
0ae0cb1
feat(canvas): add ShapeNode with roughjs SVG rendering
csacsi Mar 25, 2026
041a2ef
feat(canvas): add WidgetNode with roughjs SVG rendering
csacsi Mar 25, 2026
aa68c08
feat(canvas): add CanvasNode dispatcher with drag support
csacsi Mar 25, 2026
df98aee
feat(canvas): render nodes on canvas with selection and drag
csacsi Mar 25, 2026
0cbd682
feat(canvas): add ImageNode with missing-file placeholder
csacsi Mar 25, 2026
283ef26
feat(canvas): add DocumentNode with inline markdown editing
csacsi Mar 25, 2026
89c9106
feat(canvas): wire ImageNode and DocumentNode into dispatcher
csacsi Mar 25, 2026
c72ec91
feat(canvas): add ArtboardNode with iframe overlay and interactive mode
csacsi Mar 25, 2026
12c4cbe
feat(canvas): wire ArtboardNode into dispatcher
csacsi Mar 25, 2026
23640cd
feat(canvas): add InspectorPanel for node property editing
csacsi Mar 25, 2026
e90149e
feat(canvas): add Sidebar with Setup/Chat/Inspector tabs
csacsi Mar 25, 2026
9bb7347
feat(canvas): add CanvasToolbar with shape/widget/image/doc creation
csacsi Mar 25, 2026
eb36591
feat(canvas): wire toolbar node creation and Sidebar with artboard ge…
csacsi Mar 25, 2026
b788b8d
feat(canvas): add Minimap with bounding-box view and viewport indicator
csacsi Mar 25, 2026
34f8546
feat(canvas): wire Minimap into canvas page
csacsi Mar 25, 2026
86db0e4
feat(canvas): add image upload via drag-and-drop
csacsi Mar 25, 2026
38f420b
feat(canvas): update Header navigation for canvas route
csacsi Mar 25, 2026
08f54d0
fix(generate): use en-US locale for star count formatting to match te…
csacsi Mar 25, 2026
b3d7a89
fix(canvas): fix image upload response, add image serving route, fix …
csacsi Mar 25, 2026
d355d0d
fix(canvas): fix node drag-and-drop not working
csacsi Mar 25, 2026
af65b13
fix(canvas): disable panning during node drag
csacsi Mar 25, 2026
e6dedb4
feat(canvas): add green selection highlight on nodes
csacsi Mar 25, 2026
2b463a5
feat(canvas): add lasso/marquee selection with green rectangle
csacsi Mar 25, 2026
876f1b9
merge: integrate main branch changes
csacsi Mar 25, 2026
abf897a
feat(canvas): add Header with project selector, fix provider default
csacsi Mar 25, 2026
8b3635d
feat(canvas): wire full brief generation flow into Sidebar
csacsi Mar 25, 2026
11f3f99
feat(canvas): add resize handles on selected nodes
csacsi Mar 25, 2026
fdbfd97
feat(canvas): two-finger scroll pans, pinch zooms
csacsi Mar 25, 2026
c7e41ef
feat(canvas): Shift locks aspect ratio, Alt resizes from center
csacsi Mar 25, 2026
0303746
feat(canvas): live lasso selection, Escape cancel, multi-drag
csacsi Mar 25, 2026
189534c
feat(canvas): live multi-drag — all selected nodes move together
csacsi Mar 25, 2026
3819b09
feat(canvas): zoom-independent resize handles and selection outline
csacsi Mar 25, 2026
681e462
fix(canvas): resize handles to 8px screen size
csacsi Mar 25, 2026
7a0db9e
fix(canvas): click on empty area deselects all nodes
csacsi Mar 25, 2026
fb4b85a
feat(canvas): bottom pill toolbar with icons and prompt input
csacsi Mar 25, 2026
6c4e3e6
fix(canvas): move toolbar outside Canvas component for correct positi…
csacsi Mar 25, 2026
5990d52
fix(canvas): use fixed positioning for toolbar visibility
csacsi Mar 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
52 changes: 52 additions & 0 deletions src/app/api/canvas/route.ts
Original file line number Diff line number Diff line change
@@ -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)
}
44 changes: 44 additions & 0 deletions src/app/api/uploads/[filename]/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
'.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 })
}
}
26 changes: 26 additions & 0 deletions src/app/api/uploads/route.ts
Original file line number Diff line number Diff line change
@@ -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}` })
}
Loading