This commit is contained in:
phaichayon
2026-06-11 12:46:57 +07:00
parent 1993d88306
commit 7a390cf0df
720 changed files with 100919 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
'use client';
import { Icons } from '@/components/icons';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { KanbanColumn, KanbanColumnHandle } from '@/components/ui/kanban';
import type { Task } from '../utils/store';
import { TaskCard } from './task-card';
const COLUMN_TITLES: Record<string, string> = {
backlog: 'Backlog',
inProgress: 'In Progress',
review: 'Review',
done: 'Done'
};
interface TaskColumnProps extends Omit<React.ComponentProps<typeof KanbanColumn>, 'children'> {
tasks: Task[];
}
export function TaskColumn({ value, tasks, ...props }: TaskColumnProps) {
return (
<KanbanColumn value={value} className='w-full shrink-0 md:w-[320px]' {...props}>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='text-sm font-semibold'>{COLUMN_TITLES[value] ?? value}</span>
<Badge variant='secondary' className='pointer-events-none rounded-sm'>
{tasks.length}
</Badge>
</div>
<KanbanColumnHandle asChild>
<Button variant='ghost' size='icon'>
<Icons.gripVertical className='h-4 w-4' />
</Button>
</KanbanColumnHandle>
</div>
<div className='flex flex-col gap-2 p-0.5'>
{tasks.map((task) => (
<TaskCard key={task.id} task={task} asHandle />
))}
</div>
</KanbanColumn>
);
}

View File

@@ -0,0 +1,54 @@
'use client';
import { useCallback, useRef } from 'react';
import { Kanban, KanbanBoard as KanbanBoardPrimitive, KanbanOverlay } from '@/components/ui/kanban';
import { useTaskStore } from '../utils/store';
import { TaskColumn } from './board-column';
import { TaskCard } from './task-card';
import { createRestrictToContainer } from '../utils/restrict-to-container';
export function KanbanBoard() {
const { columns, setColumns } = useTaskStore();
const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line react-hooks/exhaustive-deps -- factory function, stable after mount
const restrictToBoard = useCallback(
createRestrictToContainer(() => containerRef.current),
[]
);
return (
<div ref={containerRef}>
<Kanban
value={columns}
onValueChange={setColumns}
getItemValue={(item) => item.id}
modifiers={[restrictToBoard]}
autoScroll={false}
>
<div className='w-full overflow-x-auto rounded-md pb-4'>
<KanbanBoardPrimitive className='flex flex-col items-start gap-4 md:flex-row'>
{Object.entries(columns).map(([columnValue, tasks]) => (
<TaskColumn key={columnValue} value={columnValue} tasks={tasks} />
))}
</KanbanBoardPrimitive>
</div>
<KanbanOverlay>
{({ value, variant }) => {
if (variant === 'column') {
const tasks = columns[value] ?? [];
return <TaskColumn value={value} tasks={tasks} />;
}
const task = Object.values(columns)
.flat()
.find((task) => task.id === value);
if (!task) return null;
return <TaskCard task={task} />;
}}
</KanbanOverlay>
</Kanban>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import PageContainer from '@/components/layout/page-container';
import { KanbanBoard } from './kanban-board';
import NewTaskDialog from './new-task-dialog';
export default function KanbanViewPage() {
return (
<PageContainer
pageTitle='Kanban'
pageDescription='Manage tasks with drag and drop'
pageHeaderAction={<NewTaskDialog />}
>
<KanbanBoard />
</PageContainer>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { useTaskStore } from '../utils/store';
export default function NewTaskDialog() {
const addTask = useTaskStore((state) => state.addTask);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const formData = new FormData(form);
const { title, description } = Object.fromEntries(formData);
if (typeof title !== 'string' || typeof description !== 'string') return;
addTask(title, description);
};
return (
<Dialog>
<DialogTrigger asChild>
<Button variant='secondary' size='sm'>
+ Add New Task
</Button>
</DialogTrigger>
<DialogContent className='sm:max-w-[425px]'>
<DialogHeader>
<DialogTitle>Add New Task</DialogTitle>
<DialogDescription>What do you want to get done today?</DialogDescription>
</DialogHeader>
<form id='task-form' className='grid gap-4 py-4' onSubmit={handleSubmit}>
<div className='grid grid-cols-4 items-center gap-4'>
<Input id='title' name='title' placeholder='Task title...' className='col-span-4' />
</div>
<div className='grid grid-cols-4 items-center gap-4'>
<Textarea
id='description'
name='description'
placeholder='Description...'
className='col-span-4'
/>
</div>
</form>
<DialogFooter>
<DialogTrigger asChild>
<Button type='submit' size='sm' form='task-form'>
Add Task
</Button>
</DialogTrigger>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,44 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { KanbanItem } from '@/components/ui/kanban';
import type { Task } from '../utils/store';
interface TaskCardProps extends Omit<React.ComponentProps<typeof KanbanItem>, 'value'> {
task: Task;
}
export function TaskCard({ task, ...props }: TaskCardProps) {
return (
<KanbanItem key={task.id} value={task.id} asChild {...props}>
<div className='bg-card rounded-md border p-3 shadow-xs'>
<div className='flex flex-col gap-2'>
<div className='flex items-center justify-between gap-2'>
<span className='line-clamp-1 text-sm font-medium'>{task.title}</span>
<Badge
variant={
task.priority === 'high'
? 'destructive'
: task.priority === 'medium'
? 'default'
: 'secondary'
}
className='pointer-events-none h-5 rounded-sm px-1.5 text-[11px] capitalize'
>
{task.priority}
</Badge>
</div>
<div className='text-muted-foreground flex items-center justify-between text-xs'>
{task.assignee && (
<div className='flex items-center gap-1'>
<div className='bg-primary/20 size-2 rounded-full' />
<span className='line-clamp-1'>{task.assignee}</span>
</div>
)}
{task.dueDate && <time className='text-[10px] tabular-nums'>{task.dueDate}</time>}
</div>
</div>
</div>
</KanbanItem>
);
}