- packages/bridge/Dockerfile: multi-stage build (node:22-alpine) - frontend/Dockerfile: multi-stage build + nginx:alpine - frontend/nginx.conf: proxy /bridge to bridge service - docker-compose.yml: bridge (3456) + frontend (8080) - .dockerignore: exclude backend/node_modules/dist - Fix vite proxy target to port 3456 - .env with bridge vars (AUTH_MODE=dev)
89 lines
3.1 KiB
TypeScript
89 lines
3.1 KiB
TypeScript
import type { TaskSummary, TaskStatus, ColumnDef } from '@kanban/contracts'
|
|
import TaskCard from './TaskCard'
|
|
|
|
interface Props {
|
|
tasks: TaskSummary[]
|
|
columns: ColumnDef[]
|
|
onTaskTap: (task: TaskSummary) => void
|
|
onRefresh: () => void
|
|
}
|
|
|
|
export default function KanbanBoard({ tasks, columns, onTaskTap, onRefresh }: Props) {
|
|
// Group tasks by status
|
|
const grouped: Record<TaskStatus, TaskSummary[]> = {} as Record<TaskStatus, TaskSummary[]>
|
|
for (const col of columns) {
|
|
grouped[col.status] = []
|
|
}
|
|
for (const task of tasks) {
|
|
const status = task.status as TaskStatus
|
|
if (!grouped[status]) {
|
|
grouped[status] = []
|
|
}
|
|
grouped[status].push(task)
|
|
}
|
|
|
|
return (
|
|
<div className="flex-1 overflow-x-auto">
|
|
<div className="flex gap-3 p-4 min-w-max pb-20">
|
|
{columns.map((col) => (
|
|
<div
|
|
key={col.status}
|
|
className="flex-shrink-0 w-[280px] flex flex-col"
|
|
>
|
|
{/* Column header */}
|
|
<div className="flex items-center justify-between mb-3 px-1">
|
|
<div className="flex items-center gap-2">
|
|
<StatusDot status={col.status} />
|
|
<h3 className="font-medium text-sm text-gray-300 uppercase tracking-wider">
|
|
{col.title}
|
|
</h3>
|
|
</div>
|
|
<span className="text-xs text-gray-500 bg-white/5 px-2 py-0.5 rounded-full">
|
|
{grouped[col.status]?.length || 0}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Column content */}
|
|
<div className="flex-1 space-y-2 min-h-[200px]">
|
|
{grouped[col.status]?.map((task) => (
|
|
<TaskCard
|
|
key={task.id}
|
|
task={task}
|
|
onTap={() => onTaskTap(task)}
|
|
/>
|
|
))}
|
|
{(!grouped[col.status] || grouped[col.status].length === 0) && (
|
|
<div className="text-center py-8 text-gray-600 text-sm">
|
|
No tasks
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Pull to refresh hint */}
|
|
<button
|
|
onClick={onRefresh}
|
|
className="fixed bottom-4 right-4 p-3 bg-blue-600 rounded-full shadow-lg z-10"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-white" viewBox="0 0 20 20" fill="currentColor">
|
|
<path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function StatusDot({ status }: { status: TaskStatus }) {
|
|
const colors: Record<TaskStatus, string> = {
|
|
triage: 'bg-gray-400',
|
|
todo: 'bg-blue-400',
|
|
ready: 'bg-yellow-400',
|
|
running: 'bg-green-400',
|
|
blocked: 'bg-red-400',
|
|
done: 'bg-emerald-400',
|
|
archived: 'bg-gray-500',
|
|
}
|
|
return <div className={`w-2 h-2 rounded-full ${colors[status] || 'bg-gray-400'}`} />
|
|
}
|