├── 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 |
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 |
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 | --------------------------------------------------------------------------------