Skip to content

Commit

Permalink
feat: 🔥 adding initial kanban board
Browse files Browse the repository at this point in the history
added the whole kanban view with mock data
  • Loading branch information
aacevski committed Jan 18, 2025
1 parent 0c2ab27 commit 0e2734a
Show file tree
Hide file tree
Showing 22 changed files with 668 additions and 60 deletions.
4 changes: 4 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4",
Expand All @@ -20,6 +23,7 @@
"@tanstack/react-router": "^1.97.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^11.18.0",
"lucide-react": "^0.471.1",
"react": "^19.0.0",
Expand Down
6 changes: 1 addition & 5 deletions apps/web/src/components/common/sidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,7 @@ import { SidebarHeader } from "./sidebar-header";

export function Sidebar() {
return (
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
className="w-full"
>
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }}>
<div className="flex w-64 bg-white dark:bg-zinc-900 border-r border-zinc-200 dark:border-zinc-800 h-screen flex-col">
<SidebarHeader />
<SidebarContent />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
import useGetWorkspaces from "@/hooks/queries/workspace/use-get-workspace";
import useWorkspaceStore from "@/store/workspace";
import { useEffect } from "react";
import AddWorkspace from "./add-workspace";
import WorkspaceItemButton from "./workspace-item-button";

const selectedWorkspace = {
id: "1",
};

function Workspaces() {
const { data } = useGetWorkspaces();
const workspaces = data?.data;
const { workspace: selectedWorkspace, setWorkspace } = useWorkspaceStore(
(state) => state,
);

useEffect(() => {
if (data?.data) {
setWorkspace(data?.data[0]);
}
}, [data?.data, setWorkspace]);

if (!workspaces || !workspaces?.length) {
// TODO: Add better empty screen
return <div>You don't have any workspaces</div>;
}

Expand All @@ -24,11 +32,10 @@ function Workspaces() {
<WorkspaceItemButton
key={workspace.id}
workspace={workspace}
onSelectWorkspace={() => console.log("Selected")}
isSelected={workspace.id === selectedWorkspace.id}
onSelectWorkspace={() => setWorkspace(workspace)}
isSelected={workspace.id === selectedWorkspace?.id}
/>
))}

<AddWorkspace />
</div>
</div>
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/components/kanban-board/column/column-dropzone.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { Column } from "@/types/workspace";
import { useDroppable } from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import TaskCard from "../task-card";

interface ColumnDropzoneProps {
column: Column;
}

export function ColumnDropzone({ column }: ColumnDropzoneProps) {
const { setNodeRef } = useDroppable({
id: column.id,
data: {
type: "column",
column,
},
});

return (
<div ref={setNodeRef} className="flex-1">
<SortableContext
items={column.tasks}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-2">
{column.tasks.map((task) => (
<TaskCard key={task.id} task={task} />
))}
</div>
</SortableContext>
</div>
);
}
27 changes: 27 additions & 0 deletions apps/web/src/components/kanban-board/column/column-header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Column } from "@/types/workspace";
import { MoreHorizontal } from "lucide-react";

interface ColumnHeaderProps {
column: Column;
}

export function ColumnHeader({ column }: ColumnHeaderProps) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="font-medium text-zinc-900 dark:text-zinc-100">
{column.name}
</h3>
<span className="text-sm text-zinc-500 dark:text-zinc-500">
{column.tasks.length}
</span>
</div>
<button
type="button"
className="p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md transition-colors"
>
<MoreHorizontal className="w-4 h-4 text-zinc-500 dark:text-zinc-400" />
</button>
</div>
);
}
22 changes: 22 additions & 0 deletions apps/web/src/components/kanban-board/column/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Column as ColumnType } from "@/types/workspace";
import { ColumnDropzone } from "./column-dropzone";
import { ColumnHeader } from "./column-header";

interface ColumnProps {
column: ColumnType;
}

function Column({ column }: ColumnProps) {
return (
<div className="flex flex-col w-[calc(100vw-2rem)] md:w-80 shrink-0 bg-zinc-50/50 dark:bg-zinc-900/50 backdrop-blur-sm rounded-lg border border-zinc-200 dark:border-zinc-800/50 snap-center h-full shadow-sm">
<div className="p-3 border-b border-zinc-200 dark:border-zinc-800/50">
<ColumnHeader column={column} />
</div>
<div className="p-3 overflow-y-auto flex-1">
<ColumnDropzone column={column} />
</div>
</div>
);
}

export default Column;
152 changes: 152 additions & 0 deletions apps/web/src/components/kanban-board/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import generateProject from "@/lib/workspace/generate-project";
import {
DndContext,
type DragEndEvent,
DragOverlay,
type DragStartEvent,
type UniqueIdentifier,
closestCorners,
} from "@dnd-kit/core";
import { useState } from "react";
import Column from "./column";
import TaskCard from "./task-card";

function KanbanBoard() {
const [project, setProject] = useState(
generateProject({
projectId: "sample-1",
workspaceId: "workspace-1",
tasksPerColumn: 4,
}),
);
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);

