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 | Plus, |
| #5 | ListTodo, |
| #6 | ChevronRight, |
| #7 | ChevronDown, |
| #8 | GripVertical, |
| #9 | Calendar, |
| #10 | } from "lucide-react"; |
| #11 | import clsx from "clsx"; |
| #12 | import { format } from "date-fns"; |
| #13 | import { useStore } from "../../application/stores"; |
| #14 | import type { Issue, Priority } from "../../domain/value-objects"; |
| #15 | import { |
| #16 | Avatar, |
| #17 | IssueTypeBadge, |
| #18 | PriorityBadge, |
| #19 | EmptyState, |
| #20 | Modal, |
| #21 | Select, |
| #22 | Tooltip, |
| #23 | } from "../ui"; |
| #24 | |
| #25 | export function BacklogView() { |
| #26 | const { |
| #27 | issues, |
| #28 | currentProject, |
| #29 | sprints, |
| #30 | users, |
| #31 | setCurrentIssue, |
| #32 | addIssueToSprint, |
| #33 | createIssue, |
| #34 | } = useStore(); |
| #35 | |
| #36 | const [showCreate, setShowCreate] = useState(false); |
| #37 | const [newTitle, setNewTitle] = useState(""); |
| #38 | const [newPriority, setNewPriority] = useState<Priority>("none"); |
| #39 | const [newType, setNewType] = useState<"task" | "bug" | "feature">("task"); |
| #40 | const [expandedSprint, setExpandedSprint] = useState<string | null>(null); |
| #41 | const [dragOverSprint, setDragOverSprint] = useState<string | null>(null); |
| #42 | |
| #43 | const projectIssues = useMemo( |
| #44 | () => issues.filter((i) => i.projectId === currentProject?.id), |
| #45 | [issues, currentProject] |
| #46 | ); |
| #47 | |
| #48 | const backlogIssues = useMemo( |
| #49 | () => |
| #50 | projectIssues |
| #51 | .filter((i) => !i.sprintId && i.status === "backlog") |
| #52 | .sort((a, b) => { |
| #53 | const order: Record<Priority, number> = { |
| #54 | urgent: 0, |
| #55 | high: 1, |
| #56 | medium: 2, |
| #57 | low: 3, |
| #58 | none: 4, |
| #59 | }; |
| #60 | return order[a.priority] - order[b.priority]; |
| #61 | }), |
| #62 | [projectIssues] |
| #63 | ); |
| #64 | |
| #65 | const projectSprints = useMemo( |
| #66 | () => sprints.filter((s) => s.projectId === currentProject?.id), |
| #67 | [sprints, currentProject] |
| #68 | ); |
| #69 | |
| #70 | const handleCreate = () => { |
| #71 | if (!newTitle.trim() || !currentProject) return; |
| #72 | createIssue({ |
| #73 | projectId: currentProject.id, |
| #74 | title: newTitle, |
| #75 | description: "", |
| #76 | status: "backlog", |
| #77 | priority: newPriority, |
| #78 | type: newType, |
| #79 | assigneeId: null, |
| #80 | sprintId: null, |
| #81 | labels: [], |
| #82 | dueDate: null, |
| #83 | estimate: null, |
| #84 | parentId: null, |
| #85 | createdById: users[0]?.id || "u1", |
| #86 | }); |
| #87 | setNewTitle(""); |
| #88 | setNewPriority("none"); |
| #89 | setNewType("task"); |
| #90 | setShowCreate(false); |
| #91 | }; |
| #92 | |
| #93 | return ( |
| #94 | <div className="p-6 max-w-5xl mx-auto"> |
| #95 | <div className="page-header"> |
| #96 | <h1 className="page-title">Backlog</h1> |
| #97 | <button onClick={() => setShowCreate(true)} className="btn-primary"> |
| #98 | <Plus size={16} /> |
| #99 | Add Issue |
| #100 | </button> |
| #101 | </div> |
| #102 | |
| #103 | <div className="grid lg:grid-cols-2 gap-6"> |
| #104 | <div> |
| #105 | <div className="flex items-center gap-2 mb-3"> |
| #106 | <ListTodo size={16} className="text-zinc-500" /> |
| #107 | <h2 className="text-sm font-semibold text-zinc-300"> |
| #108 | Backlog |
| #109 | </h2> |
| #110 | <span className="text-2xs text-zinc-500 bg-surface-3 px-1.5 py-0.5 rounded-full"> |
| #111 | {backlogIssues.length} |
| #112 | </span> |
| #113 | </div> |
| #114 | |
| #115 | <div className="space-y-1"> |
| #116 | {backlogIssues.map((issue) => ( |
| #117 | <BacklogItem |
| #118 | key={issue.id} |
| #119 | issue={issue} |
| #120 | onClick={() => setCurrentIssue(issue)} |
| #121 | onDragStart={(e) => { |
| #122 | e.dataTransfer.setData("issueId", issue.id); |
| #123 | }} |
| #124 | /> |
| #125 | ))} |
| #126 | |
| #127 | {backlogIssues.length === 0 && ( |
| #128 | <div className="text-center py-8 text-sm text-zinc-600"> |
| #129 | No issues in backlog |
| #130 | </div> |
| #131 | )} |
| #132 | </div> |
| #133 | </div> |
| #134 | |
| #135 | <div className="space-y-4"> |
| #136 | {projectSprints.map((sprint) => { |
| #137 | const sprintIssues = projectIssues.filter( |
| #138 | (i) => i.sprintId === sprint.id |
| #139 | ); |
| #140 | const isExpanded = |
| #141 | expandedSprint === sprint.id || sprint.status === "active"; |
| #142 | |
| #143 | return ( |
| #144 | <div |
| #145 | key={sprint.id} |
| #146 | className={clsx( |
| #147 | "rounded-xl border transition-colors", |
| #148 | dragOverSprint === sprint.id |
| #149 | ? "border-accent bg-accent/5" |
| #150 | : "border-border" |
| #151 | )} |
| #152 | onDragOver={(e) => { |
| #153 | e.preventDefault(); |
| #154 | setDragOverSprint(sprint.id); |
| #155 | }} |
| #156 | onDragLeave={() => setDragOverSprint(null)} |
| #157 | onDrop={(e) => { |
| #158 | e.preventDefault(); |
| #159 | const issueId = e.dataTransfer.getData("issueId"); |
| #160 | if (issueId) { |
| #161 | addIssueToSprint(issueId, sprint.id); |
| #162 | } |
| #163 | setDragOverSprint(null); |
| #164 | }} |
| #165 | > |
| #166 | <div |
| #167 | className="flex items-center justify-between px-4 py-3 cursor-pointer" |
| #168 | onClick={() => |
| #169 | setExpandedSprint(isExpanded ? null : sprint.id) |
| #170 | } |
| #171 | > |
| #172 | <div className="flex items-center gap-2"> |
| #173 | {isExpanded ? ( |
| #174 | <ChevronDown size={14} className="text-zinc-500" /> |
| #175 | ) : ( |
| #176 | <ChevronRight size={14} className="text-zinc-500" /> |
| #177 | )} |
| #178 | <span className="text-sm font-medium text-zinc-200"> |
| #179 | {sprint.name} |
| #180 | </span> |
| #181 | <span |
| #182 | className={clsx( |
| #183 | "badge text-2xs", |
| #184 | sprint.status === "active" |
| #185 | ? "bg-emerald-500/10 text-emerald-400" |
| #186 | : sprint.status === "completed" |
| #187 | ? "bg-blue-500/10 text-blue-400" |
| #188 | : "bg-zinc-500/10 text-zinc-400" |
| #189 | )} |
| #190 | > |
| #191 | {sprint.status} |
| #192 | </span> |
| #193 | </div> |
| #194 | <span className="text-2xs text-zinc-500"> |
| #195 | {sprintIssues.length} issues |
| #196 | </span> |
| #197 | </div> |
| #198 | |
| #199 | <AnimatePresence> |
| #200 | {isExpanded && ( |
| #201 | <motion.div |
| #202 | initial={{ height: 0, opacity: 0 }} |
| #203 | animate={{ height: "auto", opacity: 1 }} |
| #204 | exit={{ height: 0, opacity: 0 }} |
| #205 | className="overflow-hidden" |
| #206 | > |
| #207 | <div className="px-2 pb-2 space-y-1"> |
| #208 | {sprintIssues.map((issue) => ( |
| #209 | <BacklogItem |
| #210 | key={issue.id} |
| #211 | issue={issue} |
| #212 | onClick={() => setCurrentIssue(issue)} |
| #213 | /> |
| #214 | ))} |
| #215 | {sprintIssues.length === 0 && ( |
| #216 | <p className="text-sm text-zinc-600 text-center py-4"> |
| #217 | Drag issues here to add to sprint |
| #218 | </p> |
| #219 | )} |
| #220 | </div> |
| #221 | </motion.div> |
| #222 | )} |
| #223 | </AnimatePresence> |
| #224 | </div> |
| #225 | ); |
| #226 | })} |
| #227 | |
| #228 | {projectSprints.length === 0 && ( |
| #229 | <div className="text-center py-8 text-sm text-zinc-600"> |
| #230 | No sprints created yet. Create a sprint to start planning. |
| #231 | </div> |
| #232 | )} |
| #233 | </div> |
| #234 | </div> |
| #235 | |
| #236 | <Modal |
| #237 | open={showCreate} |
| #238 | onClose={() => setShowCreate(false)} |
| #239 | title="Add Issue to Backlog" |
| #240 | > |
| #241 | <div className="space-y-4"> |
| #242 | <div> |
| #243 | <label className="block text-2xs font-medium text-zinc-400 uppercase tracking-wider mb-1.5"> |
| #244 | Title |
| #245 | </label> |
| #246 | <input |
| #247 | value={newTitle} |
| #248 | onChange={(e) => setNewTitle(e.target.value)} |
| #249 | placeholder="Issue title" |
| #250 | className="input" |
| #251 | autoFocus |
| #252 | /> |
| #253 | </div> |
| #254 | |
| #255 | <div className="grid grid-cols-2 gap-3"> |
| #256 | <div> |
| #257 | <label className="block text-2xs font-medium text-zinc-400 uppercase tracking-wider mb-1.5"> |
| #258 | Type |
| #259 | </label> |
| #260 | <Select |
| #261 | value={newType} |
| #262 | onChange={(v) => setNewType(v as typeof newType)} |
| #263 | options={[ |
| #264 | { value: "task", label: "Task" }, |
| #265 | { value: "bug", label: "Bug" }, |
| #266 | { value: "feature", label: "Feature" }, |
| #267 | ]} |
| #268 | /> |
| #269 | </div> |
| #270 | <div> |
| #271 | <label className="block text-2xs font-medium text-zinc-400 uppercase tracking-wider mb-1.5"> |
| #272 | Priority |
| #273 | </label> |
| #274 | <Select |
| #275 | value={newPriority} |
| #276 | onChange={(v) => setNewPriority(v as Priority)} |
| #277 | options={[ |
| #278 | { value: "none", label: "None" }, |
| #279 | { value: "low", label: "Low" }, |
| #280 | { value: "medium", label: "Medium" }, |
| #281 | { value: "high", label: "High" }, |
| #282 | { value: "urgent", label: "Urgent" }, |
| #283 | ]} |
| #284 | /> |
| #285 | </div> |
| #286 | </div> |
| #287 | |
| #288 | <div className="flex justify-end gap-2 pt-2"> |
| #289 | <button |
| #290 | onClick={() => setShowCreate(false)} |
| #291 | className="btn-secondary" |
| #292 | > |
| #293 | Cancel |
| #294 | </button> |
| #295 | <button |
| #296 | onClick={handleCreate} |
| #297 | disabled={!newTitle.trim()} |
| #298 | className="btn-primary" |
| #299 | > |
| #300 | Add Issue |
| #301 | </button> |
| #302 | </div> |
| #303 | </div> |
| #304 | </Modal> |
| #305 | </div> |
| #306 | ); |
| #307 | } |
| #308 | |
| #309 | function BacklogItem({ |
| #310 | issue, |
| #311 | onClick, |
| #312 | onDragStart, |
| #313 | }: { |
| #314 | issue: Issue; |
| #315 | onClick: () => void; |
| #316 | onDragStart?: (e: React.DragEvent) => void; |
| #317 | }) { |
| #318 | const { users } = useStore(); |
| #319 | const assignee = issue.assigneeId |
| #320 | ? users.find((u) => u.id === issue.assigneeId) |
| #321 | : null; |
| #322 | |
| #323 | return ( |
| #324 | <div |
| #325 | draggable |
| #326 | onDragStart={onDragStart} |
| #327 | onClick={onClick} |
| #328 | className="flex items-center gap-3 py-2 px-3 rounded-lg hover:bg-surface-3 transition-colors cursor-pointer group" |
| #329 | > |
| #330 | <GripVertical |
| #331 | size={12} |
| #332 | className="text-zinc-700 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" |
| #333 | /> |
| #334 | <IssueTypeBadge type={issue.type} /> |
| #335 | <span className="text-2xs font-mono text-zinc-500 shrink-0"> |
| #336 | {issue.identifier} |
| #337 | </span> |
| #338 | <span className="text-sm text-zinc-300 truncate flex-1"> |
| #339 | {issue.title} |
| #340 | </span> |
| #341 | <div className="flex items-center gap-2 shrink-0"> |
| #342 | {issue.priority !== "none" && ( |
| #343 | <PriorityBadge priority={issue.priority} /> |
| #344 | )} |
| #345 | {issue.dueDate && ( |
| #346 | <span className="text-2xs text-zinc-500 flex items-center gap-1"> |
| #347 | <Calendar size={10} /> |
| #348 | {format(new Date(issue.dueDate), "MMM d")} |
| #349 | </span> |
| #350 | )} |
| #351 | {assignee && ( |
| #352 | <Tooltip content={assignee.name}> |
| #353 | <Avatar name={assignee.name} size="xs" /> |
| #354 | </Tooltip> |
| #355 | )} |
| #356 | </div> |
| #357 | </div> |
| #358 | ); |
| #359 | } |
| #360 |