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