import React, { useState } from "react"; import { FiPlus, FiTrash } from "react-icons/fi"; import { motion } from "framer-motion"; import { FaFire } from "react-icons/fa"; export const CustomKanban = () => { return ( <div className="h-screen w-full bg-neutral-900 text-neutral-50"> <Board /> </div> ); }; const Board = () => { const [cards, setCards] = useState(DEFAULT_CARDS); return ( <div className="flex h-full w-full gap-3 overflow-scroll p-12"> <Column title="Backlog" column="backlog" headingColor="text-neutral-500" cards={cards} setCards={setCards} /> <Column title="TODO" column="todo" headingColor="text-yellow-200" cards={cards} setCards={setCards} /> <Column title="In progress" column="doing" headingColor="text-blue-200" cards={cards} setCards={setCards} /> <Column title="Complete" column="done" headingColor="text-emerald-200" cards={cards} setCards={setCards} /> <BurnBarrel setCards={setCards} /> </div> ); }; const Column = ({ title, headingColor, cards, column, setCards }) => { const [active, setActive] = useState(false); const handleDragStart = (e, card) => { e.dataTransfer.setData("cardId", card.id); }; const handleDragEnd = (e) => { const cardId = e.dataTransfer.getData("cardId"); setActive(false); clearHighlights(); const indicators = getIndicators(); const { element } = getNearestIndicator(e, indicators); const before = element.dataset.before || "-1"; if (before !== cardId) { let copy = [...cards]; let cardToTransfer = copy.find((c) => c.id === cardId); if (!cardToTransfer) return; cardToTransfer = { ...cardToTransfer, column }; copy = copy.filter((c) => c.id !== cardId); const moveToBack = before === "-1"; if (moveToBack) { copy.push(cardToTransfer); } else { const insertAtIndex = copy.findIndex((el) => el.id === before); if (insertAtIndex === undefined) return; copy.splice(insertAtIndex, 0, cardToTransfer); } setCards(copy); } }; const handleDragOver = (e) => { e.preventDefault(); highlightIndicator(e); setActive(true); }; const clearHighlights = (els) => { const indicators = els || getIndicators(); indicators.forEach((i) => { i.style.opacity = "0"; }); }; const highlightIndicator = (e) => { const indicators = getIndicators(); clearHighlights(indicators); const el = getNearestIndicator(e, indicators); el.element.style.opacity = "1"; }; const getNearestIndicator = (e, indicators) => { const DISTANCE_OFFSET = 50; const el = indicators.reduce( (closest, child) => { const box = child.getBoundingClientRect(); const offset = e.clientY - (box.top + DISTANCE_OFFSET); if (offset < 0 && offset > closest.offset) { return { offset: offset, element: child }; } else { return closest; } }, { offset: Number.NEGATIVE_INFINITY, element: indicators[indicators.length - 1], } ); return el; }; const getIndicators = () => { return Array.from(document.querySelectorAll(`[data-column="${column}"]`)); }; const handleDragLeave = () => { clearHighlights(); setActive(false); }; const filteredCards = cards.filter((c) => c.column === column); return ( <div className="w-56 shrink-0"> <div className="mb-3 flex items-center justify-between"> <h3 className={`font-medium ${headingColor}`}>{title}</h3> <span className="rounded text-sm text-neutral-400"> {filteredCards.length} </span> </div> <div onDrop={handleDragEnd} onDragOver={handleDragOver} onDragLeave={handleDragLeave} className={`h-full w-full transition-colors ${ active ? "bg-neutral-800/50" : "bg-neutral-800/0" }`} > {filteredCards.map((c) => { return <Card key={c.id} {...c} handleDragStart={handleDragStart} />; })} <DropIndicator beforeId={null} column={column} /> <AddCard column={column} setCards={setCards} /> </div> </div> ); }; const Card = ({ title, id, column, handleDragStart }) => { return ( <> <DropIndicator beforeId={id} column={column} /> <motion.div layout layoutId={id} draggable="true" onDragStart={(e) => handleDragStart(e, { title, id, column })} className="cursor-grab rounded border border-neutral-700 bg-neutral-800 p-3 active:cursor-grabbing" > <p className="text-sm text-neutral-100">{title}</p> </motion.div> </> ); }; const DropIndicator = ({ beforeId, column }) => { return ( <div data-before={beforeId || "-1"} data-column={column} className="my-0.5 h-0.5 w-full bg-violet-400 opacity-0" /> ); }; const BurnBarrel = ({ setCards }) => { const [active, setActive] = useState(false); const handleDragOver = (e) => { e.preventDefault(); setActive(true); }; const handleDragLeave = () => { setActive(false); }; const handleDragEnd = (e) => { const cardId = e.dataTransfer.getData("cardId"); setCards((pv) => pv.filter((c) => c.id !== cardId)); setActive(false); }; return ( <div onDrop={handleDragEnd} onDragOver={handleDragOver} onDragLeave={handleDragLeave} className={`mt-10 grid h-56 w-56 shrink-0 place-content-center rounded border text-3xl ${ active ? "border-red-800 bg-red-800/20 text-red-500" : "border-neutral-500 bg-neutral-500/20 text-neutral-500" }`} > {active ? <FaFire className="animate-bounce" /> : <FiTrash />} </div> ); }; const AddCard = ({ column, setCards }) => { const [text, setText] = useState(""); const [adding, setAdding] = useState(false); const handleSubmit = (e) => { e.preventDefault(); if (!text.trim().length) return; const newCard = { column, title: text.trim(), id: Math.random().toString(), }; setCards((pv) => [...pv, newCard]); setAdding(false); }; return ( <> {adding ? ( <motion.form layout onSubmit={handleSubmit}> <textarea onChange={(e) => setText(e.target.value)} autoFocus placeholder="Add new task..." className="w-full rounded border border-violet-400 bg-violet-400/20 p-3 text-sm text-neutral-50 placeholder-violet-300 focus:outline-0" /> <div className="mt-1.5 flex items-center justify-end gap-1.5"> <button onClick={() => setAdding(false)} className="px-3 py-1.5 text-xs text-neutral-400 transition-colors hover:text-neutral-50" > Close </button> <button type="submit" className="flex items-center gap-1.5 rounded bg-neutral-50 px-3 py-1.5 text-xs text-neutral-950 transition-colors hover:bg-neutral-300" > <span>Add</span> <FiPlus /> </button> </div> </motion.form> ) : ( <motion.button layout onClick={() => setAdding(true)} className="flex w-full items-center gap-1.5 px-3 py-1.5 text-xs text-neutral-400 transition-colors hover:text-neutral-50" > <span>Add card</span> <FiPlus /> </motion.button> )} </> ); }; const DEFAULT_CARDS = [ // BACKLOG { title: "Look into render bug in dashboard", id: "1", column: "backlog" }, { title: "SOX compliance checklist", id: "2", column: "backlog" }, { title: "[SPIKE] Migrate to Azure", id: "3", column: "backlog" }, { title: "Document Notifications service", id: "4", column: "backlog" }, // TODO { title: "Research DB options for new microservice", id: "5", column: "todo", }, { title: "Postmortem for outage", id: "6", column: "todo" }, { title: "Sync with product on Q3 roadmap", id: "7", column: "todo" }, // DOING { title: "Refactor context providers to use Zustand", id: "8", column: "doing", }, { title: "Add logging to daily CRON", id: "9", column: "doing" }, // DONE { title: "Set up DD dashboards for Lambda listener", id: "10", column: "done", }, ];