feat: mobile-responsive layout
All checks were successful
Deploy Web Client / deploy (push) Successful in 36s
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:
parent
d7a9bc7a59
commit
6ac4f4450b
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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,8 +87,108 @@ 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">
|
||||
<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) => {
|
||||
const colTasks = tasks.filter((t) => t.status === col.key);
|
||||
return (
|
||||
@ -89,14 +198,12 @@ export default function KanbanBoard({ projectId }: Props) {
|
||||
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>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="flex flex-col gap-2 flex-1 min-h-[100px] rounded-lg bg-[var(--bg)] p-2">
|
||||
{colTasks.map((task) => (
|
||||
<div
|
||||
@ -120,9 +227,7 @@ export default function KanbanBoard({ projectId }: Props) {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add task */}
|
||||
{addingTo === col.key ? (
|
||||
<div className="mt-1">
|
||||
<input
|
||||
autoFocus
|
||||
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 === "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"
|
||||
@ -152,5 +254,6 @@ export default function KanbanBoard({ projectId }: Props) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user