feat: init kanban

This commit is contained in:
Carlos Florêncio
2026-01-10 15:23:49 +00:00
parent 7551235267
commit 5b31958775
9 changed files with 190 additions and 58 deletions
+3 -2
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono, Figtree } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
const figtree = Figtree({subsets:['latin'],variable:'--font-sans'});
@@ -25,11 +26,11 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" className={figtree.variable}>
<html lang="en" className={figtree.variable} suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
+22 -14
View File
@@ -10,6 +10,7 @@ import {
} from '@dnd-kit/core';
import { KanbanColumn, Column } from './kanban-column';
import { Task } from './kanban-card';
import { ThemeToggle } from './theme-toggle';
type ColumnId = 'todo' | 'in-progress' | 'in-review' | 'done';
@@ -52,11 +53,12 @@ export function KanbanBoard() {
);
};
const handleAddTask = (columnId: string, title: string) => {
const handleAddTask = (columnId: string, title: string, description?: string) => {
const newTask: Task = {
id: crypto.randomUUID(),
title,
status: columnId,
description,
};
setTasks((tasks) => [...tasks, newTask]);
};
@@ -66,19 +68,25 @@ export function KanbanBoard() {
};
return (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<div className="h-screen w-full overflow-x-auto bg-background p-6">
<div className="inline-grid grid-flow-col auto-cols-[minmax(280px,360px)] gap-4">
{COLUMNS.map((column) => (
<KanbanColumn
key={column.id}
column={column}
tasks={getTasksForColumn(column.id)}
onAddTask={handleAddTask}
/>
))}
<div className="h-screen w-full flex flex-col bg-background">
<header className="flex items-center justify-between p-6 pb-4">
<h1 className="text-2xl font-bold">Kanban Board</h1>
<ThemeToggle />
</header>
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<div className="flex-1 overflow-x-auto px-6 pb-6">
<div className="inline-grid grid-flow-col auto-cols-[minmax(280px,360px)] gap-4">
{COLUMNS.map((column) => (
<KanbanColumn
key={column.id}
column={column}
tasks={getTasksForColumn(column.id)}
onAddTask={handleAddTask}
/>
))}
</div>
</div>
</div>
</DndContext>
</DndContext>
</div>
);
}
+6 -2
View File
@@ -9,6 +9,7 @@ export interface Task {
id: string;
title: string;
status: string;
description?: string;
}
interface KanbanCardProps {
@@ -30,13 +31,16 @@ export function KanbanCard({ task }: KanbanCardProps) {
style={style}
className={cn(
'cursor-grab active:cursor-grabbing mb-2 transition-opacity',
isDragging && 'opacity-50'
isDragging && 'opacity-50 z-50'
)}
{...listeners}
{...attributes}
>
<CardContent className="p-3">
<p className="text-sm">{task.title}</p>
<p className="text-sm font-medium">{task.title}</p>
{task.description && (
<p className="text-xs text-muted-foreground mt-1">{task.description}</p>
)}
</CardContent>
</Card>
);
+13 -40
View File
@@ -4,10 +4,10 @@ import { useDroppable } from '@dnd-kit/core';
import { KanbanCard, Task } from './kanban-card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { IconPlus } from '@tabler/icons-react';
import { useState, KeyboardEvent } from 'react';
import { useState } from 'react';
import { TaskCreateDialog } from './task-create-dialog';
export interface Column {
id: string;
@@ -18,7 +18,7 @@ export interface Column {
interface KanbanColumnProps {
column: Column;
tasks: Task[];
onAddTask: (columnId: string, title: string) => void;
onAddTask: (columnId: string, title: string, description?: string) => void;
}
export function KanbanColumn({ column, tasks, onAddTask }: KanbanColumnProps) {
@@ -26,28 +26,10 @@ export function KanbanColumn({ column, tasks, onAddTask }: KanbanColumnProps) {
id: column.id,
});
const [isAdding, setIsAdding] = useState(false);
const [newTaskTitle, setNewTaskTitle] = useState('');
const [isDialogOpen, setIsDialogOpen] = useState(false);
const handleAddClick = () => {
setIsAdding(true);
};
const handleSubmit = () => {
if (newTaskTitle.trim()) {
onAddTask(column.id, newTaskTitle.trim());
setNewTaskTitle('');
setIsAdding(false);
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSubmit();
} else if (e.key === 'Escape') {
setNewTaskTitle('');
setIsAdding(false);
}
const handleDialogSubmit = (title: string, description: string) => {
onAddTask(column.id, title, description);
};
return (
@@ -71,30 +53,21 @@ export function KanbanColumn({ column, tasks, onAddTask }: KanbanColumnProps) {
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={handleAddClick}
onClick={() => setIsDialogOpen(true)}
>
<IconPlus className="h-4 w-4" />
</Button>
</div>
{/* Add Task Input */}
{isAdding && (
<div className="mb-3">
<Input
autoFocus
placeholder="Enter task title..."
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSubmit}
className="text-sm"
/>
</div>
)}
<TaskCreateDialog
open={isDialogOpen}
onOpenChange={setIsDialogOpen}
onSubmit={handleDialogSubmit}
/>
{/* Tasks */}
<div className="flex-1 overflow-y-auto space-y-0">
{tasks.length === 0 && !isAdding ? (
{tasks.length === 0 ? (
<p className="text-sm text-muted-foreground text-center mt-8">No tasks yet</p>
) : (
tasks.map((task) => <KanbanCard key={task.id} task={task} />)
@@ -0,0 +1,83 @@
'use client';
import { useState, FormEvent } from 'react';
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogFooter,
AlertDialogCancel,
} from '@/components/ui/alert-dialog';
import { Field, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
interface TaskCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (title: string, description: string) => void;
}
export function TaskCreateDialog({
open,
onOpenChange,
onSubmit,
}: TaskCreateDialogProps) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (title.trim()) {
onSubmit(title.trim(), description.trim());
setTitle('');
setDescription('');
onOpenChange(false);
}
};
const handleCancel = () => {
setTitle('');
setDescription('');
onOpenChange(false);
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogTitle>Create Task</AlertDialogTitle>
</AlertDialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<Field>
<FieldLabel>Title</FieldLabel>
<Input
autoFocus
required
placeholder="Enter task title..."
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</Field>
<Field>
<FieldLabel>Description</FieldLabel>
<Textarea
placeholder="Enter task description (optional)..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</Field>
<AlertDialogFooter>
<AlertDialogCancel type="button" onClick={handleCancel}>
Cancel
</AlertDialogCancel>
<Button type="submit">Create</Button>
</AlertDialogFooter>
</form>
</AlertDialogContent>
</AlertDialog>
);
}
+16
View File
@@ -0,0 +1,16 @@
'use client';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { ReactNode } from 'react';
export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="dark"
enableSystem={false}
>
{children}
</NextThemesProvider>
);
}
+35
View File
@@ -0,0 +1,35 @@
'use client';
import { useTheme } from 'next-themes';
import { IconMoon, IconSun } from '@tabler/icons-react';
import { Button } from '@/components/ui/button';
import { useEffect, useState } from 'react';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return null;
}
return (
<Button
variant="ghost"
size="sm"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="h-9 w-9 p-0"
>
{theme === 'dark' ? (
<IconSun className="h-4 w-4" />
) : (
<IconMoon className="h-4 w-4" />
)}
<span className="sr-only">Toggle theme</span>
</Button>
);
}
+11
View File
@@ -16,6 +16,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"next": "16.1.1",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
@@ -8734,6 +8735,16 @@
}
}
},
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+1
View File
@@ -17,6 +17,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"next": "16.1.1",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",