feat: mobile-responsive layout
All checks were successful
Deploy Web Client / deploy (push) Successful in 36s

- Sidebar: burger menu on mobile, slide-in overlay
- Kanban: tab selector + vertical cards on mobile
- Move task buttons (← →) on mobile instead of drag
- Login: full-width on small screens
- Header: padding for burger button
This commit is contained in:
Markov 2026-02-15 19:48:23 +01:00
parent d7a9bc7a59
commit 6ac4f4450b
4 changed files with 205 additions and 55 deletions

View File

@ -32,7 +32,7 @@ export default function ProjectPage() {
<div className="flex h-screen">
<Sidebar projects={projects} activeSlug={slug} />
<main className="flex-1 flex flex-col overflow-hidden">
<header className="border-b border-[var(--border)] px-6 py-4 flex items-center gap-4">
<header className="border-b border-[var(--border)] px-6 py-4 flex items-center gap-4 pl-14 md:pl-6">
<div>
<h1 className="text-xl font-bold">{project.name}</h1>
{project.description && (

View File

@ -35,7 +35,7 @@ export default function LoginPage() {
return (
<div className="flex h-screen items-center justify-center">
<form onSubmit={handleSubmit} className="w-80">
<form onSubmit={handleSubmit} className="w-full max-w-80 px-4">
<h1 className="text-3xl font-bold mb-1 text-center">Team Board</h1>
<p className="text-[var(--muted)] mb-6 text-center text-sm">AI Agent Collaboration Platform</p>

View File

@ -28,6 +28,7 @@ export default function KanbanBoard({ projectId }: Props) {
const [newTaskTitle, setNewTaskTitle] = useState("");
const [addingTo, setAddingTo] = useState<string | null>(null);
const [draggedTask, setDraggedTask] = useState<string | null>(null);
const [activeColumn, setActiveColumn] = useState<string | null>(null);
const loadTasks = async () => {
try {
@ -47,14 +48,22 @@ export default function KanbanBoard({ projectId }: Props) {
const task = tasks.find((t) => t.id === draggedTask);
if (!task || task.status === status) return;
// Optimistic update
setTasks((prev) => prev.map((t) => (t.id === draggedTask ? { ...t, status } : t)));
setDraggedTask(null);
try {
await updateTask(draggedTask, { status });
} catch {
loadTasks(); // revert
loadTasks();
}
};
const handleMoveTask = async (taskId: string, newStatus: string) => {
setTasks((prev) => prev.map((t) => (t.id === taskId ? { ...t, status: newStatus } : t)));
try {
await updateTask(taskId, { status: newStatus });
} catch {
loadTasks();
}
};
@ -78,51 +87,147 @@ export default function KanbanBoard({ projectId }: Props) {
return <div className="flex items-center justify-center h-64 text-[var(--muted)]">Загрузка...</div>;
}
// Mobile: column selector + vertical list
// Desktop: horizontal kanban
return (
<div className="flex gap-4 overflow-x-auto p-4 h-full">
{COLUMNS.map((col) => {
const colTasks = tasks.filter((t) => t.status === col.key);
return (
<div
key={col.key}
className="flex flex-col min-w-[280px] w-[280px] shrink-0"
onDragOver={(e) => e.preventDefault()}
onDrop={() => handleDrop(col.key)}
>
{/* Column header */}
<div className="flex items-center gap-2 mb-3 px-1">
<div className="w-3 h-3 rounded-full" style={{ background: col.color }} />
<span className="font-semibold text-sm">{col.label}</span>
<span className="text-xs text-[var(--muted)] ml-auto">{colTasks.length}</span>
</div>
<div className="h-full flex flex-col">
{/* Mobile column tabs */}
<div className="md:hidden flex overflow-x-auto border-b border-[var(--border)] px-2 py-1 gap-1 shrink-0">
{COLUMNS.map((col) => {
const count = tasks.filter((t) => t.status === col.key).length;
return (
<button
key={col.key}
onClick={() => setActiveColumn(col.key)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs whitespace-nowrap shrink-0 transition-colors
${(activeColumn || "draft") === col.key
? "bg-white/10 text-[var(--fg)]"
: "text-[var(--muted)]"
}`}
>
<span className="w-2 h-2 rounded-full" style={{ background: col.color }} />
{col.label}
{count > 0 && <span className="text-[var(--muted)]">{count}</span>}
</button>
);
})}
</div>
{/* Cards */}
<div className="flex flex-col gap-2 flex-1 min-h-[100px] rounded-lg bg-[var(--bg)] p-2">
{/* Mobile: single column view */}
<div className="md:hidden flex-1 overflow-y-auto p-3">
{(() => {
const col = COLUMNS.find((c) => c.key === (activeColumn || "draft"))!;
const colTasks = tasks.filter((t) => t.status === col.key);
const colIndex = COLUMNS.findIndex((c) => c.key === col.key);
return (
<div className="flex flex-col gap-2">
{colTasks.map((task) => (
<div
key={task.id}
draggable
onDragStart={() => setDraggedTask(task.id)}
className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 cursor-grab
hover:border-[var(--accent)] transition-colors active:cursor-grabbing"
className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3"
>
<div className="flex items-start gap-2">
<div
className="w-2 h-2 rounded-full mt-1.5 shrink-0"
style={{ background: PRIORITY_COLORS[task.priority] || "#737373" }}
title={task.priority}
/>
<span className="text-sm">{task.title}</span>
<span className="text-sm flex-1">{task.title}</span>
</div>
{task.description && (
<p className="text-xs text-[var(--muted)] mt-1 ml-4 line-clamp-2">{task.description}</p>
<p className="text-xs text-[var(--muted)] mt-1 ml-4">{task.description}</p>
)}
{/* Move buttons */}
<div className="flex gap-1 mt-2 ml-4">
{colIndex > 0 && (
<button
onClick={() => handleMoveTask(task.id, COLUMNS[colIndex - 1].key)}
className="text-xs px-2 py-0.5 rounded bg-white/5 text-[var(--muted)]
hover:text-[var(--fg)]"
>
{COLUMNS[colIndex - 1].label}
</button>
)}
{colIndex < COLUMNS.length - 1 && (
<button
onClick={() => handleMoveTask(task.id, COLUMNS[colIndex + 1].key)}
className="text-xs px-2 py-0.5 rounded bg-white/5 text-[var(--muted)]
hover:text-[var(--fg)]"
>
{COLUMNS[colIndex + 1].label}
</button>
)}
</div>
</div>
))}
{/* Add task */}
{addingTo === col.key ? (
<div className="mt-1">
<input
autoFocus
className="w-full bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2
text-sm outline-none focus:border-[var(--accent)]"
placeholder="Название задачи..."
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddTask(col.key);
if (e.key === "Escape") setAddingTo(null);
}}
onBlur={() => !newTaskTitle.trim() && setAddingTo(null)}
/>
) : (
<button
className="text-sm text-[var(--muted)] hover:text-[var(--fg)] py-2"
onClick={() => setAddingTo(col.key)}
>
+ Добавить задачу
</button>
)}
</div>
);
})()}
</div>
{/* Desktop: horizontal kanban */}
<div className="hidden md:flex gap-4 overflow-x-auto p-4 flex-1">
{COLUMNS.map((col) => {
const colTasks = tasks.filter((t) => t.status === col.key);
return (
<div
key={col.key}
className="flex flex-col min-w-[280px] w-[280px] shrink-0"
onDragOver={(e) => e.preventDefault()}
onDrop={() => handleDrop(col.key)}
>
<div className="flex items-center gap-2 mb-3 px-1">
<div className="w-3 h-3 rounded-full" style={{ background: col.color }} />
<span className="font-semibold text-sm">{col.label}</span>
<span className="text-xs text-[var(--muted)] ml-auto">{colTasks.length}</span>
</div>
<div className="flex flex-col gap-2 flex-1 min-h-[100px] rounded-lg bg-[var(--bg)] p-2">
{colTasks.map((task) => (
<div
key={task.id}
draggable
onDragStart={() => setDraggedTask(task.id)}
className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3 cursor-grab
hover:border-[var(--accent)] transition-colors active:cursor-grabbing"
>
<div className="flex items-start gap-2">
<div
className="w-2 h-2 rounded-full mt-1.5 shrink-0"
style={{ background: PRIORITY_COLORS[task.priority] || "#737373" }}
title={task.priority}
/>
<span className="text-sm">{task.title}</span>
</div>
{task.description && (
<p className="text-xs text-[var(--muted)] mt-1 ml-4 line-clamp-2">{task.description}</p>
)}
</div>
))}
{addingTo === col.key ? (
<input
autoFocus
className="w-full bg-[var(--card)] border border-[var(--border)] rounded px-2 py-1.5
@ -134,23 +239,21 @@ export default function KanbanBoard({ projectId }: Props) {
if (e.key === "Enter") handleAddTask(col.key);
if (e.key === "Escape") setAddingTo(null);
}}
onBlur={() => {
if (!newTaskTitle.trim()) setAddingTo(null);
}}
onBlur={() => !newTaskTitle.trim() && setAddingTo(null)}
/>
</div>
) : (
<button
className="text-xs text-[var(--muted)] hover:text-[var(--fg)] mt-1 text-left px-2 py-1"
onClick={() => setAddingTo(col.key)}
>
+ Добавить
</button>
)}
) : (
<button
className="text-xs text-[var(--muted)] hover:text-[var(--fg)] mt-1 text-left px-2 py-1"
onClick={() => setAddingTo(col.key)}
>
+ Добавить
</button>
)}
</div>
</div>
</div>
);
})}
);
})}
</div>
</div>
);
}

View File

@ -1,7 +1,9 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Project } from "@/lib/api";
import { logout } from "@/lib/auth-client";
interface Props {
projects: Project[];
@ -9,22 +11,30 @@ interface Props {
}
export default function Sidebar({ projects, activeSlug }: Props) {
return (
<aside className="w-60 shrink-0 border-r border-[var(--border)] h-screen flex flex-col bg-[var(--card)]">
{/* Logo */}
<div className="p-4 border-b border-[var(--border)]">
const [open, setOpen] = useState(false);
const nav = (
<>
<div className="p-4 border-b border-[var(--border)] flex items-center justify-between">
<Link href="/" className="text-lg font-bold tracking-tight">
Team Board
</Link>
{/* Close button on mobile */}
<button
className="md:hidden text-[var(--muted)] hover:text-[var(--fg)] text-xl"
onClick={() => setOpen(false)}
>
</button>
</div>
{/* Projects */}
<nav className="flex-1 overflow-y-auto p-2">
<div className="text-xs uppercase text-[var(--muted)] px-2 py-1 mb-1">Проекты</div>
{projects.map((p) => (
<Link
key={p.id}
href={`/projects/${p.slug}`}
onClick={() => setOpen(false)}
className={`block px-3 py-2 rounded text-sm transition-colors ${
activeSlug === p.slug
? "bg-[var(--accent)]/10 text-[var(--accent)]"
@ -36,10 +46,47 @@ export default function Sidebar({ projects, activeSlug }: Props) {
))}
</nav>
{/* Footer */}
<div className="p-3 border-t border-[var(--border)] text-xs text-[var(--muted)]">
Team Board v0.1
<div className="p-3 border-t border-[var(--border)] flex items-center justify-between">
<span className="text-xs text-[var(--muted)]">Team Board v0.1</span>
<button
onClick={logout}
className="text-xs text-[var(--muted)] hover:text-[var(--fg)]"
>
Выйти
</button>
</div>
</aside>
</>
);
return (
<>
{/* Mobile hamburger */}
<button
className="md:hidden fixed top-3 left-3 z-40 p-2 bg-[var(--card)] border border-[var(--border)]
rounded-lg text-[var(--fg)]"
onClick={() => setOpen(true)}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M2 4.5h16M2 10h16M2 15.5h16" stroke="currentColor" strokeWidth="1.5" fill="none" />
</svg>
</button>
{/* Mobile overlay */}
{open && (
<div className="md:hidden fixed inset-0 bg-black/60 z-40" onClick={() => setOpen(false)} />
)}
{/* Sidebar: always visible on desktop, slide-in on mobile */}
<aside
className={`
fixed md:static z-50 top-0 left-0 h-screen w-60 shrink-0
border-r border-[var(--border)] bg-[var(--card)] flex flex-col
transition-transform duration-200
${open ? "translate-x-0" : "-translate-x-full md:translate-x-0"}
`}
>
{nav}
</aside>
</>
);
}