├── src
├── vite-env.d.ts
├── components
│ ├── index.ts
│ ├── input
│ │ └── input.tsx
│ ├── item
│ │ └── item.tsx
│ ├── button
│ │ └── button.tsx
│ ├── modal
│ │ └── modal.tsx
│ ├── menu
│ │ └── menu.tsx
│ └── container
│ │ └── container.tsx
├── main.tsx
├── lib
│ ├── utilities
│ │ ├── dnd
│ │ │ ├── handleDragStart.ts
│ │ │ ├── handleDragMove.ts
│ │ │ └── handleDragEnd.ts
│ │ ├── container
│ │ │ ├── onDeleteContainer.ts
│ │ │ ├── onAddContainer.ts
│ │ │ ├── findContainerItems.ts
│ │ │ ├── findContainerTitle.ts
│ │ │ └── onEditContainer.ts
│ │ ├── modal
│ │ │ ├── openEditItemModal.ts
│ │ │ └── openEditModal.ts
│ │ ├── helper.ts
│ │ ├── findValueOfItems.ts
│ │ ├── item
│ │ │ ├── findItemTitle.ts
│ │ │ ├── onAddItem.ts
│ │ │ ├── onDeleteItem.ts
│ │ │ └── onEditItem.ts
│ │ └── validation.ts
│ ├── types.ts
│ ├── store
│ │ └── useContainerStore.ts
│ └── index.ts
├── index.css
└── App.tsx
├── public
└── favicon.png
├── postcss.config.js
├── tsconfig.json
├── vite.config.ts
├── tailwind.config.js
├── .gitignore
├── tsconfig.node.json
├── index.html
├── .eslintrc.cjs
├── tsconfig.app.json
├── package.json
└── README.md
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mirayatech/drag-track/HEAD/public/favicon.png
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | {
5 | "path": "./tsconfig.app.json"
6 | },
7 | {
8 | "path": "./tsconfig.node.json"
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [],
8 | };
9 |
--------------------------------------------------------------------------------
/src/components/index.ts:
--------------------------------------------------------------------------------
1 | import { lazy } from "react";
2 |
3 | export * from "./item/item";
4 | export * from "./input/input";
5 | export * from "./button/button";
6 | export * from "./container/container";
7 |
8 | export const Modal = lazy(() => import("./modal/modal"));
9 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import App from './App.tsx'
4 | import './index.css'
5 |
6 | ReactDOM.createRoot(document.getElementById('root')!).render(
7 |
8 |
9 | ,
10 | )
11 |
--------------------------------------------------------------------------------
/src/lib/utilities/dnd/handleDragStart.ts:
--------------------------------------------------------------------------------
1 | import { DragStartEvent, UniqueIdentifier } from "@dnd-kit/core";
2 |
3 | export function handleDragStart(
4 | event: DragStartEvent,
5 | setActiveId: (id: UniqueIdentifier) => void
6 | ) {
7 | const { active } = event;
8 | const { id } = active;
9 | setActiveId(id);
10 | }
11 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | * {
6 | margin: 0;
7 | padding: 0;
8 | box-sizing: border-box;
9 | font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto,
10 | "Helvetica Neue", Arial, sans-serif;
11 | }
12 |
13 | body {
14 | padding: 30px;
15 | }
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
5 | "skipLibCheck": true,
6 | "module": "ESNext",
7 | "moduleResolution": "bundler",
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "noEmit": true
11 | },
12 | "include": ["vite.config.ts"]
13 | }
14 |
--------------------------------------------------------------------------------
/src/lib/utilities/container/onDeleteContainer.ts:
--------------------------------------------------------------------------------
1 | import { UniqueIdentifier } from "@dnd-kit/core";
2 | import { ContainerType } from "../../types";
3 |
4 | export function onDeleteContainer(
5 | id: UniqueIdentifier,
6 | containers: ContainerType[],
7 | setContainers: (containers: ContainerType[]) => void
8 | ) {
9 | setContainers(containers.filter((container) => container.id !== id));
10 | }
11 |
--------------------------------------------------------------------------------
/src/lib/utilities/container/onAddContainer.ts:
--------------------------------------------------------------------------------
1 | export function onAddContainer(
2 | containerName: string,
3 | setContainerName: (name: string) => void,
4 | setShowAddContainerModal: (show: boolean) => void,
5 | addContainer: (name: string) => void
6 | ) {
7 | if (!containerName) return;
8 | addContainer(containerName);
9 | setContainerName("");
10 | setShowAddContainerModal(false);
11 | }
12 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Drag Track
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/lib/types.ts:
--------------------------------------------------------------------------------
1 | import { UniqueIdentifier } from "@dnd-kit/core";
2 |
3 | export type DNDType = {
4 | id: UniqueIdentifier;
5 | title: string;
6 | items: {
7 | id: UniqueIdentifier;
8 | title: string;
9 | }[];
10 | };
11 |
12 | export type ItemType = {
13 | id: UniqueIdentifier;
14 | title: string;
15 | };
16 |
17 | export type ContainerType = {
18 | id: UniqueIdentifier;
19 | title: string;
20 | items: ItemType[];
21 | };
22 |
--------------------------------------------------------------------------------
/src/lib/utilities/modal/openEditItemModal.ts:
--------------------------------------------------------------------------------
1 | import { UniqueIdentifier } from "@dnd-kit/core";
2 |
3 | export function openEditItemModal(
4 | setEditingItem: (id: UniqueIdentifier | null) => void,
5 | setEditingItemName: (name: string) => void,
6 | setShowEditItemModal: (show: boolean) => void,
7 | id: UniqueIdentifier,
8 | title: string
9 | ) {
10 | setEditingItem(id);
11 | setEditingItemName(title);
12 | setShowEditItemModal(true);
13 | }
14 |
--------------------------------------------------------------------------------
/src/lib/utilities/container/findContainerItems.ts:
--------------------------------------------------------------------------------
1 | import { UniqueIdentifier } from "@dnd-kit/core";
2 | import { ContainerType } from "../..";
3 | import { findValueOfItems } from "../findValueOfItems";
4 |
5 | export function findContainerItems(
6 | containers: ContainerType[],
7 | id: UniqueIdentifier | undefined
8 | ) {
9 | const container = findValueOfItems(containers, id, "container");
10 | if (!container) return [];
11 | return container.items;
12 | }
13 |
--------------------------------------------------------------------------------
/src/lib/utilities/helper.ts:
--------------------------------------------------------------------------------
1 | import { UniqueIdentifier } from "@dnd-kit/core";
2 | import { ContainerType } from "..";
3 |
4 | export const findContainerNameByItemId = (
5 | containers: ContainerType[],
6 | itemId: UniqueIdentifier | null
7 | ): string | undefined => {
8 | for (const container of containers) {
9 | if (container.items.some((item) => item.id === itemId)) {
10 | return container.title;
11 | }
12 | }
13 | return undefined;
14 | };
15 |
--------------------------------------------------------------------------------
/src/lib/utilities/container/findContainerTitle.ts:
--------------------------------------------------------------------------------
1 | import { UniqueIdentifier } from "@dnd-kit/core";
2 | import { ContainerType } from "../../types";
3 | import { findValueOfItems } from "../findValueOfItems";
4 |
5 | export function findContainerTitle(
6 | containers: ContainerType[],
7 | id: UniqueIdentifier | undefined
8 | ) {
9 | const container = findValueOfItems(containers, id, "container");
10 | if (!container) return "";
11 | return container.title;
12 | }
13 |
--------------------------------------------------------------------------------
/src/lib/utilities/modal/openEditModal.ts:
--------------------------------------------------------------------------------
1 | import { UniqueIdentifier } from "@dnd-kit/core";
2 |
3 | export function openEditModal(
4 | setEditingContainer: (id: UniqueIdentifier | null) => void,
5 | setEditingContainerName: (name: string) => void,
6 | setShowEditContainerModal: (show: boolean) => void,
7 | id: UniqueIdentifier,
8 | title: string
9 | ) {
10 | setEditingContainer(id);
11 | setEditingContainerName(title);
12 | setShowEditContainerModal(true);
13 | }
14 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:react-hooks/recommended',
8 | ],
9 | ignorePatterns: ['dist', '.eslintrc.cjs'],
10 | parser: '@typescript-eslint/parser',
11 | plugins: ['react-refresh'],
12 | rules: {
13 | 'react-refresh/only-export-components': [
14 | 'warn',
15 | { allowConstantExport: true },
16 | ],
17 | },
18 | }
19 |
--------------------------------------------------------------------------------
/src/lib/utilities/findValueOfItems.ts:
--------------------------------------------------------------------------------
1 | import { UniqueIdentifier } from "@dnd-kit/core";
2 | import { ContainerType } from "..";
3 |
4 | export function findValueOfItems(
5 | containers: ContainerType[],
6 | id: UniqueIdentifier | undefined,
7 | type: string
8 | ) {
9 | if (type === "container") {
10 | return containers.find((item) => item.id === id);
11 | }
12 | if (type === "item") {
13 | return containers.find((container) =>
14 | container.items.find((item) => item.id === id)
15 | );
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/lib/utilities/item/findItemTitle.ts:
--------------------------------------------------------------------------------
1 | import { UniqueIdentifier } from "@dnd-kit/core";
2 | import { ContainerType } from "../../types";
3 | import { findValueOfItems } from "../findValueOfItems";
4 |
5 | export function findItemTitle(
6 | containers: ContainerType[],
7 | id: UniqueIdentifier | undefined
8 | ) {
9 | const container = findValueOfItems(containers, id, "item");
10 | if (!container) return "";
11 | const item = container.items.find((item) => item.id === id);
12 | if (!item) return "";
13 | return item.title;
14 | }
15 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
5 | "target": "ES2020",
6 | "useDefineForClassFields": true,
7 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
8 | "module": "ESNext",
9 | "skipLibCheck": true,
10 |
11 | /* Bundler mode */
12 | "moduleResolution": "bundler",
13 | "allowImportingTsExtensions": true,
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "moduleDetection": "force",
17 | "noEmit": true,
18 | "jsx": "react-jsx",
19 |
20 | /* Linting */
21 | "strict": true,
22 | "noUnusedLocals": true,
23 | "noUnusedParameters": true,
24 | "noFallthroughCasesInSwitch": true
25 | },
26 | "include": ["src"]
27 | }
28 |
--------------------------------------------------------------------------------
/src/lib/utilities/item/onAddItem.ts:
--------------------------------------------------------------------------------
1 | import { UniqueIdentifier } from "@dnd-kit/core";
2 | import { ContainerType } from "../../types";
3 |
4 | export function onAddItem(
5 | itemName: string,
6 | setItemName: (name: string) => void,
7 | setShowAddItemModal: (show: boolean) => void,
8 | containers: ContainerType[],
9 | setContainers: (containers: ContainerType[]) => void,
10 | currentContainerId: UniqueIdentifier | undefined
11 | ) {
12 | if (!itemName) return;
13 | const id = `item-${Math.random() * 1000}`;
14 | const container = containers.find((item) => item.id === currentContainerId);
15 | if (!container) return;
16 | container.items.push({
17 | id,
18 | title: itemName,
19 | });
20 | setContainers([...containers]);
21 | setItemName("");
22 | setShowAddItemModal(false);
23 | }
24 |
--------------------------------------------------------------------------------
/src/lib/utilities/item/onDeleteItem.ts:
--------------------------------------------------------------------------------
1 | import { UniqueIdentifier } from "@dnd-kit/core";
2 | import { ContainerType } from "../../types";
3 |
4 | export function onDeleteItem(
5 | editingItem: UniqueIdentifier | null,
6 | containers: ContainerType[],
7 | setContainers: (containers: ContainerType[]) => void,
8 | setEditingItem: (id: UniqueIdentifier | null) => void,
9 | setShowEditItemModal: (show: boolean) => void
10 | ) {
11 | if (!editingItem) return;
12 | const container = containers.find((container) =>
13 | container.items.find((item) => item.id === editingItem)
14 | );
15 | if (!container) return;
16 | container.items = container.items.filter((item) => item.id !== editingItem);
17 | setContainers([...containers]);
18 | setEditingItem(null);
19 | setShowEditItemModal(false);
20 | }
21 |
--------------------------------------------------------------------------------
/src/lib/utilities/item/onEditItem.ts:
--------------------------------------------------------------------------------
1 | import { UniqueIdentifier } from "@dnd-kit/core";
2 | import { ContainerType } from "../../types";
3 |
4 | export function onEditItem(
5 | editingItemName: string,
6 | editingItem: UniqueIdentifier | null,
7 | containers: ContainerType[],
8 | setContainers: (containers: ContainerType[]) => void,
9 | setEditingItem: (id: UniqueIdentifier | null) => void,
10 | setShowEditItemModal: (show: boolean) => void
11 | ) {
12 | if (!editingItemName || !editingItem) return;
13 | const container = containers.find((container) =>
14 | container.items.find((item) => item.id === editingItem)
15 | );
16 | if (!container) return;
17 | const item = container.items.find((item) => item.id === editingItem);
18 | if (!item) return;
19 | item.title = editingItemName;
20 | setContainers([...containers]);
21 | setEditingItem(null);
22 | setShowEditItemModal(false);
23 | }
24 |
--------------------------------------------------------------------------------
/src/lib/utilities/container/onEditContainer.ts:
--------------------------------------------------------------------------------
1 | import { UniqueIdentifier } from "@dnd-kit/core";
2 | import { ContainerType } from "../../types";
3 |
4 | export function onEditContainer(
5 | editingContainerName: string,
6 | editingContainer: UniqueIdentifier | null,
7 | containers: ContainerType[],
8 | setContainers: (containers: ContainerType[]) => void,
9 | setEditingContainer: (id: UniqueIdentifier | null) => void,
10 | setEditingContainerName: (name: string) => void,
11 | setShowEditContainerModal: (show: boolean) => void
12 | ) {
13 | if (!editingContainerName || !editingContainer) return;
14 | const container = containers.find((item) => item.id === editingContainer);
15 | if (!container) return;
16 | container.title = editingContainerName;
17 | setContainers([...containers]);
18 | setEditingContainer(null);
19 | setEditingContainerName("");
20 | setShowEditContainerModal(false);
21 | }
22 |
--------------------------------------------------------------------------------
/src/lib/store/useContainerStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 | import { persist } from "zustand/middleware";
3 | import { DNDType } from "..";
4 |
5 | type ContainerState = {
6 | containers: DNDType[];
7 | setContainers: (containers: DNDType[]) => void;
8 | addContainer: (title: string) => void;
9 | };
10 |
11 | export const useContainerStore = create()(
12 | persist(
13 | (set) => ({
14 | containers: [],
15 | setContainers: (containers) => set({ containers }),
16 | addContainer: (title: string) =>
17 | set((state) => ({
18 | containers: [
19 | ...state.containers,
20 | {
21 | id: `container-${Math.random() * 1000}`,
22 | title,
23 | items: [],
24 | },
25 | ],
26 | })),
27 | }),
28 | {
29 | name: "container-storage",
30 | }
31 | )
32 | );
33 |
--------------------------------------------------------------------------------
/src/lib/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./types";
2 | export * from "./store/useContainerStore";
3 |
4 | export * from "./utilities/dnd/handleDragEnd";
5 | export * from "./utilities/dnd/handleDragStart";
6 | export * from "./utilities/dnd/handleDragMove";
7 |
8 | export * from "./utilities/container/findContainerTitle";
9 | export * from "./utilities/container/findContainerItems";
10 | export * from "./utilities/container/onAddContainer";
11 | export * from "./utilities/container/onDeleteContainer";
12 | export * from "./utilities/container/onEditContainer";
13 |
14 | export * from "./utilities/item/findItemTitle";
15 | export * from "./utilities/item/onDeleteItem";
16 | export * from "./utilities/item/onAddItem";
17 | export * from "./utilities/item/onEditItem";
18 |
19 | export * from "./utilities/modal/openEditItemModal";
20 | export * from "./utilities/modal/openEditModal";
21 |
22 | export * from "./utilities/helper";
23 | export * from "./utilities/validation";
24 |
--------------------------------------------------------------------------------
/src/components/input/input.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from "react";
2 |
3 | type InputProps = {
4 | type: string;
5 | name: string;
6 | value?: string;
7 | shadow?: boolean;
8 | placeholder?: string;
9 | onChange?: (event: React.ChangeEvent) => void;
10 | };
11 |
12 | export function Input({
13 | type,
14 | name,
15 | value = "",
16 | placeholder = "",
17 | onChange = () => {},
18 | shadow = false,
19 | }: InputProps) {
20 | const inputRef = useRef(null);
21 |
22 | useEffect(() => {
23 | if (inputRef.current) {
24 | inputRef.current.focus();
25 | }
26 | }, []);
27 |
28 | return (
29 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/src/lib/utilities/validation.ts:
--------------------------------------------------------------------------------
1 | import { UniqueIdentifier } from "@dnd-kit/core";
2 | import { ContainerType } from "..";
3 |
4 | export const isContainerNameEmpty = (containerName: string): boolean => {
5 | return containerName.trim() === "";
6 | };
7 |
8 | export const isItemNameEmpty = (itemName: string): boolean => {
9 | return itemName.trim() === "";
10 | };
11 |
12 | export const isEditingContainerNameChanged = (
13 | editingContainerName: string,
14 | editingContainer: UniqueIdentifier | null,
15 | containers: ContainerType[]
16 | ): boolean => {
17 | return (
18 | editingContainerName.trim() !== "" &&
19 | containers.some(
20 | (container) =>
21 | container.id === editingContainer &&
22 | container.title !== editingContainerName.trim()
23 | )
24 | );
25 | };
26 |
27 | export const isEditingItemNameChanged = (
28 | editingItemName: string,
29 | editingItem: UniqueIdentifier | null,
30 | containers: ContainerType[]
31 | ): boolean => {
32 | return (
33 | editingItemName.trim() !== "" &&
34 | containers.some((container) =>
35 | container.items.some(
36 | (item) =>
37 | item.id === editingItem && item.title !== editingItemName.trim()
38 | )
39 | )
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "drag-track",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview",
11 | "tscheck": "./node_modules/typescript/bin/tsc --noEmit"
12 | },
13 | "dependencies": {
14 | "@dnd-kit/core": "^6.1.0",
15 | "@dnd-kit/sortable": "^8.0.0",
16 | "clsx": "^2.1.1",
17 | "focus-trap-react": "^10.2.3",
18 | "framer-motion": "^11.2.11",
19 | "lucide-react": "^0.396.0",
20 | "react": "^18.3.1",
21 | "react-dom": "^18.3.1",
22 | "zustand": "^4.5.3"
23 | },
24 | "devDependencies": {
25 | "@types/react": "^18.3.3",
26 | "@types/react-dom": "^18.3.0",
27 | "@typescript-eslint/eslint-plugin": "^7.13.1",
28 | "@typescript-eslint/parser": "^7.13.1",
29 | "@vitejs/plugin-react": "^4.3.1",
30 | "autoprefixer": "^10.4.19",
31 | "eslint": "^8.57.0",
32 | "eslint-plugin-react-hooks": "^4.6.2",
33 | "eslint-plugin-react-refresh": "^0.4.7",
34 | "postcss": "^8.4.38",
35 | "tailwindcss": "^3.4.4",
36 | "typescript": "^5.2.2",
37 | "vite": "^5.3.1"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/item/item.tsx:
--------------------------------------------------------------------------------
1 | import { UniqueIdentifier } from "@dnd-kit/core";
2 | import { useSortable } from "@dnd-kit/sortable";
3 | import { CSS } from "@dnd-kit/utilities";
4 | import { Grip } from "lucide-react";
5 | import clsx from "clsx";
6 |
7 | type ItemsType = {
8 | id: UniqueIdentifier;
9 | title: string;
10 | onEdit?: () => void;
11 | };
12 |
13 | export function Items({ id, title, onEdit }: ItemsType) {
14 | const {
15 | attributes,
16 | listeners,
17 | setNodeRef,
18 | transform,
19 | transition,
20 | isDragging,
21 | } = useSortable({
22 | id: id,
23 | data: {
24 | type: "item",
25 | },
26 | });
27 |
28 | return (
29 |
42 |
43 | {title}
44 |
45 |
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/src/components/button/button.tsx:
--------------------------------------------------------------------------------
1 | import { LucideIcon } from "lucide-react";
2 |
3 | type ButtonProps = {
4 | onClick?: (() => void) | undefined;
5 | label: string;
6 | fullWidth?: boolean;
7 | bgLight?: boolean;
8 | variant?:
9 | | "default"
10 | | "destructive"
11 | | "outline"
12 | | "secondary"
13 | | "ghost"
14 | | "link";
15 | disabled?: boolean;
16 | icon?: LucideIcon;
17 | };
18 |
19 | export function Button({
20 | onClick,
21 | label,
22 | fullWidth = false,
23 | variant = "default",
24 | bgLight = false,
25 | disabled = false,
26 | icon: Icon,
27 | }: ButtonProps) {
28 | const baseClasses =
29 | "flex items-center justify-center gap-3 rounded md:rounded-md text-xs font-bold md:text-sm md:font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 py-2 px-3 md:px-4 md:py-2";
30 |
31 | const fullWidthClass = fullWidth ? "w-full text-center" : "";
32 | const bgLightClass = bgLight
33 | ? "bg-indigo-100 hover:bg-indigo-200 text-indigo-600 hover:text-indigo-800"
34 | : "";
35 |
36 | const variantClasses = {
37 | default: "bg-indigo-500 text-white hover:bg-indigo-600",
38 | destructive: "bg-red-600 text-white hover:bg-red-700",
39 | outline: "border border-slate-300 bg-white hover:bg-slate-100",
40 | secondary: "bg-slate-700 text-white hover:bg-slate-800",
41 | ghost:
42 | "bg-transparent text-slate-600 hover:bg-slate-200 hover:text-slate-800 font-semibold",
43 | link: "text-blue-600 underline hover:text-blue-800",
44 | };
45 |
46 | return (
47 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## DragTrack - A Kanban Board for Drag and Drop
2 |
3 | DragTrack is a simple Kanban board that allows you to drag and drop tasks between columns. It is built using:
4 |
5 | - React
6 | - DnD-Kit
7 | - TypeScript
8 | - Tailwind CSS
9 | - Framer-Motion
10 | - Lucide Icons (for the icons)
11 |
12 | ## 👾 Features
13 |
14 | - Drag and drop containers.
15 | - Delete containers and items.
16 | - Edit container and item names.
17 | - Drag and drop items between containers.
18 | - Add containers and items (both are draggable).
19 | - Indication of which container you are editing items in.
20 |
21 | ## 📒 Process
22 |
23 | I started by implementing the functionality for creating containers. Next, I added the ability to drag and drop containers. Afterwards, I focused on the capability to create items inside containers and further, to drag and drop them.
24 |
25 | Then I styled the board, drawing inspiration from modern Kanban boards like Jira, Trello, and Notion based on my experience.
26 |
27 | Subsequently, I added functionalities for deleting items and containers. I then implemented the ability to edit the names of containers and items. Lastly, I added an indication of which container you are editing items in. Afterward, I performed some small refactoring and styling touch-ups.
28 |
29 | Some features were added in between the main features development. Everything is saved in local storage. The user can come back, and everything will still be there.
30 |
31 | **NOTE:** The project's purpose is to demonstrate the use of DnD-Kit and TypeScript. It is not meant to be a full-fledged Kanban board.
32 |
33 | ## 🚦 Running the Project
34 |
35 | To run the project in your local environment, follow these steps:
36 |
37 | 1. Clone the repository to your local machine.
38 | 2. Run `npm install` or `yarn` in the project directory to install the required dependencies.
39 | 3. Run `npm run start` or `yarn start` to get the project started.
40 | 4. Open [http://localhost:5173](http://localhost:5173) (or the address shown in your console) in your web browser to view the app.
41 |
42 | ## 📹 Video
43 |
44 | https://github.com/mirayatech/drag-track/assets/71933266/bee55318-f365-4028-9003-c06db8567a53
45 |
46 |
47 |
--------------------------------------------------------------------------------
/src/components/modal/modal.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef } from "react";
2 | import { AnimatePresence, motion } from "framer-motion";
3 | import FocusTrap from "focus-trap-react";
4 | import clsx from "clsx";
5 | import React from "react";
6 |
7 | interface ModalProps {
8 | showModal: boolean;
9 | containerClasses?: string;
10 | children: React.ReactNode;
11 | setShowModal: (value: boolean) => void;
12 | }
13 |
14 | export default function Modal({
15 | children,
16 | showModal,
17 | setShowModal,
18 | containerClasses,
19 | }: ModalProps) {
20 | const desktopModalRef = useRef(null);
21 | const onKeyDown = useCallback(
22 | (e: KeyboardEvent) => {
23 | if (e.key === "Escape") {
24 | setShowModal(false);
25 | }
26 | },
27 | [setShowModal]
28 | );
29 |
30 | useEffect(() => {
31 | document.addEventListener("keydown", onKeyDown);
32 | return () => document.removeEventListener("keydown", onKeyDown);
33 | }, [onKeyDown]);
34 |
35 | return (
36 |
37 | {showModal && (
38 | <>
39 |
40 | {
47 | if (desktopModalRef.current === e.target) {
48 | setShowModal(false);
49 | }
50 | }}
51 | className="fixed inset-0 z-40 min-h-screen items-center justify-center flex"
52 | >
53 |
59 | {children}
60 |
61 |
62 |
63 | setShowModal(false)}
70 | />
71 | >
72 | )}
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/src/components/menu/menu.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef } from "react";
2 | import { AnimatePresence, motion } from "framer-motion";
3 |
4 | type MenuProps = {
5 | isMenuOpen: boolean;
6 | setMenuOpen: (value: boolean) => void;
7 | onEdit?: () => void;
8 | onDelete?: () => void;
9 | extraTop?: boolean;
10 | };
11 |
12 | export default function Menu({
13 | setMenuOpen,
14 | isMenuOpen,
15 | onEdit,
16 | onDelete,
17 | extraTop = false,
18 | }: MenuProps) {
19 | const desktopModalRef = useRef(null);
20 |
21 | const onKeyDown = useCallback(
22 | (event: KeyboardEvent) => {
23 | if (event.key === "Escape") {
24 | setMenuOpen(false);
25 | }
26 | },
27 | [setMenuOpen]
28 | );
29 |
30 | const handleClickOutside = useCallback(
31 | (event: MouseEvent) => {
32 | if (
33 | desktopModalRef.current &&
34 | !desktopModalRef.current.contains(event.target as Node)
35 | ) {
36 | setMenuOpen(false);
37 | }
38 | },
39 | [setMenuOpen]
40 | );
41 |
42 | useEffect(() => {
43 | document.addEventListener("keydown", onKeyDown);
44 | document.addEventListener("mousedown", handleClickOutside);
45 |
46 | return () => {
47 | document.removeEventListener("keydown", onKeyDown);
48 | document.removeEventListener("mousedown", handleClickOutside);
49 | };
50 | }, [onKeyDown, handleClickOutside]);
51 |
52 | return (
53 |
54 | {isMenuOpen && (
55 |
65 |
74 |
83 |
84 | )}
85 |
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/src/components/container/container.tsx:
--------------------------------------------------------------------------------
1 | import { useSortable } from "@dnd-kit/sortable";
2 | import { UniqueIdentifier } from "@dnd-kit/core";
3 | import { EllipsisVertical } from "lucide-react";
4 | import { Button } from "..";
5 | import { useState } from "react";
6 | import { CSS } from "@dnd-kit/utilities";
7 | import { AnimatePresence } from "framer-motion";
8 | import Menu from "../menu/menu";
9 | import clsx from "clsx";
10 |
11 | type ContainerProps = {
12 | id: UniqueIdentifier;
13 | children: React.ReactNode;
14 | title?: string;
15 | description?: string;
16 | onAddItem?: () => void;
17 | onEdit?: () => void;
18 | onDelete?: () => void;
19 | };
20 |
21 | export function Container({
22 | id,
23 | children,
24 | title,
25 | onAddItem,
26 | onEdit,
27 | onDelete,
28 | }: ContainerProps) {
29 | const [isMenuOpen, setMenuOpen] = useState(false);
30 | const {
31 | attributes,
32 | setNodeRef,
33 | listeners,
34 | transform,
35 | transition,
36 | isDragging,
37 | } = useSortable({
38 | id: id,
39 | data: {
40 | type: "container",
41 | },
42 | });
43 |
44 | return (
45 |
57 |
58 |
62 | {title}
63 |
64 |
65 |
71 |
72 | {isMenuOpen && (
73 |
79 | )}
80 |
81 |
82 |
83 |
{children}
84 |
85 |
91 |
92 |
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/src/lib/utilities/dnd/handleDragMove.ts:
--------------------------------------------------------------------------------
1 | import { DragMoveEvent } from "@dnd-kit/core";
2 | import { arrayMove } from "@dnd-kit/sortable";
3 | import { ContainerType } from "../..";
4 | import { findValueOfItems } from "../findValueOfItems";
5 |
6 | export function handleDragMove(
7 | event: DragMoveEvent,
8 | containers: ContainerType[],
9 | setContainers: (containers: ContainerType[]) => void
10 | ) {
11 | const { active, over } = event;
12 |
13 | if (
14 | active.id.toString().includes("item") &&
15 | over?.id.toString().includes("item") &&
16 | active &&
17 | over &&
18 | active.id !== over.id
19 | ) {
20 | const activeContainer = findValueOfItems(containers, active.id, "item");
21 | const overContainer = findValueOfItems(containers, over.id, "item");
22 |
23 | if (!activeContainer || !overContainer) return;
24 |
25 | const activeContainerIndex = containers.findIndex(
26 | (container) => container.id === activeContainer.id
27 | );
28 |
29 | const overContainerIndex = containers.findIndex(
30 | (container) => container.id === overContainer.id
31 | );
32 |
33 | const activeitemIndex = activeContainer.items.findIndex(
34 | (item) => item.id === active.id
35 | );
36 | const overitemIndex = overContainer.items.findIndex(
37 | (item) => item.id === over.id
38 | );
39 |
40 | if (activeContainerIndex === overContainerIndex) {
41 | const newItems = [...containers];
42 | newItems[activeContainerIndex].items = arrayMove(
43 | newItems[activeContainerIndex].items,
44 | activeitemIndex,
45 | overitemIndex
46 | );
47 |
48 | setContainers(newItems);
49 | } else {
50 | const newItems = [...containers];
51 | const [removeditem] = newItems[activeContainerIndex].items.splice(
52 | activeitemIndex,
53 | 1
54 | );
55 | newItems[overContainerIndex].items.splice(overitemIndex, 0, removeditem);
56 | setContainers(newItems);
57 | }
58 | }
59 |
60 | if (
61 | active.id.toString().includes("item") &&
62 | over?.id.toString().includes("container") &&
63 | active &&
64 | over &&
65 | active.id !== over.id
66 | ) {
67 | const activeContainer = findValueOfItems(containers, active.id, "item");
68 | const overContainer = findValueOfItems(containers, over.id, "container");
69 |
70 | if (!activeContainer || !overContainer) return;
71 |
72 | const activeContainerIndex = containers.findIndex(
73 | (container) => container.id === activeContainer.id
74 | );
75 | const overContainerIndex = containers.findIndex(
76 | (container) => container.id === overContainer.id
77 | );
78 |
79 | const activeitemIndex = activeContainer.items.findIndex(
80 | (item) => item.id === active.id
81 | );
82 |
83 | const newItems = [...containers];
84 | const [removeditem] = newItems[activeContainerIndex].items.splice(
85 | activeitemIndex,
86 | 1
87 | );
88 | newItems[overContainerIndex].items.push(removeditem);
89 | setContainers(newItems);
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/src/lib/utilities/dnd/handleDragEnd.ts:
--------------------------------------------------------------------------------
1 | import { DragEndEvent, UniqueIdentifier } from "@dnd-kit/core";
2 | import { arrayMove } from "@dnd-kit/sortable";
3 | import { ContainerType } from "../..";
4 | import { findValueOfItems } from "../findValueOfItems";
5 |
6 | export function handleDragEnd(
7 | event: DragEndEvent,
8 | containers: ContainerType[],
9 | setContainers: (containers: ContainerType[]) => void,
10 | setActiveId: (id: UniqueIdentifier | null) => void
11 | ) {
12 | const { active, over } = event;
13 |
14 | if (
15 | active.id.toString().includes("container") &&
16 | over?.id.toString().includes("container") &&
17 | active &&
18 | over &&
19 | active.id !== over.id
20 | ) {
21 | const activeContainerIndex = containers.findIndex(
22 | (container) => container.id === active.id
23 | );
24 | const overContainerIndex = containers.findIndex(
25 | (container) => container.id === over.id
26 | );
27 | let newItems = [...containers];
28 | newItems = arrayMove(newItems, activeContainerIndex, overContainerIndex);
29 | setContainers(newItems);
30 | }
31 |
32 | if (
33 | active.id.toString().includes("item") &&
34 | over?.id.toString().includes("item") &&
35 | active &&
36 | over &&
37 | active.id !== over.id
38 | ) {
39 | const activeContainer = findValueOfItems(containers, active.id, "item");
40 | const overContainer = findValueOfItems(containers, over.id, "item");
41 |
42 | if (!activeContainer || !overContainer) return;
43 | const activeContainerIndex = containers.findIndex(
44 | (container) => container.id === activeContainer.id
45 | );
46 | const overContainerIndex = containers.findIndex(
47 | (container) => container.id === overContainer.id
48 | );
49 | const activeitemIndex = activeContainer.items.findIndex(
50 | (item) => item.id === active.id
51 | );
52 | const overitemIndex = overContainer.items.findIndex(
53 | (item) => item.id === over.id
54 | );
55 |
56 | if (activeContainerIndex === overContainerIndex) {
57 | const newItems = [...containers];
58 | newItems[activeContainerIndex].items = arrayMove(
59 | newItems[activeContainerIndex].items,
60 | activeitemIndex,
61 | overitemIndex
62 | );
63 | setContainers(newItems);
64 | } else {
65 | const newItems = [...containers];
66 | const [removeditem] = newItems[activeContainerIndex].items.splice(
67 | activeitemIndex,
68 | 1
69 | );
70 | newItems[overContainerIndex].items.splice(overitemIndex, 0, removeditem);
71 | setContainers(newItems);
72 | }
73 | }
74 |
75 | if (
76 | active.id.toString().includes("item") &&
77 | over?.id.toString().includes("container") &&
78 | active &&
79 | over &&
80 | active.id !== over.id
81 | ) {
82 | const activeContainer = findValueOfItems(containers, active.id, "item");
83 | const overContainer = findValueOfItems(containers, over.id, "container");
84 |
85 | if (!activeContainer || !overContainer) return;
86 | const activeContainerIndex = containers.findIndex(
87 | (container) => container.id === activeContainer.id
88 | );
89 | const overContainerIndex = containers.findIndex(
90 | (container) => container.id === overContainer.id
91 | );
92 | const activeitemIndex = activeContainer.items.findIndex(
93 | (item) => item.id === active.id
94 | );
95 |
96 | const newItems = [...containers];
97 | const [removeditem] = newItems[activeContainerIndex].items.splice(
98 | activeitemIndex,
99 | 1
100 | );
101 | newItems[overContainerIndex].items.push(removeditem);
102 | setContainers(newItems);
103 | }
104 | setActiveId(null);
105 | }
106 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Button, Container, Input, Items, Modal } from "./components";
3 |
4 | import {
5 | findContainerItems,
6 | findItemTitle,
7 | handleDragEnd,
8 | handleDragMove,
9 | handleDragStart,
10 | onAddContainer,
11 | onAddItem,
12 | onDeleteContainer,
13 | onEditContainer,
14 | onEditItem,
15 | openEditItemModal,
16 | openEditModal,
17 | useContainerStore,
18 | findContainerTitle,
19 | onDeleteItem,
20 | findContainerNameByItemId,
21 | isContainerNameEmpty,
22 | isItemNameEmpty,
23 | isEditingContainerNameChanged,
24 | isEditingItemNameChanged,
25 | } from "./lib";
26 |
27 | import {
28 | DndContext,
29 | DragOverlay,
30 | KeyboardSensor,
31 | PointerSensor,
32 | UniqueIdentifier,
33 | closestCorners,
34 | useSensor,
35 | useSensors,
36 | } from "@dnd-kit/core";
37 | import {
38 | SortableContext,
39 | sortableKeyboardCoordinates,
40 | } from "@dnd-kit/sortable";
41 | import { Layout, Text, Trash2 } from "lucide-react";
42 |
43 | export default function App() {
44 | const [containerName, setContainerName] = useState("");
45 | const { containers, setContainers, addContainer } = useContainerStore();
46 | const [activeId, setActiveId] = useState(null);
47 | const [showAddContainerModal, setShowAddContainerModal] = useState(false);
48 | const [showAddItemModal, setShowAddItemModal] = useState(false);
49 | const [showEditContainerModal, setShowEditContainerModal] = useState(false);
50 | const [currentContainerId, setCurrentContainerId] =
51 | useState();
52 | const [itemName, setItemName] = useState("");
53 | const [editingContainer, setEditingContainer] =
54 | useState(null);
55 | const [editingContainerName, setEditingContainerName] = useState("");
56 |
57 | const [showEditItemModal, setShowEditItemModal] = useState(false);
58 | const [editingItem, setEditingItem] = useState(null);
59 | const [editingItemName, setEditingItemName] = useState("");
60 |
61 | const sensors = useSensors(
62 | useSensor(PointerSensor),
63 | useSensor(KeyboardSensor, {
64 | coordinateGetter: sortableKeyboardCoordinates,
65 | })
66 | );
67 |
68 | const containerNameForEditingItem = findContainerNameByItemId(
69 | containers,
70 | editingItem
71 | );
72 |
73 | return (
74 |
75 |
79 |
80 |
81 | Add Container
82 |
83 | setContainerName(event.target.value)}
89 | />
90 |
104 |
105 |
106 |
107 |
108 | Add Card
109 |
110 | setItemName(event.target.value)}
116 | />
117 |
133 |
134 |
138 |
139 |
140 | Edit Container
141 |
142 | setEditingContainerName(event.target.value)}
148 | />
149 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 | Optimization
180 | {" "}
181 |
182 |
183 | in list {containerNameForEditingItem}
184 |
185 |
186 |
187 |
202 |
203 |
204 |
205 |
206 |
207 | Card Title
208 | {" "}
209 |
210 |
setEditingItemName(event.target.value)}
216 | />{" "}
217 |
218 |
247 |
248 |
249 |
250 |
251 | DragTrack
252 |
253 | setShowAddContainerModal(true)}
255 | label="Add Container"
256 | />
257 |
258 |
259 |
260 |
handleDragStart(event, setActiveId)}
264 | onDragMove={(event) =>
265 | handleDragMove(event, containers, setContainers)
266 | }
267 | onDragEnd={(event) =>
268 | handleDragEnd(event, containers, setContainers, setActiveId)
269 | }
270 | >
271 | item.id)}>
272 | {containers.map((container) => (
273 | {
278 | setShowAddItemModal(true);
279 | setCurrentContainerId(container.id);
280 | }}
281 | onEdit={() =>
282 | openEditModal(
283 | setEditingContainer,
284 | setEditingContainerName,
285 | setShowEditContainerModal,
286 | container.id,
287 | container.title
288 | )
289 | }
290 | onDelete={() =>
291 | onDeleteContainer(container.id, containers, setContainers)
292 | }
293 | >
294 | item.id)}
296 | >
297 |
298 | {container.items.map((item) => (
299 |
304 | openEditItemModal(
305 | setEditingItem,
306 | setEditingItemName,
307 | setShowEditItemModal,
308 | item.id,
309 | item.title
310 | )
311 | }
312 | />
313 | ))}
314 |
315 |
316 |
317 | ))}
318 |
319 |
320 | {activeId && activeId.toString().includes("item") && (
321 |
325 | )}
326 | {activeId && activeId.toString().includes("container") && (
327 |
331 | {findContainerItems(containers, activeId).map((item) => (
332 |
333 | ))}
334 |
335 | )}
336 |
337 |
338 |
339 |
340 |
341 | );
342 | }
343 |
--------------------------------------------------------------------------------