repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
Projectflow
stars
latest
clone command
git clone gitlawb://did:key:z6Mkfh4Y...QBEi/projectflowgit clone gitlawb://did:key:z6Mkfh4Y.../projectflowb3cded1async from playground23h ago| #1 | import { useState, useMemo } from "react"; |
| #2 | import { motion, AnimatePresence } from "framer-motion"; |
| #3 | import { |
| #4 | DndContext, |
| #5 | DragOverlay, |
| #6 | closestCorners, |
| #7 | KeyboardSensor, |
| #8 | PointerSensor, |
| #9 | useSensor, |
| #10 | useSensors, |
| #11 | type DragStartEvent, |
| #12 | type DragEndEvent, |
| #13 | type DragOverEvent, |
| #14 | } from "@dnd-kit/core"; |
| #15 | import { |
| #16 | SortableContext, |
| #17 | verticalListSortingStrategy, |
| #18 | useSortable, |
| #19 | } from "@dnd-kit/sortable"; |
| #20 | import { CSS } from "@dnd-kit/utilities"; |
| #21 | import { |
| #22 | Plus, |
| #23 | Filter, |
| #24 | X, |
| #25 | GripVertical, |
| #26 | Calendar, |
| #27 | MessageSquare, |
| #28 | } from "lucide-react"; |
| #29 | import clsx from "clsx"; |
| #30 | import { format } from "date-fns"; |
| #31 | import { useStore } from "../store"; |
| #32 | import type { Issue, IssueStatus, Priority } from "../types"; |
| #33 | import { |
| #34 | Avatar, |
| #35 | IssueTypeBadge, |
| #36 | Tooltip, |
| #37 | Select, |
| #38 | } from "./ui"; |
| #39 | |
| #40 | const COLUMNS: { id: IssueStatus; label: string; color: string }[] = [ |
| #41 | { id: "backlog", label: "Backlog", color: "bg-zinc-500" }, |
| #42 | { id: "todo", label: "Todo", color: "bg-zinc-400" }, |
| #43 | { id: "in-progress", label: "In Progress", color: "bg-amber-400" }, |
| #44 | { id: "in-review", label: "In Review", color: "bg-purple-400" }, |
| #45 | { id: "done", label: "Done", color: "bg-emerald-400" }, |
| #46 | ]; |
| #47 | |
| #48 | // Card content (shared between sortable and overlay) |
| #49 | function CardContent({ issue, onClick }: { issue: Issue; onClick: () => void }) { |
| #50 | const { users, comments } = useStore(); |
| #51 | const assignee = issue.assigneeId |
| #52 | ? users.find((u) => u.id === issue.assigneeId) |
| #53 | : null; |
| #54 | const issueComments = comments.filter((c) => c.issueId === issue.id); |
| #55 | |
| #56 | return ( |
| #57 | <div |
| #58 | className="group/card rounded-lg border border-border bg-surface-1 p-3 cursor-pointer hover:border-border-strong transition-all" |
| #59 | onClick={onClick} |
| #60 | > |
| #61 | <div className="flex items-start justify-between gap-2"> |
| #62 | <div className="flex items-center gap-2 min-w-0"> |
| #63 | <span className="text-2xs font-mono text-zinc-500 shrink-0"> |
| #64 | {issue.identifier} |
| #65 | </span> |
| #66 | </div> |
| #67 | <IssueTypeBadge type={issue.type} /> |
| #68 | </div> |
| #69 | |
| #70 | <h4 className="text-sm text-zinc-200 mt-2 leading-snug line-clamp-2"> |
| #71 | {issue.title} |
| #72 | </h4> |
| #73 | |
| #74 | <div className="flex items-center justify-between mt-3"> |
| #75 | <div className="flex items-center gap-2"> |
| #76 | {issue.priority !== "none" && ( |
| #77 | <PriorityDot priority={issue.priority} /> |
| #78 | )} |
| #79 | {issue.dueDate && ( |
| #80 | <span |
| #81 | className={clsx( |
| #82 | "flex items-center gap-1 text-2xs", |
| #83 | new Date(issue.dueDate) < new Date() && issue.status !== "done" |
| #84 | ? "text-red-400" |
| #85 | : "text-zinc-500" |
| #86 | )} |
| #87 | > |
| #88 | <Calendar size={10} /> |
| #89 | {format(new Date(issue.dueDate), "MMM d")} |
| #90 | </span> |
| #91 | )} |
| #92 | {issueComments.length > 0 && ( |
| #93 | <span className="flex items-center gap-1 text-2xs text-zinc-500"> |
| #94 | <MessageSquare size={10} /> |
| #95 | {issueComments.length} |
| #96 | </span> |
| #97 | )} |
| #98 | </div> |
| #99 | {assignee && ( |
| #100 | <Tooltip content={assignee.name}> |
| #101 | <Avatar name={assignee.name} size="xs" /> |
| #102 | </Tooltip> |
| #103 | )} |
| #104 | </div> |
| #105 | </div> |
| #106 | ); |
| #107 | } |
| #108 | |
| #109 | // Sortable Issue Card |
| #110 | function IssueCard({ |
| #111 | issue, |
| #112 | onClick, |
| #113 | }: { |
| #114 | issue: Issue; |
| #115 | onClick: () => void; |
| #116 | }) { |
| #117 | const { |
| #118 | attributes, |
| #119 | listeners, |
| #120 | setNodeRef, |
| #121 | transform, |
| #122 | transition, |
| #123 | isDragging, |
| #124 | } = useSortable({ |
| #125 | id: issue.id, |
| #126 | data: { type: "issue", issue }, |
| #127 | }); |
| #128 | |
| #129 | const style = { |
| #130 | transform: CSS.Transform.toString(transform), |
| #131 | transition, |
| #132 | }; |
| #133 | |
| #134 | return ( |
| #135 | <div |
| #136 | ref={setNodeRef} |
| #137 | style={style} |
| #138 | className={clsx("group", isDragging && "opacity-50")} |
| #139 | > |
| #140 | <div className="relative"> |
| #141 | <div |
| #142 | {...attributes} |
| #143 | {...listeners} |
| #144 | className="absolute -left-1 top-3 p-0.5 rounded text-zinc-600 opacity-0 group-hover:opacity-100 cursor-grab active:cursor-grabbing transition-opacity z-10" |
| #145 | > |
| #146 | <GripVertical size={12} /> |
| #147 | </div> |
| #148 | <CardContent issue={issue} onClick={onClick} /> |
| #149 | </div> |
| #150 | </div> |
| #151 | ); |
| #152 | } |
| #153 | |
| #154 | function PriorityDot({ priority }: { priority: Priority }) { |
| #155 | const colors: Record<Priority, string> = { |
| #156 | urgent: "bg-red-400", |
| #157 | high: "bg-orange-400", |
| #158 | medium: "bg-yellow-400", |
| #159 | low: "bg-blue-400", |
| #160 | none: "bg-zinc-500", |
| #161 | }; |
| #162 | |
| #163 | return ( |
| #164 | <Tooltip content={priority}> |
| #165 | <div className={clsx("w-2 h-2 rounded-full", colors[priority])} /> |
| #166 | </Tooltip> |
| #167 | ); |
| #168 | } |
| #169 | |
| #170 | // Column |
| #171 | function Column({ |
| #172 | column, |
| #173 | issues, |
| #174 | onIssueClick, |
| #175 | }: { |
| #176 | column: (typeof COLUMNS)[number]; |
| #177 | issues: Issue[]; |
| #178 | onIssueClick: (issue: Issue) => void; |
| #179 | }) { |
| #180 | const [showCreate, setShowCreate] = useState(false); |
| #181 | const { createIssue, currentProject, users } = useStore(); |
| #182 | |
| #183 | const handleQuickCreate = (title: string) => { |
| #184 | if (!title.trim() || !currentProject) return; |
| #185 | createIssue({ |
| #186 | projectId: currentProject.id, |
| #187 | title, |
| #188 | description: "", |
| #189 | status: column.id, |
| #190 | priority: "none", |
| #191 | type: "task", |
| #192 | assigneeId: null, |
| #193 | sprintId: null, |
| #194 | labels: [], |
| #195 | dueDate: null, |
| #196 | estimate: null, |
| #197 | parentId: null, |
| #198 | createdById: users[0]?.id || "u1", |
| #199 | }); |
| #200 | setShowCreate(false); |
| #201 | }; |
| #202 | |
| #203 | return ( |
| #204 | <div className="flex flex-col min-w-[280px] max-w-[320px] flex-1"> |
| #205 | {/* Column header */} |
| #206 | <div className="flex items-center justify-between px-2 mb-3"> |
| #207 | <div className="flex items-center gap-2"> |
| #208 | <div className={clsx("w-2.5 h-2.5 rounded-full", column.color)} /> |
| #209 | <span className="text-sm font-medium text-zinc-300"> |
| #210 | {column.label} |
| #211 | </span> |
| #212 | <span className="text-2xs text-zinc-500 bg-surface-3 px-1.5 py-0.5 rounded-full"> |
| #213 | {issues.length} |
| #214 | </span> |
| #215 | </div> |
| #216 | <button |
| #217 | onClick={() => setShowCreate(true)} |
| #218 | className="p-1 rounded text-zinc-500 hover:text-zinc-300 hover:bg-surface-3 transition-colors" |
| #219 | > |
| #220 | <Plus size={14} /> |
| #221 | </button> |
| #222 | </div> |
| #223 | |
| #224 | {/* Issues */} |
| #225 | <SortableContext |
| #226 | items={issues.map((i) => i.id)} |
| #227 | strategy={verticalListSortingStrategy} |
| #228 | > |
| #229 | <div className="flex-1 space-y-2 px-0.5 pb-2 min-h-[100px]"> |
| #230 | {issues.map((issue) => ( |
| #231 | <IssueCard |
| #232 | key={issue.id} |
| #233 | issue={issue} |
| #234 | onClick={() => onIssueClick(issue)} |
| #235 | /> |
| #236 | ))} |
| #237 | </div> |
| #238 | </SortableContext> |
| #239 | |
| #240 | {/* Quick create */} |
| #241 | <AnimatePresence> |
| #242 | {showCreate && ( |
| #243 | <motion.div |
| #244 | initial={{ opacity: 0, height: 0 }} |
| #245 | animate={{ opacity: 1, height: "auto" }} |
| #246 | exit={{ opacity: 0, height: 0 }} |
| #247 | className="px-0.5" |
| #248 | > |
| #249 | <QuickCreate |
| #250 | onSubmit={handleQuickCreate} |
| #251 | onCancel={() => setShowCreate(false)} |
| #252 | /> |
| #253 | </motion.div> |
| #254 | )} |
| #255 | </AnimatePresence> |
| #256 | </div> |
| #257 | ); |
| #258 | } |
| #259 | |
| #260 | // Quick Create input |
| #261 | function QuickCreate({ |
| #262 | onSubmit, |
| #263 | onCancel, |
| #264 | }: { |
| #265 | onSubmit: (title: string) => void; |
| #266 | onCancel: () => void; |
| #267 | }) { |
| #268 | const [title, setTitle] = useState(""); |
| #269 | |
| #270 | return ( |
| #271 | <div className="rounded-lg border border-border bg-surface-1 p-3"> |
| #272 | <input |
| #273 | value={title} |
| #274 | onChange={(e) => setTitle(e.target.value)} |
| #275 | onKeyDown={(e) => { |
| #276 | if (e.key === "Enter" && title.trim()) onSubmit(title); |
| #277 | if (e.key === "Escape") onCancel(); |
| #278 | }} |
| #279 | placeholder="Issue title..." |
| #280 | className="w-full text-sm text-zinc-200 bg-transparent outline-none" |
| #281 | autoFocus |
| #282 | /> |
| #283 | <div className="flex justify-end gap-2 mt-2"> |
| #284 | <button onClick={onCancel} className="btn-ghost text-xs px-2 py-1"> |
| #285 | Cancel |
| #286 | </button> |
| #287 | <button |
| #288 | onClick={() => title.trim() && onSubmit(title)} |
| #289 | disabled={!title.trim()} |
| #290 | className="btn-primary text-xs px-2 py-1" |
| #291 | > |
| #292 | Create |
| #293 | </button> |
| #294 | </div> |
| #295 | </div> |
| #296 | ); |
| #297 | } |
| #298 | |
| #299 | // Board Component |
| #300 | export function Board() { |
| #301 | const { |
| #302 | issues, |
| #303 | currentProject, |
| #304 | setCurrentIssue, |
| #305 | moveIssue, |
| #306 | filters, |
| #307 | setFilter, |
| #308 | clearFilters, |
| #309 | users, |
| #310 | } = useStore(); |
| #311 | |
| #312 | const [activeId, setActiveId] = useState<string | null>(null); |
| #313 | |
| #314 | const sensors = useSensors( |
| #315 | useSensor(PointerSensor, { |
| #316 | activationConstraint: { distance: 5 }, |
| #317 | }), |
| #318 | useSensor(KeyboardSensor) |
| #319 | ); |
| #320 | |
| #321 | // Filter issues for current project |
| #322 | const projectIssues = useMemo(() => { |
| #323 | let filtered = issues.filter( |
| #324 | (i) => i.projectId === currentProject?.id |
| #325 | ); |
| #326 | |
| #327 | if (filters.assignee) { |
| #328 | filtered = filtered.filter((i) => i.assigneeId === filters.assignee); |
| #329 | } |
| #330 | if (filters.priority) { |
| #331 | filtered = filtered.filter((i) => i.priority === filters.priority); |
| #332 | } |
| #333 | if (filters.status) { |
| #334 | filtered = filtered.filter((i) => i.status === filters.status); |
| #335 | } |
| #336 | if (filters.label) { |
| #337 | filtered = filtered.filter((i) => i.labels.includes(filters.label!)); |
| #338 | } |
| #339 | if (filters.search) { |
| #340 | const q = filters.search.toLowerCase(); |
| #341 | filtered = filtered.filter( |
| #342 | (i) => |
| #343 | i.title.toLowerCase().includes(q) || |
| #344 | i.identifier.toLowerCase().includes(q) |
| #345 | ); |
| #346 | } |
| #347 | |
| #348 | return filtered; |
| #349 | }, [issues, currentProject, filters]); |
| #350 | |
| #351 | // Group by status |
| #352 | const columnIssues = useMemo(() => { |
| #353 | const grouped: Record<IssueStatus, Issue[]> = { |
| #354 | backlog: [], |
| #355 | todo: [], |
| #356 | "in-progress": [], |
| #357 | "in-review": [], |
| #358 | done: [], |
| #359 | cancelled: [], |
| #360 | }; |
| #361 | projectIssues.forEach((issue) => { |
| #362 | if (grouped[issue.status]) { |
| #363 | grouped[issue.status].push(issue); |
| #364 | } |
| #365 | }); |
| #366 | return grouped; |
| #367 | }, [projectIssues]); |
| #368 | |
| #369 | const activeIssue = activeId |
| #370 | ? issues.find((i) => i.id === activeId) |
| #371 | : null; |
| #372 | |
| #373 | const hasFilters = |
| #374 | filters.assignee || filters.priority || filters.status || filters.label; |
| #375 | |
| #376 | function handleDragStart(event: DragStartEvent) { |
| #377 | setActiveId(event.active.id as string); |
| #378 | } |
| #379 | |
| #380 | function handleDragOver(event: DragOverEvent) { |
| #381 | const { active, over } = event; |
| #382 | if (!over) return; |
| #383 | |
| #384 | const activeIssue = issues.find((i) => i.id === active.id); |
| #385 | if (!activeIssue) return; |
| #386 | |
| #387 | // Check if over a column |
| #388 | const overColumn = COLUMNS.find((c) => c.id === over.id); |
| #389 | if (overColumn && activeIssue.status !== overColumn.id) { |
| #390 | moveIssue(activeIssue.id, overColumn.id); |
| #391 | return; |
| #392 | } |
| #393 | |
| #394 | // Check if over another issue |
| #395 | const overIssue = issues.find((i) => i.id === over.id); |
| #396 | if (overIssue && activeIssue.status !== overIssue.status) { |
| #397 | moveIssue(activeIssue.id, overIssue.status); |
| #398 | } |
| #399 | } |
| #400 | |
| #401 | function handleDragEnd(event: DragEndEvent) { |
| #402 | setActiveId(null); |
| #403 | } |
| #404 | |
| #405 | return ( |
| #406 | <div className="h-full flex flex-col"> |
| #407 | {/* Filters bar */} |
| #408 | <div className="flex items-center gap-2 px-6 py-3 border-b border-border shrink-0"> |
| #409 | <div className="flex items-center gap-2"> |
| #410 | <Filter size={14} className="text-zinc-500" /> |
| #411 | <Select |
| #412 | value={filters.assignee || ""} |
| #413 | onChange={(v) => setFilter("assignee", v || null)} |
| #414 | options={[ |
| #415 | { value: "", label: "All assignees" }, |
| #416 | ...users.map((u) => ({ value: u.id, label: u.name })), |
| #417 | ]} |
| #418 | className="w-40 text-xs py-1.5" |
| #419 | /> |
| #420 | <Select |
| #421 | value={filters.priority || ""} |
| #422 | onChange={(v) => |
| #423 | setFilter("priority", (v as Priority) || null) |
| #424 | } |
| #425 | options={[ |
| #426 | { value: "", label: "All priorities" }, |
| #427 | { value: "urgent", label: "Urgent" }, |
| #428 | { value: "high", label: "High" }, |
| #429 | { value: "medium", label: "Medium" }, |
| #430 | { value: "low", label: "Low" }, |
| #431 | ]} |
| #432 | className="w-36 text-xs py-1.5" |
| #433 | /> |
| #434 | {hasFilters && ( |
| #435 | <button |
| #436 | onClick={clearFilters} |
| #437 | className="flex items-center gap-1 text-2xs text-zinc-500 hover:text-zinc-300" |
| #438 | > |
| #439 | <X size={12} /> |
| #440 | Clear |
| #441 | </button> |
| #442 | )} |
| #443 | </div> |
| #444 | </div> |
| #445 | |
| #446 | {/* Board */} |
| #447 | <div className="flex-1 overflow-x-auto p-6"> |
| #448 | <DndContext |
| #449 | sensors={sensors} |
| #450 | collisionDetection={closestCorners} |
| #451 | onDragStart={handleDragStart} |
| #452 | onDragOver={handleDragOver} |
| #453 | onDragEnd={handleDragEnd} |
| #454 | > |
| #455 | <div className="flex gap-4 h-full min-w-max"> |
| #456 | {COLUMNS.map((column) => ( |
| #457 | <Column |
| #458 | key={column.id} |
| #459 | column={column} |
| #460 | issues={columnIssues[column.id]} |
| #461 | onIssueClick={(issue) => setCurrentIssue(issue)} |
| #462 | /> |
| #463 | ))} |
| #464 | </div> |
| #465 | |
| #466 | <DragOverlay> |
| #467 | {activeIssue && ( |
| #468 | <div className="shadow-xl shadow-black/30 border-accent/30 rounded-lg"> |
| #469 | <CardContent issue={activeIssue} onClick={() => {}} /> |
| #470 | </div> |
| #471 | )} |
| #472 | </DragOverlay> |
| #473 | </DndContext> |
| #474 | </div> |
| #475 | </div> |
| #476 | ); |
| #477 | } |
| #478 |