feat(board): responsive mobile layout — tab-based kanban on small screens

- Mobile (<lg): status tabs replace horizontal columns, single column view
- Tab bar with colored underlines, sticky at top, horizontally scrollable
- Mobile column shows full-width task cards with 'Add Task' button
- Header: compact on mobile — shorter title, 'New' instead of 'New Task'
- Desktop (>=lg): unchanged horizontal kanban board
- Error banner margin adapts to screen size
This commit is contained in:
小糯 🐱 2026-04-13 18:04:34 +08:00
parent 32d85223f2
commit d696707430
4 changed files with 134 additions and 34 deletions

View File

@ -151,7 +151,7 @@ function App() {
/>
{error && (
<div className="mx-6 mt-3 rounded-md bg-red-500/10 border border-red-500/30 px-4 py-2 text-sm text-red-400">
<div className="mx-3 sm:mx-6 mt-3 rounded-md bg-red-500/10 border border-red-500/30 px-4 py-2 text-sm text-red-400">
{error}
</div>
)}
@ -171,7 +171,7 @@ function App() {
)}
{connected && (
<main className="flex-1 overflow-hidden">
<main className="flex-1 overflow-hidden flex flex-col">
<KanbanBoard
tasks={tasks}
onTaskClick={handleTaskClick}

View File

@ -10,36 +10,37 @@ interface HeaderProps {
export function Header({ onNewTask, onOpenSettings, taskCount, connected }: HeaderProps) {
return (
<header className="border-b border-zinc-800 bg-zinc-950 px-6 py-3">
<header className="border-b border-zinc-800 bg-zinc-950 px-4 sm:px-6 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center h-8 w-8 rounded-lg bg-zinc-800 text-zinc-300">
<LayoutDashboard className="h-4 w-4" />
<div className="flex items-center gap-2.5 sm:gap-3 min-w-0">
<div className="flex items-center justify-center h-7 w-7 sm:h-8 sm:w-8 rounded-lg bg-zinc-800 text-zinc-300 shrink-0">
<LayoutDashboard className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
</div>
<div>
<h1 className="text-lg font-semibold text-zinc-100 leading-tight">OGraph Task Board</h1>
<p className="text-xs text-zinc-500">
<div className="min-w-0">
<h1 className="text-sm sm:text-lg font-semibold text-zinc-100 leading-tight truncate">OGraph Tasks</h1>
<p className="text-[11px] sm:text-xs text-zinc-500 truncate">
{connected
? `${taskCount} task${taskCount !== 1 ? "s" : ""}`
: "Not connected — configure in Settings"}
: "Not connected"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 sm:gap-2 shrink-0">
<Button
onClick={onOpenSettings}
variant="ghost"
size="icon"
className="text-zinc-500 hover:text-zinc-200"
className="text-zinc-500 hover:text-zinc-200 h-8 w-8"
title="Settings"
>
<Settings className="h-4 w-4" />
</Button>
<Button onClick={onNewTask} size="sm" className="gap-1.5" disabled={!connected}>
<Plus className="h-4 w-4" />
New Task
<Button onClick={onNewTask} size="sm" className="gap-1 sm:gap-1.5 text-xs sm:text-sm" disabled={!connected}>
<Plus className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
<span className="hidden sm:inline">New Task</span>
<span className="sm:hidden">New</span>
</Button>
</div>
</div>

View File

@ -1,4 +1,5 @@
import { ALL_STATUSES, type Task, type TaskStatus } from "@/types"
import { useState } from "react"
import { ALL_STATUSES, STATUS_CONFIG, type Task, type TaskStatus } from "@/types"
import { KanbanColumn } from "@/components/KanbanColumn"
interface KanbanBoardProps {
@ -8,30 +9,85 @@ interface KanbanBoardProps {
onMoveTask: (taskId: number, status: TaskStatus) => void
}
export function KanbanBoard({ tasks, onTaskClick, onAddTask, onMoveTask }: KanbanBoardProps) {
return (
<div className="flex gap-4 overflow-x-auto pb-4 px-6 pt-4">
{ALL_STATUSES.map((status) => {
const columnTasks = tasks
.filter((t) => t.status === status)
.sort((a, b) => {
// Sort by priority first (p0 first), then by updatedAt descending
const prioOrder = a.priority.localeCompare(b.priority)
if (prioOrder !== 0) return prioOrder
return b.updatedAt - a.updatedAt
})
const tabColors: Record<string, string> = {
zinc: "border-zinc-500 text-zinc-300",
blue: "border-blue-500 text-blue-400",
amber: "border-amber-500 text-amber-400",
purple: "border-purple-500 text-purple-400",
emerald: "border-emerald-500 text-emerald-400",
}
return (
export function KanbanBoard({ tasks, onTaskClick, onAddTask, onMoveTask }: KanbanBoardProps) {
const [mobileTab, setMobileTab] = useState<TaskStatus>("todo")
const getColumnTasks = (status: TaskStatus) =>
tasks
.filter((t) => t.status === status)
.sort((a, b) => {
const prioOrder = a.priority.localeCompare(b.priority)
if (prioOrder !== 0) return prioOrder
return b.updatedAt - a.updatedAt
})
return (
<>
{/* Mobile: tab switcher */}
<div className="lg:hidden border-b border-zinc-800 bg-zinc-950/80 backdrop-blur-sm sticky top-0 z-10">
<div className="flex overflow-x-auto px-2 gap-0.5 scrollbar-hide">
{ALL_STATUSES.map((status) => {
const config = STATUS_CONFIG[status]
const count = tasks.filter((t) => t.status === status).length
const isActive = mobileTab === status
return (
<button
key={status}
onClick={() => setMobileTab(status)}
className={`
shrink-0 px-3 py-2.5 text-xs font-medium border-b-2 transition-all
${isActive
? tabColors[config.color]
: "border-transparent text-zinc-500 hover:text-zinc-300"
}
`}
>
{config.label}
{count > 0 && (
<span className={`ml-1.5 text-[10px] ${isActive ? "opacity-80" : "opacity-50"}`}>
{count}
</span>
)}
</button>
)
})}
</div>
</div>
{/* Mobile: single column view */}
<div className="lg:hidden px-3 py-3 flex-1 overflow-y-auto">
<KanbanColumn
key={mobileTab}
status={mobileTab}
tasks={getColumnTasks(mobileTab)}
onTaskClick={onTaskClick}
onAddTask={onAddTask}
onDrop={onMoveTask}
mobile
/>
</div>
{/* Desktop: horizontal kanban */}
<div className="hidden lg:flex gap-4 overflow-x-auto pb-4 px-6 pt-4">
{ALL_STATUSES.map((status) => (
<KanbanColumn
key={status}
status={status}
tasks={columnTasks}
tasks={getColumnTasks(status)}
onTaskClick={onTaskClick}
onAddTask={onAddTask}
onDrop={onMoveTask}
/>
)
})}
</div>
))}
</div>
</>
)
}

View File

@ -9,6 +9,7 @@ interface KanbanColumnProps {
onTaskClick: (task: Task) => void
onAddTask: (status: TaskStatus) => void
onDrop: (taskId: number, status: TaskStatus) => void
mobile?: boolean
}
const borderColors: Record<string, string> = {
@ -27,7 +28,7 @@ const countBgColors: Record<string, string> = {
emerald: "bg-emerald-500/15 text-emerald-400",
}
export function KanbanColumn({ status, tasks, onTaskClick, onAddTask, onDrop }: KanbanColumnProps) {
export function KanbanColumn({ status, tasks, onTaskClick, onAddTask, onDrop, mobile }: KanbanColumnProps) {
const config = STATUS_CONFIG[status]
function handleDragOver(e: React.DragEvent) {
@ -43,6 +44,48 @@ export function KanbanColumn({ status, tasks, onTaskClick, onAddTask, onDrop }:
}
}
if (mobile) {
// Mobile: full-width, no column header (tabs handle that), no fixed width
return (
<div
className="flex flex-col"
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div className="space-y-2">
{tasks.map((task) => (
<div
key={task.id}
draggable
onDragStart={(e) => {
e.dataTransfer.setData("text/plain", String(task.id))
e.dataTransfer.effectAllowed = "move"
}}
>
<TaskCard task={task} onClick={onTaskClick} />
</div>
))}
{tasks.length === 0 && (
<div className="flex items-center justify-center h-24 text-xs text-zinc-600 border border-dashed border-zinc-800 rounded-lg">
No tasks
</div>
)}
</div>
<Button
variant="ghost"
className="mt-3 w-full text-zinc-500 hover:text-zinc-300 border border-dashed border-zinc-800 hover:border-zinc-600 h-9 text-xs"
onClick={() => onAddTask(status)}
>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Add Task
</Button>
</div>
)
}
// Desktop: fixed-width column
return (
<div
className="flex flex-col min-w-[272px] w-[272px] shrink-0"