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:
parent
32d85223f2
commit
d696707430
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
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
|
||||
const [mobileTab, setMobileTab] = useState<TaskStatus>("todo")
|
||||
|
||||
const getColumnTasks = (status: TaskStatus) =>
|
||||
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
|
||||
})
|
||||
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user