Initial commit
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import type { GameBridge } from '../game/GameBridge'
|
||||
import {
|
||||
DEFAULT_MAP_STR,
|
||||
DEFAULT_SEATS,
|
||||
parseMapStr,
|
||||
gridToMapStr,
|
||||
} from '../game/map/OfficeMapBuilder'
|
||||
import { getOffices, type OfficeConfig } from '../game/map/OfficeStore'
|
||||
import { OFFICE_COLS, OFFICE_ROWS, TILE_SIZE } from '../game/config'
|
||||
|
||||
interface Props {
|
||||
bridge: GameBridge
|
||||
}
|
||||
|
||||
type EditorMode = 'wall' | 'floor' | 'seat'
|
||||
|
||||
const BG_ASSET_URL = 'assets/office-bg.png'
|
||||
|
||||
export function CollisionEditor({ bridge }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const bgImgRef = useRef<HTMLImageElement | null>(null)
|
||||
const isPainting = useRef(false)
|
||||
|
||||
const [offices, setOffices] = useState<OfficeConfig[]>(() => getOffices())
|
||||
const [selectedOfficeId, setSelectedOfficeId] = useState<string>(() => getOffices()[0]?.id ?? 'office-0')
|
||||
|
||||
const selectedOffice = offices.find(o => o.id === selectedOfficeId) ?? offices[0]
|
||||
|
||||
const [grid, setGrid] = useState<number[][]>(() => parseMapStr(selectedOffice?.mapStr ?? DEFAULT_MAP_STR, OFFICE_COLS, OFFICE_ROWS))
|
||||
const [seats, setSeats] = useState<[number, number][]>(() => [...(selectedOffice?.seats ?? DEFAULT_SEATS)])
|
||||
const [mode, setMode] = useState<EditorMode>('wall')
|
||||
const [bgLoaded, setBgLoaded] = useState(false)
|
||||
const [showExport, setShowExport] = useState(false)
|
||||
const [applied, setApplied] = useState(false)
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [showGrid, setShowGrid] = useState(true)
|
||||
|
||||
const cols = OFFICE_COLS
|
||||
const rows = OFFICE_ROWS
|
||||
|
||||
const switchOffice = (officeId: string) => {
|
||||
const refreshed = getOffices()
|
||||
setOffices(refreshed)
|
||||
setSelectedOfficeId(officeId)
|
||||
const office = refreshed.find(o => o.id === officeId)
|
||||
if (office) {
|
||||
setGrid(parseMapStr(office.mapStr, OFFICE_COLS, OFFICE_ROWS))
|
||||
setSeats([...office.seats])
|
||||
}
|
||||
setApplied(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => { bgImgRef.current = img; setBgLoaded(true) }
|
||||
img.onerror = () => { bgImgRef.current = null; setBgLoaded(true) }
|
||||
img.src = BG_ASSET_URL
|
||||
}, [])
|
||||
|
||||
const render = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const w = cols * TILE_SIZE
|
||||
const h = rows * TILE_SIZE
|
||||
canvas.width = w
|
||||
canvas.height = h
|
||||
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
if (bgImgRef.current) {
|
||||
ctx.drawImage(bgImgRef.current, 0, 0, w, h)
|
||||
} else {
|
||||
ctx.fillStyle = '#3d3d3d'
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
}
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const x0 = c * TILE_SIZE
|
||||
const y0 = r * TILE_SIZE
|
||||
const blocked = grid[r]?.[c] === 1
|
||||
ctx.fillStyle = blocked ? 'rgba(255, 40, 40, 0.35)' : 'rgba(40, 255, 40, 0.2)'
|
||||
ctx.fillRect(x0, y0, TILE_SIZE, TILE_SIZE)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [c, r] of seats) {
|
||||
const cx = c * TILE_SIZE + TILE_SIZE / 2
|
||||
const cy = r * TILE_SIZE + TILE_SIZE / 2
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, 9, 0, Math.PI * 2)
|
||||
ctx.fillStyle = 'rgba(255, 220, 0, 0.85)'
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = '#000'
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
if (showGrid) {
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)'
|
||||
ctx.lineWidth = 0.5
|
||||
for (let c = 0; c <= cols; c++) {
|
||||
ctx.beginPath(); ctx.moveTo(c * TILE_SIZE, 0); ctx.lineTo(c * TILE_SIZE, h); ctx.stroke()
|
||||
}
|
||||
for (let r = 0; r <= rows; r++) {
|
||||
ctx.beginPath(); ctx.moveTo(0, r * TILE_SIZE); ctx.lineTo(w, r * TILE_SIZE); ctx.stroke()
|
||||
}
|
||||
ctx.font = '10px monospace'
|
||||
ctx.textBaseline = 'top'
|
||||
for (let c = 0; c < cols; c++) { ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.fillText(String(c), c * TILE_SIZE + 2, 2) }
|
||||
for (let r = 0; r < rows; r++) { ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.fillText(String(r), 2, r * TILE_SIZE + 2) }
|
||||
}
|
||||
}, [grid, seats, cols, rows, showGrid, bgLoaded])
|
||||
|
||||
useEffect(() => { render() }, [render])
|
||||
|
||||
const getCellFromEvent = (e: React.MouseEvent<HTMLCanvasElement>): { c: number; r: number } | null => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return null
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const scaleX = canvas.width / rect.width
|
||||
const scaleY = canvas.height / rect.height
|
||||
const c = Math.floor((e.clientX - rect.left) * scaleX / TILE_SIZE)
|
||||
const r = Math.floor((e.clientY - rect.top) * scaleY / TILE_SIZE)
|
||||
if (c < 0 || c >= cols || r < 0 || r >= rows) return null
|
||||
return { c, r }
|
||||
}
|
||||
|
||||
const paint = useCallback((c: number, r: number) => {
|
||||
setApplied(false)
|
||||
if (mode === 'seat') {
|
||||
setSeats(prev => {
|
||||
const idx = prev.findIndex(([sc, sr]) => sc === c && sr === r)
|
||||
if (idx >= 0) return prev.filter((_, i) => i !== idx)
|
||||
return [...prev, [c, r]]
|
||||
})
|
||||
setGrid(prev => {
|
||||
if (prev[r]?.[c] === 1) { const next = prev.map(row => [...row]); next[r][c] = 0; return next }
|
||||
return prev
|
||||
})
|
||||
} else {
|
||||
const value = mode === 'wall' ? 1 : 0
|
||||
setGrid(prev => {
|
||||
if (prev[r]?.[c] === value) return prev
|
||||
const next = prev.map(row => [...row]); next[r][c] = value; return next
|
||||
})
|
||||
if (mode === 'wall') setSeats(prev => prev.filter(([sc, sr]) => !(sc === c && sr === r)))
|
||||
}
|
||||
}, [mode])
|
||||
|
||||
const onMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (e.button !== 0) return
|
||||
isPainting.current = true
|
||||
const cell = getCellFromEvent(e)
|
||||
if (cell) paint(cell.c, cell.r)
|
||||
}
|
||||
const onMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!isPainting.current) return
|
||||
if (mode === 'seat') return
|
||||
const cell = getCellFromEvent(e)
|
||||
if (cell) paint(cell.c, cell.r)
|
||||
}
|
||||
const onMouseUp = () => { isPainting.current = false }
|
||||
|
||||
const handleApply = () => {
|
||||
const mapStr = gridToMapStr(grid)
|
||||
bridge.rebuildOfficeCollision(selectedOfficeId, mapStr, seats)
|
||||
setApplied(true)
|
||||
setOffices(getOffices())
|
||||
setTimeout(() => setApplied(false), 2000)
|
||||
}
|
||||
|
||||
const handleExport = () => setShowExport(v => !v)
|
||||
|
||||
const handleReset = () => {
|
||||
setGrid(parseMapStr(DEFAULT_MAP_STR, OFFICE_COLS, OFFICE_ROWS))
|
||||
setSeats([...DEFAULT_SEATS])
|
||||
setApplied(false)
|
||||
}
|
||||
|
||||
const exportText = (() => {
|
||||
const mapStr = gridToMapStr(grid)
|
||||
const mapLines = mapStr.map((line, i) => ` '${line}', // ${i}`).join('\n')
|
||||
const seatLines = seats.map(([c, r]) => ` [${c}, ${r}],`).join('\n')
|
||||
return `const MAP_STR: string[] = [\n${mapLines}\n]\n\nconst SEATS: [number, number][] = [\n${seatLines}\n]`
|
||||
})()
|
||||
|
||||
const zoomIn = () => setZoom(z => Math.min(z + 0.25, 3))
|
||||
const zoomOut = () => setZoom(z => Math.max(z - 0.25, 0.5))
|
||||
|
||||
const wallCount = grid.flat().filter(v => v === 1).length
|
||||
const floorCount = grid.flat().filter(v => v === 0).length
|
||||
|
||||
return (
|
||||
<div className="collision-editor">
|
||||
<div className="ce-toolbar">
|
||||
<div className="ce-toolbar-group">
|
||||
<span className="ce-title">Map Editor</span>
|
||||
<select
|
||||
className="ce-office-select"
|
||||
value={selectedOfficeId}
|
||||
onChange={e => switchOffice(e.target.value)}
|
||||
>
|
||||
{offices.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="ce-toolbar-group">
|
||||
<button className={`ce-mode-btn ${mode === 'wall' ? 'active wall' : ''}`} onClick={() => setMode('wall')}>
|
||||
<span className="ce-mode-dot wall" /> Wall
|
||||
</button>
|
||||
<button className={`ce-mode-btn ${mode === 'floor' ? 'active floor' : ''}`} onClick={() => setMode('floor')}>
|
||||
<span className="ce-mode-dot floor" /> Floor
|
||||
</button>
|
||||
<button className={`ce-mode-btn ${mode === 'seat' ? 'active seat' : ''}`} onClick={() => setMode('seat')}>
|
||||
<span className="ce-mode-dot seat" /> Seat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ce-toolbar-group">
|
||||
<button className="ce-btn" onClick={zoomOut}>-</button>
|
||||
<span className="ce-zoom-label">{Math.round(zoom * 100)}%</span>
|
||||
<button className="ce-btn" onClick={zoomIn}>+</button>
|
||||
<button className={`ce-btn${showGrid ? ' active' : ''}`} onClick={() => setShowGrid(v => !v)}>Grid</button>
|
||||
</div>
|
||||
|
||||
<div className="ce-toolbar-group">
|
||||
<button className={`ce-btn apply${applied ? ' success' : ''}`} onClick={handleApply}>
|
||||
{applied ? 'Applied' : 'Apply'}
|
||||
</button>
|
||||
<button className={`ce-btn${showExport ? ' active' : ''}`} onClick={handleExport}>Export</button>
|
||||
<button className="ce-btn danger" onClick={handleReset}>Reset</button>
|
||||
</div>
|
||||
|
||||
<div className="ce-toolbar-group ce-stats">
|
||||
<span>Walls: {wallCount}</span>
|
||||
<span>Floor: {floorCount}</span>
|
||||
<span>Seats: {seats.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ce-body">
|
||||
<div className="ce-canvas-wrap" style={{ overflow: 'auto' }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
width: cols * TILE_SIZE * zoom,
|
||||
height: rows * TILE_SIZE * zoom,
|
||||
imageRendering: 'pixelated',
|
||||
cursor: mode === 'seat' ? 'crosshair' : 'cell',
|
||||
}}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseUp}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showExport && (
|
||||
<div className="ce-export-panel">
|
||||
<div className="ce-export-header">
|
||||
<span>Export — copy to source code</span>
|
||||
<button className="ce-btn" onClick={() => navigator.clipboard.writeText(exportText)}>Copy</button>
|
||||
</div>
|
||||
<textarea className="ce-export-textarea" value={exportText} readOnly rows={20} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import type { Project } from '../types/kanban'
|
||||
|
||||
interface ProjectSelectorProps {
|
||||
projects: Project[]
|
||||
activeId: string
|
||||
onSelect: (id: string) => void
|
||||
onCreate: (id: string) => void
|
||||
onDelete?: (id: string) => void
|
||||
}
|
||||
|
||||
export function ProjectSelector({ projects, activeId, onSelect, onCreate, onDelete }: ProjectSelectorProps) {
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
const id = newName.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-|-$/g, '')
|
||||
if (!id) return
|
||||
onCreate(id)
|
||||
setNewName('')
|
||||
setCreating(false)
|
||||
}, [newName, onCreate])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!confirmDelete || !onDelete) return
|
||||
onDelete(confirmDelete)
|
||||
setConfirmDelete(null)
|
||||
}, [confirmDelete, onDelete])
|
||||
|
||||
return (
|
||||
<div className="project-selector" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<select
|
||||
className="theme-select"
|
||||
value={activeId}
|
||||
onChange={e => onSelect(e.target.value)}
|
||||
title="Switch project"
|
||||
>
|
||||
{projects.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{creating ? (
|
||||
<form
|
||||
onSubmit={e => { e.preventDefault(); handleCreate() }}
|
||||
style={{ display: 'flex', gap: 4 }}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
className="theme-select"
|
||||
value={newName}
|
||||
placeholder="project-name"
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Escape') setCreating(false) }}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<button type="submit" className="pill-btn" style={{ fontSize: 11, padding: '2px 8px' }}>+</button>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
className="pill-btn"
|
||||
onClick={() => setCreating(true)}
|
||||
title="New project"
|
||||
style={{ fontSize: 11, padding: '2px 8px' }}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
{onDelete && activeId !== 'default' && !confirmDelete && (
|
||||
<button
|
||||
className="pill-btn"
|
||||
onClick={() => setConfirmDelete(activeId)}
|
||||
title="Delete project"
|
||||
style={{ fontSize: 11, padding: '2px 8px', color: '#ef4444' }}
|
||||
>
|
||||
Del
|
||||
</button>
|
||||
)}
|
||||
|
||||
{confirmDelete && (
|
||||
<div className="project-delete-confirm" style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9999,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'var(--bg-surface, #1e1e2e)', borderRadius: 12, padding: '24px 32px',
|
||||
maxWidth: 400, boxShadow: '0 8px 32px rgba(0,0,0,0.4)', textAlign: 'center',
|
||||
}}>
|
||||
<p style={{ margin: '0 0 8px', fontWeight: 600, fontSize: 15 }}>
|
||||
Delete project "{confirmDelete}"?
|
||||
</p>
|
||||
<p style={{ margin: '0 0 20px', fontSize: 13, opacity: 0.7 }}>
|
||||
All sessions, messages, tasks, and agent data in this project will be permanently deleted. This action cannot be undone.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||
<button
|
||||
className="pill-btn"
|
||||
onClick={() => setConfirmDelete(null)}
|
||||
style={{ padding: '6px 18px', fontSize: 13 }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="pill-btn"
|
||||
onClick={handleDelete}
|
||||
style={{ padding: '6px 18px', fontSize: 13, background: '#ef4444', color: '#fff' }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user