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"> <div className="flex h-screen">
<Sidebar projects={projects} activeSlug={slug} /> <Sidebar projects={projects} activeSlug={slug} />
<main className="flex-1 flex flex-col overflow-hidden"> <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> <div>
<h1 className="text-xl font-bold">{project.name}</h1> <h1 className="text-xl font-bold">{project.name}</h1>
{project.description && ( {project.description && (

View File

@ -35,7 +35,7 @@ export default function LoginPage() {
return ( return (
<div className="flex h-screen items-center justify-center"> <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> <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> <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 [newTaskTitle, setNewTaskTitle] = useState("");
const [addingTo, setAddingTo] = useState<string | null>(null); const [addingTo, setAddingTo] = useState<string | null>(null);
const [draggedTask, setDraggedTask] = useState<string | null>(null); const [draggedTask, setDraggedTask] = useState<string | null>(null);
const [activeColumn, setActiveColumn] = useState<string | null>(null);
const loadTasks = async () => { const loadTasks = async () => {
try { try {
@ -47,14 +48,22 @@ export default function KanbanBoard({ projectId }: Props) {
const task = tasks.find((t) => t.id === draggedTask); const task = tasks.find((t) => t.id === draggedTask);
if (!task || task.status === status) return; if (!task || task.status === status) return;
// Optimistic update
setTasks((prev) => prev.map((t) => (t.id === draggedTask ? { ...t, status } : t))); setTasks((prev) => prev.map((t) => (t.id === draggedTask ? { ...t, status } : t)));
setDraggedTask(null); setDraggedTask(null);
try { try {
await updateTask(draggedTask, { status }); await updateTask(draggedTask, { status });
} catch { } 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,8 +87,108 @@ export default function KanbanBoard({ projectId }: Props) {
return <div className="flex items-center justify-center h-64 text-[var(--muted)]">Загрузка...</div>; return <div className="flex items-center justify-center h-64 text-[var(--muted)]">Загрузка...</div>;
} }
// Mobile: column selector + vertical list
// Desktop: horizontal kanban
return ( return (
<div className="flex gap-4 overflow-x-auto p-4 h-full"> <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>
{/* 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}
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" }}
/>
<span className="text-sm flex-1">{task.title}</span>
</div>
{task.description && (
<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>
))}
{addingTo === col.key ? (
<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) => { {COLUMNS.map((col) => {
const colTasks = tasks.filter((t) => t.status === col.key); const colTasks = tasks.filter((t) => t.status === col.key);
return ( return (
@ -89,14 +198,12 @@ export default function KanbanBoard({ projectId }: Props) {
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
onDrop={() => handleDrop(col.key)} onDrop={() => handleDrop(col.key)}
> >
{/* Column header */}
<div className="flex items-center gap-2 mb-3 px-1"> <div className="flex items-center gap-2 mb-3 px-1">
<div className="w-3 h-3 rounded-full" style={{ background: col.color }} /> <div className="w-3 h-3 rounded-full" style={{ background: col.color }} />
<span className="font-semibold text-sm">{col.label}</span> <span className="font-semibold text-sm">{col.label}</span>
<span className="text-xs text-[var(--muted)] ml-auto">{colTasks.length}</span> <span className="text-xs text-[var(--muted)] ml-auto">{colTasks.length}</span>
</div> </div>
{/* Cards */}
<div className="flex flex-col gap-2 flex-1 min-h-[100px] rounded-lg bg-[var(--bg)] p-2"> <div className="flex flex-col gap-2 flex-1 min-h-[100px] rounded-lg bg-[var(--bg)] p-2">
{colTasks.map((task) => ( {colTasks.map((task) => (
<div <div
@ -120,9 +227,7 @@ export default function KanbanBoard({ projectId }: Props) {
</div> </div>
))} ))}
{/* Add task */}
{addingTo === col.key ? ( {addingTo === col.key ? (
<div className="mt-1">
<input <input
autoFocus autoFocus
className="w-full bg-[var(--card)] border border-[var(--border)] rounded px-2 py-1.5 className="w-full bg-[var(--card)] border border-[var(--border)] rounded px-2 py-1.5
@ -134,11 +239,8 @@ export default function KanbanBoard({ projectId }: Props) {
if (e.key === "Enter") handleAddTask(col.key); if (e.key === "Enter") handleAddTask(col.key);
if (e.key === "Escape") setAddingTo(null); if (e.key === "Escape") setAddingTo(null);
}} }}
onBlur={() => { onBlur={() => !newTaskTitle.trim() && setAddingTo(null)}
if (!newTaskTitle.trim()) setAddingTo(null);
}}
/> />
</div>
) : ( ) : (
<button <button
className="text-xs text-[var(--muted)] hover:text-[var(--fg)] mt-1 text-left px-2 py-1" className="text-xs text-[var(--muted)] hover:text-[var(--fg)] mt-1 text-left px-2 py-1"
@ -152,5 +254,6 @@ export default function KanbanBoard({ projectId }: Props) {
); );
})} })}
</div> </div>
</div>
); );
} }

View File

@ -1,7 +1,9 @@
"use client"; "use client";
import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Project } from "@/lib/api"; import { Project } from "@/lib/api";
import { logout } from "@/lib/auth-client";
interface Props { interface Props {
projects: Project[]; projects: Project[];
@ -9,22 +11,30 @@ interface Props {
} }
export default function Sidebar({ projects, activeSlug }: Props) { export default function Sidebar({ projects, activeSlug }: Props) {
return ( const [open, setOpen] = useState(false);
<aside className="w-60 shrink-0 border-r border-[var(--border)] h-screen flex flex-col bg-[var(--card)]">
{/* Logo */} const nav = (
<div className="p-4 border-b border-[var(--border)]"> <>
<div className="p-4 border-b border-[var(--border)] flex items-center justify-between">
<Link href="/" className="text-lg font-bold tracking-tight"> <Link href="/" className="text-lg font-bold tracking-tight">
Team Board Team Board
</Link> </Link>
{/* Close button on mobile */}
<button
className="md:hidden text-[var(--muted)] hover:text-[var(--fg)] text-xl"
onClick={() => setOpen(false)}
>
</button>
</div> </div>
{/* Projects */}
<nav className="flex-1 overflow-y-auto p-2"> <nav className="flex-1 overflow-y-auto p-2">
<div className="text-xs uppercase text-[var(--muted)] px-2 py-1 mb-1">Проекты</div> <div className="text-xs uppercase text-[var(--muted)] px-2 py-1 mb-1">Проекты</div>
{projects.map((p) => ( {projects.map((p) => (
<Link <Link
key={p.id} key={p.id}
href={`/projects/${p.slug}`} href={`/projects/${p.slug}`}
onClick={() => setOpen(false)}
className={`block px-3 py-2 rounded text-sm transition-colors ${ className={`block px-3 py-2 rounded text-sm transition-colors ${
activeSlug === p.slug activeSlug === p.slug
? "bg-[var(--accent)]/10 text-[var(--accent)]" ? "bg-[var(--accent)]/10 text-[var(--accent)]"
@ -36,10 +46,47 @@ export default function Sidebar({ projects, activeSlug }: Props) {
))} ))}
</nav> </nav>
{/* Footer */} <div className="p-3 border-t border-[var(--border)] flex items-center justify-between">
<div className="p-3 border-t border-[var(--border)] text-xs text-[var(--muted)]"> <span className="text-xs text-[var(--muted)]">Team Board v0.1</span>
Team Board v0.1 <button
onClick={logout}
className="text-xs text-[var(--muted)] hover:text-[var(--fg)]"
>
Выйти
</button>
</div> </div>
</>
);
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> </aside>
</>
); );
} }