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 && ( {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} {error}
</div> </div>
)} )}
@ -171,7 +171,7 @@ function App() {
)} )}
{connected && ( {connected && (
<main className="flex-1 overflow-hidden"> <main className="flex-1 overflow-hidden flex flex-col">
<KanbanBoard <KanbanBoard
tasks={tasks} tasks={tasks}
onTaskClick={handleTaskClick} onTaskClick={handleTaskClick}

View File

@ -10,36 +10,37 @@ interface HeaderProps {
export function Header({ onNewTask, onOpenSettings, taskCount, connected }: HeaderProps) { export function Header({ onNewTask, onOpenSettings, taskCount, connected }: HeaderProps) {
return ( 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 justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2.5 sm:gap-3 min-w-0">
<div className="flex items-center justify-center h-8 w-8 rounded-lg bg-zinc-800 text-zinc-300"> <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-4 w-4" /> <LayoutDashboard className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
</div> </div>
<div> <div className="min-w-0">
<h1 className="text-lg font-semibold text-zinc-100 leading-tight">OGraph Task Board</h1> <h1 className="text-sm sm:text-lg font-semibold text-zinc-100 leading-tight truncate">OGraph Tasks</h1>
<p className="text-xs text-zinc-500"> <p className="text-[11px] sm:text-xs text-zinc-500 truncate">
{connected {connected
? `${taskCount} task${taskCount !== 1 ? "s" : ""}` ? `${taskCount} task${taskCount !== 1 ? "s" : ""}`
: "Not connected — configure in Settings"} : "Not connected"}
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5 sm:gap-2 shrink-0">
<Button <Button
onClick={onOpenSettings} onClick={onOpenSettings}
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-zinc-500 hover:text-zinc-200" className="text-zinc-500 hover:text-zinc-200 h-8 w-8"
title="Settings" title="Settings"
> >
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />
</Button> </Button>
<Button onClick={onNewTask} size="sm" className="gap-1.5" disabled={!connected}> <Button onClick={onNewTask} size="sm" className="gap-1 sm:gap-1.5 text-xs sm:text-sm" disabled={!connected}>
<Plus className="h-4 w-4" /> <Plus className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
New Task <span className="hidden sm:inline">New Task</span>
<span className="sm:hidden">New</span>
</Button> </Button>
</div> </div>
</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" import { KanbanColumn } from "@/components/KanbanColumn"
interface KanbanBoardProps { interface KanbanBoardProps {
@ -8,30 +9,85 @@ interface KanbanBoardProps {
onMoveTask: (taskId: number, status: TaskStatus) => void onMoveTask: (taskId: number, status: TaskStatus) => void
} }
export function KanbanBoard({ tasks, onTaskClick, onAddTask, onMoveTask }: KanbanBoardProps) { const tabColors: Record<string, string> = {
return ( zinc: "border-zinc-500 text-zinc-300",
<div className="flex gap-4 overflow-x-auto pb-4 px-6 pt-4"> blue: "border-blue-500 text-blue-400",
{ALL_STATUSES.map((status) => { amber: "border-amber-500 text-amber-400",
const columnTasks = tasks purple: "border-purple-500 text-purple-400",
.filter((t) => t.status === status) emerald: "border-emerald-500 text-emerald-400",
.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
})
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 <KanbanColumn
key={status} key={status}
status={status} status={status}
tasks={columnTasks} tasks={getColumnTasks(status)}
onTaskClick={onTaskClick} onTaskClick={onTaskClick}
onAddTask={onAddTask} onAddTask={onAddTask}
onDrop={onMoveTask} onDrop={onMoveTask}
/> />
) ))}
})} </div>
</div> </>
) )
} }

View File

@ -9,6 +9,7 @@ interface KanbanColumnProps {
onTaskClick: (task: Task) => void onTaskClick: (task: Task) => void
onAddTask: (status: TaskStatus) => void onAddTask: (status: TaskStatus) => void
onDrop: (taskId: number, status: TaskStatus) => void onDrop: (taskId: number, status: TaskStatus) => void
mobile?: boolean
} }
const borderColors: Record<string, string> = { const borderColors: Record<string, string> = {
@ -27,7 +28,7 @@ const countBgColors: Record<string, string> = {
emerald: "bg-emerald-500/15 text-emerald-400", 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] const config = STATUS_CONFIG[status]
function handleDragOver(e: React.DragEvent) { 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 ( return (
<div <div
className="flex flex-col min-w-[272px] w-[272px] shrink-0" className="flex flex-col min-w-[272px] w-[272px] shrink-0"