const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id);
};

// TODO: Simplify this function
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;

if (!over) return;

const activeId = active.id.toString();
const overId = over.id.toString();

const sourceColumn = project.columns.find((col) =>
col.tasks.some((task) => task.id === activeId),
);

const destinationColumn = project.columns.find((col) => {
if (col.id === overId) return true;
return col.tasks.some((task) => task.id === overId);
});

if (!sourceColumn || !destinationColumn) return;

setProject((currentProject) => {
const updatedColumns = [...currentProject.columns];

const sourceColumnIndex = updatedColumns.findIndex(
(col) => col.id === sourceColumn.id,
);
const destinationColumnIndex = updatedColumns.findIndex(
(col) => col.id === destinationColumn.id,
);

const sourceTaskIndex = sourceColumn.tasks.findIndex(
(task) => task.id === activeId,
);
const task = sourceColumn.tasks[sourceTaskIndex];

updatedColumns[sourceColumnIndex] = {
...sourceColumn,
tasks: sourceColumn.tasks.filter((t) => t.id !== activeId),
};

if (sourceColumn.id === destinationColumn.id) {
const destinationIndex = destinationColumn.tasks.findIndex(
(task) => task.id === overId,
);
const newTasks = [...destinationColumn.tasks];
newTasks.splice(sourceTaskIndex, 1);
newTasks.splice(destinationIndex, 0, task);

updatedColumns[destinationColumnIndex] = {
...destinationColumn,
tasks: newTasks,
};
} else {
const updatedTask = { ...task, status: destinationColumn.id };

if (overId === destinationColumn.id) {
updatedColumns[destinationColumnIndex] = {
...destinationColumn,
tasks: [...destinationColumn.tasks, updatedTask],
};
} else {
const destinationIndex = destinationColumn.tasks.findIndex(
(task) => task.id === overId,
);
const newTasks = [...destinationColumn.tasks];
newTasks.splice(destinationIndex, 0, updatedTask);

updatedColumns[destinationColumnIndex] = {
...destinationColumn,
tasks: newTasks,
};
}
}

return {
...currentProject,
columns: updatedColumns,
};
});

setActiveId(null);
};

const activeTask = activeId
? project.columns
.flatMap((col) => col.tasks)
.find((task) => task.id === activeId)
: null;

return (
<DndContext
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="h-full flex flex-col">
<header className="mb-6 space-y-6 flex-shrink-0 px-4 md:px-0">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-100">
{project.name}
</h1>
</div>
</header>

<div className="flex-1 relative min-h-0">
<div className="flex gap-6 overflow-x-auto pb-6 px-4 md:px-6 h-full snap-x snap-mandatory scrollbar-thin scrollbar-track-zinc-100 scrollbar-thumb-zinc-300 dark:scrollbar-track-zinc-900 dark:scrollbar-thumb-zinc-700">
{project.columns.map((column) => (
<Column key={column.id} column={column} />
))}
</div>
</div>
</div>

<DragOverlay>
{activeTask ? (
<div className="transform rotate-3">
<TaskCard task={activeTask} />
</div>
) : null}
</DragOverlay>
</DndContext>
);
}

export default KanbanBoard;
72 changes: 72 additions & 0 deletions apps/web/src/components/kanban-board/task-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { Task } from "@/types/workspace";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { format } from "date-fns";
import { Calendar, Flag } from "lucide-react";

interface TaskCardProps {
task: Task;
}

function TaskCard({ task }: TaskCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: task.id });

const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};

const priorityColors = {
low: "bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-500",
medium:
"bg-yellow-50 text-yellow-700 dark:bg-yellow-500/10 dark:text-yellow-500",
high: "bg-orange-50 text-orange-700 dark:bg-orange-500/10 dark:text-orange-500",
urgent: "bg-red-50 text-red-700 dark:bg-red-500/10 dark:text-red-500",
};

return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="bg-white dark:bg-zinc-800/50 backdrop-blur-sm rounded-lg border border-zinc-200 dark:border-zinc-700/50 p-3 cursor-move hover:border-zinc-300 dark:hover:border-zinc-700 transition-colors shadow-sm"
>
<h3 className="font-medium text-zinc-900 dark:text-zinc-100 mb-1">
{task.title}
</h3>

<p className="text-sm text-zinc-600 dark:text-zinc-400 mb-3">
{task.description}
</p>

<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className={`text-xs px-2 py-1 rounded-full ${priorityColors[task.priority as keyof typeof priorityColors]}`}
>
<Flag className="w-3 h-3 inline-block mr-1" />
{task.priority}
</span>
</div>

{task.dueDate && (
<div className="text-xs text-zinc-600 dark:text-zinc-400 flex items-center">
<Calendar className="w-3 h-3 mr-1" />
{format(new Date(task.dueDate), "MMM d")}
</div>
)}
</div>
</div>
);
}

export default TaskCard;
Loading

0 comments on commit 0e2734a

Please sign in to comment.