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">
|
<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 && (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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,51 +87,147 @@ 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">
|
||||||
{COLUMNS.map((col) => {
|
{/* Mobile column tabs */}
|
||||||
const colTasks = tasks.filter((t) => t.status === col.key);
|
<div className="md:hidden flex overflow-x-auto border-b border-[var(--border)] px-2 py-1 gap-1 shrink-0">
|
||||||
return (
|
{COLUMNS.map((col) => {
|
||||||
<div
|
const count = tasks.filter((t) => t.status === col.key).length;
|
||||||
key={col.key}
|
return (
|
||||||
className="flex flex-col min-w-[280px] w-[280px] shrink-0"
|
<button
|
||||||
onDragOver={(e) => e.preventDefault()}
|
key={col.key}
|
||||||
onDrop={() => handleDrop(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
|
||||||
{/* Column header */}
|
${(activeColumn || "draft") === col.key
|
||||||
<div className="flex items-center gap-2 mb-3 px-1">
|
? "bg-white/10 text-[var(--fg)]"
|
||||||
<div className="w-3 h-3 rounded-full" style={{ background: col.color }} />
|
: "text-[var(--muted)]"
|
||||||
<span className="font-semibold text-sm">{col.label}</span>
|
}`}
|
||||||
<span className="text-xs text-[var(--muted)] ml-auto">{colTasks.length}</span>
|
>
|
||||||
</div>
|
<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 */}
|
{/* Mobile: single column view */}
|
||||||
<div className="flex flex-col gap-2 flex-1 min-h-[100px] rounded-lg bg-[var(--bg)] p-2">
|
<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) => (
|
{colTasks.map((task) => (
|
||||||
<div
|
<div
|
||||||
key={task.id}
|
key={task.id}
|
||||||
draggable
|
className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-3"
|
||||||
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="flex items-start gap-2">
|
||||||
<div
|
<div
|
||||||
className="w-2 h-2 rounded-full mt-1.5 shrink-0"
|
className="w-2 h-2 rounded-full mt-1.5 shrink-0"
|
||||||
style={{ background: PRIORITY_COLORS[task.priority] || "#737373" }}
|
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>
|
</div>
|
||||||
{task.description && (
|
{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>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Add task */}
|
|
||||||
{addingTo === col.key ? (
|
{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
|
<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,23 +239,21 @@ 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"
|
onClick={() => setAddingTo(col.key)}
|
||||||
onClick={() => setAddingTo(col.key)}
|
>
|
||||||
>
|
+ Добавить
|
||||||
+ Добавить
|
</button>
|
||||||
</button>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
</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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user