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, useRef, useEffect } from "react"; |
| #2 | import { motion, AnimatePresence } from "framer-motion"; |
| #3 | import { |
| #4 | X, |
| #5 | Calendar, |
| #6 | Trash2, |
| #7 | Send, |
| #8 | MessageSquare, |
| #9 | Activity, |
| #10 | } from "lucide-react"; |
| #11 | import clsx from "clsx"; |
| #12 | import { format, formatDistanceToNow } from "date-fns"; |
| #13 | import { useStore } from "../../application/stores"; |
| #14 | import type { IssueStatus, Priority, IssueType } from "../../domain/value-objects"; |
| #15 | import { Avatar, IssueTypeBadge, Select } from "../ui"; |
| #16 | |
| #17 | export function IssueDetail() { |
| #18 | const { |
| #19 | currentIssue, |
| #20 | setCurrentIssue, |
| #21 | updateIssue, |
| #22 | deleteIssue, |
| #23 | users, |
| #24 | comments, |
| #25 | addComment, |
| #26 | activities, |
| #27 | sprints, |
| #28 | currentProject, |
| #29 | } = useStore(); |
| #30 | |
| #31 | const [activeTab, setActiveTab] = useState<"comments" | "activity">( |
| #32 | "comments" |
| #33 | ); |
| #34 | const [commentText, setCommentText] = useState(""); |
| #35 | const [editingTitle, setEditingTitle] = useState(false); |
| #36 | const [titleValue, setTitleValue] = useState(""); |
| #37 | const [editingDesc, setEditingDesc] = useState(false); |
| #38 | const [descValue, setDescValue] = useState(""); |
| #39 | const titleRef = useRef<HTMLInputElement>(null); |
| #40 | const descRef = useRef<HTMLTextAreaElement>(null); |
| #41 | |
| #42 | useEffect(() => { |
| #43 | if (currentIssue) { |
| #44 | setTitleValue(currentIssue.title); |
| #45 | setDescValue(currentIssue.description); |
| #46 | setEditingTitle(false); |
| #47 | setEditingDesc(false); |
| #48 | } |
| #49 | }, [currentIssue?.id]); |
| #50 | |
| #51 | if (!currentIssue) return null; |
| #52 | |
| #53 | const issueComments = comments |
| #54 | .filter((c) => c.issueId === currentIssue.id) |
| #55 | .sort( |
| #56 | (a, b) => |
| #57 | new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() |
| #58 | ); |
| #59 | |
| #60 | const issueActivities = activities |
| #61 | .filter((a) => a.issueId === currentIssue.id) |
| #62 | .sort( |
| #63 | (a, b) => |
| #64 | new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() |
| #65 | ); |
| #66 | |
| #67 | const projectSprints = sprints.filter( |
| #68 | (s) => s.projectId === currentProject?.id |
| #69 | ); |
| #70 | |
| #71 | const handleAddComment = () => { |
| #72 | if (!commentText.trim()) return; |
| #73 | addComment(currentIssue.id, commentText); |
| #74 | setCommentText(""); |
| #75 | }; |
| #76 | |
| #77 | const handleSaveTitle = () => { |
| #78 | if (titleValue.trim() && titleValue !== currentIssue.title) { |
| #79 | updateIssue(currentIssue.id, { title: titleValue }); |
| #80 | } |
| #81 | setEditingTitle(false); |
| #82 | }; |
| #83 | |
| #84 | const handleSaveDesc = () => { |
| #85 | if (descValue !== currentIssue.description) { |
| #86 | updateIssue(currentIssue.id, { description: descValue }); |
| #87 | } |
| #88 | setEditingDesc(false); |
| #89 | }; |
| #90 | |
| #91 | const handleDelete = () => { |
| #92 | if (confirm("Delete this issue? This cannot be undone.")) { |
| #93 | deleteIssue(currentIssue.id); |
| #94 | setCurrentIssue(null); |
| #95 | } |
| #96 | }; |
| #97 | |
| #98 | const activityLabels: Record<string, string> = { |
| #99 | status_change: "changed status", |
| #100 | assignment: "updated assignee", |
| #101 | priority_change: "changed priority", |
| #102 | comment: "commented", |
| #103 | created: "created this issue", |
| #104 | sprint_change: "moved sprint", |
| #105 | }; |
| #106 | |
| #107 | return ( |
| #108 | <AnimatePresence> |
| #109 | <motion.div |
| #110 | initial={{ opacity: 0, x: 20 }} |
| #111 | animate={{ opacity: 1, x: 0 }} |
| #112 | exit={{ opacity: 0, x: 20 }} |
| #113 | transition={{ duration: 0.15 }} |
| #114 | className="fixed inset-y-0 right-0 w-full max-w-xl z-40 flex" |
| #115 | > |
| #116 | <div |
| #117 | className="absolute inset-0 bg-black/40" |
| #118 | onClick={() => setCurrentIssue(null)} |
| #119 | /> |
| #120 | |
| #121 | <div className="relative ml-auto w-full max-w-xl bg-surface-1 border-l border-border flex flex-col shadow-2xl shadow-black/50"> |
| #122 | <div className="flex items-center justify-between px-5 py-3 border-b border-border shrink-0"> |
| #123 | <div className="flex items-center gap-2"> |
| #124 | <span className="text-2xs font-mono text-zinc-500"> |
| #125 | {currentIssue.identifier} |
| #126 | </span> |
| #127 | <IssueTypeBadge type={currentIssue.type} /> |
| #128 | </div> |
| #129 | <div className="flex items-center gap-1"> |
| #130 | <button |
| #131 | onClick={handleDelete} |
| #132 | className="p-1.5 rounded text-zinc-500 hover:text-red-400 hover:bg-red-500/10 transition-colors" |
| #133 | > |
| #134 | <Trash2 size={14} /> |
| #135 | </button> |
| #136 | <button |
| #137 | onClick={() => setCurrentIssue(null)} |
| #138 | className="p-1.5 rounded text-zinc-500 hover:text-zinc-300 hover:bg-surface-3 transition-colors" |
| #139 | > |
| #140 | <X size={14} /> |
| #141 | </button> |
| #142 | </div> |
| #143 | </div> |
| #144 | |
| #145 | <div className="flex-1 overflow-y-auto"> |
| #146 | <div className="px-5 pt-5 pb-3"> |
| #147 | {editingTitle ? ( |
| #148 | <input |
| #149 | ref={titleRef} |
| #150 | value={titleValue} |
| #151 | onChange={(e) => setTitleValue(e.target.value)} |
| #152 | onBlur={handleSaveTitle} |
| #153 | onKeyDown={(e) => { |
| #154 | if (e.key === "Enter") handleSaveTitle(); |
| #155 | if (e.key === "Escape") { |
| #156 | setTitleValue(currentIssue.title); |
| #157 | setEditingTitle(false); |
| #158 | } |
| #159 | }} |
| #160 | className="w-full text-lg font-semibold text-zinc-100 bg-transparent outline-none border-b border-accent pb-1" |
| #161 | autoFocus |
| #162 | /> |
| #163 | ) : ( |
| #164 | <h2 |
| #165 | onClick={() => setEditingTitle(true)} |
| #166 | className="text-lg font-semibold text-zinc-100 cursor-text hover:text-zinc-50 transition-colors" |
| #167 | > |
| #168 | {currentIssue.title} |
| #169 | </h2> |
| #170 | )} |
| #171 | </div> |
| #172 | |
| #173 | <div className="px-5 pb-4 space-y-3"> |
| #174 | <div className="flex items-center gap-3"> |
| #175 | <span className="w-24 text-2xs font-medium text-zinc-500 uppercase tracking-wider"> |
| #176 | Status |
| #177 | </span> |
| #178 | <Select |
| #179 | value={currentIssue.status} |
| #180 | onChange={(v) => |
| #181 | updateIssue(currentIssue.id, { |
| #182 | status: v as IssueStatus, |
| #183 | }) |
| #184 | } |
| #185 | options={[ |
| #186 | { value: "backlog", label: "Backlog" }, |
| #187 | { value: "todo", label: "Todo" }, |
| #188 | { value: "in-progress", label: "In Progress" }, |
| #189 | { value: "in-review", label: "In Review" }, |
| #190 | { value: "done", label: "Done" }, |
| #191 | { value: "cancelled", label: "Cancelled" }, |
| #192 | ]} |
| #193 | className="w-40 text-xs py-1.5" |
| #194 | /> |
| #195 | </div> |
| #196 | |
| #197 | <div className="flex items-center gap-3"> |
| #198 | <span className="w-24 text-2xs font-medium text-zinc-500 uppercase tracking-wider"> |
| #199 | Priority |
| #200 | </span> |
| #201 | <Select |
| #202 | value={currentIssue.priority} |
| #203 | onChange={(v) => |
| #204 | updateIssue(currentIssue.id, { |
| #205 | priority: v as Priority, |
| #206 | }) |
| #207 | } |
| #208 | options={[ |
| #209 | { value: "none", label: "None" }, |
| #210 | { value: "low", label: "Low" }, |
| #211 | { value: "medium", label: "Medium" }, |
| #212 | { value: "high", label: "High" }, |
| #213 | { value: "urgent", label: "Urgent" }, |
| #214 | ]} |
| #215 | className="w-40 text-xs py-1.5" |
| #216 | /> |
| #217 | </div> |
| #218 | |
| #219 | <div className="flex items-center gap-3"> |
| #220 | <span className="w-24 text-2xs font-medium text-zinc-500 uppercase tracking-wider"> |
| #221 | Assignee |
| #222 | </span> |
| #223 | <Select |
| #224 | value={currentIssue.assigneeId || ""} |
| #225 | onChange={(v) => |
| #226 | updateIssue(currentIssue.id, { |
| #227 | assigneeId: v || null, |
| #228 | }) |
| #229 | } |
| #230 | options={[ |
| #231 | { value: "", label: "Unassigned" }, |
| #232 | ...users.map((u) => ({ value: u.id, label: u.name })), |
| #233 | ]} |
| #234 | className="w-40 text-xs py-1.5" |
| #235 | /> |
| #236 | </div> |
| #237 | |
| #238 | <div className="flex items-center gap-3"> |
| #239 | <span className="w-24 text-2xs font-medium text-zinc-500 uppercase tracking-wider"> |
| #240 | Type |
| #241 | </span> |
| #242 | <Select |
| #243 | value={currentIssue.type} |
| #244 | onChange={(v) => |
| #245 | updateIssue(currentIssue.id, { |
| #246 | type: v as IssueType, |
| #247 | }) |
| #248 | } |
| #249 | options={[ |
| #250 | { value: "task", label: "Task" }, |
| #251 | { value: "bug", label: "Bug" }, |
| #252 | { value: "feature", label: "Feature" }, |
| #253 | ]} |
| #254 | className="w-40 text-xs py-1.5" |
| #255 | /> |
| #256 | </div> |
| #257 | |
| #258 | <div className="flex items-center gap-3"> |
| #259 | <span className="w-24 text-2xs font-medium text-zinc-500 uppercase tracking-wider"> |
| #260 | Sprint |
| #261 | </span> |
| #262 | <Select |
| #263 | value={currentIssue.sprintId || ""} |
| #264 | onChange={(v) => |
| #265 | updateIssue(currentIssue.id, { |
| #266 | sprintId: v || null, |
| #267 | }) |
| #268 | } |
| #269 | options={[ |
| #270 | { value: "", label: "No sprint" }, |
| #271 | ...projectSprints.map((s) => ({ |
| #272 | value: s.id, |
| #273 | label: s.name, |
| #274 | })), |
| #275 | ]} |
| #276 | className="w-40 text-xs py-1.5" |
| #277 | /> |
| #278 | </div> |
| #279 | |
| #280 | <div className="flex items-center gap-3"> |
| #281 | <span className="w-24 text-2xs font-medium text-zinc-500 uppercase tracking-wider"> |
| #282 | Due Date |
| #283 | </span> |
| #284 | <input |
| #285 | type="date" |
| #286 | value={ |
| #287 | currentIssue.dueDate |
| #288 | ? format(new Date(currentIssue.dueDate), "yyyy-MM-dd") |
| #289 | : "" |
| #290 | } |
| #291 | onChange={(e) => |
| #292 | updateIssue(currentIssue.id, { |
| #293 | dueDate: e.target.value |
| #294 | ? new Date(e.target.value).toISOString() |
| #295 | : null, |
| #296 | }) |
| #297 | } |
| #298 | className="input w-40 text-xs py-1.5" |
| #299 | /> |
| #300 | </div> |
| #301 | |
| #302 | {currentProject?.labels && currentProject.labels.length > 0 && ( |
| #303 | <div className="flex items-start gap-3"> |
| #304 | <span className="w-24 text-2xs font-medium text-zinc-500 uppercase tracking-wider pt-1"> |
| #305 | Labels |
| #306 | </span> |
| #307 | <div className="flex flex-wrap gap-1.5"> |
| #308 | {currentProject.labels.map((label) => { |
| #309 | const isActive = currentIssue.labels.includes(label.id); |
| #310 | return ( |
| #311 | <button |
| #312 | key={label.id} |
| #313 | onClick={() => { |
| #314 | const newLabels = isActive |
| #315 | ? currentIssue.labels.filter( |
| #316 | (l) => l !== label.id |
| #317 | ) |
| #318 | : [...currentIssue.labels, label.id]; |
| #319 | updateIssue(currentIssue.id, { |
| #320 | labels: newLabels, |
| #321 | }); |
| #322 | }} |
| #323 | className={clsx( |
| #324 | "badge text-2xs transition-colors", |
| #325 | isActive |
| #326 | ? "ring-1" |
| #327 | : "opacity-50 hover:opacity-80" |
| #328 | )} |
| #329 | style={{ |
| #330 | backgroundColor: `${label.color}20`, |
| #331 | color: label.color, |
| #332 | ringColor: isActive ? label.color : undefined, |
| #333 | }} |
| #334 | > |
| #335 | {label.name} |
| #336 | </button> |
| #337 | ); |
| #338 | })} |
| #339 | </div> |
| #340 | </div> |
| #341 | )} |
| #342 | </div> |
| #343 | |
| #344 | <div className="px-5 pb-4"> |
| #345 | <div className="flex items-center gap-2 mb-2"> |
| #346 | <span className="text-2xs font-medium text-zinc-500 uppercase tracking-wider"> |
| #347 | Description |
| #348 | </span> |
| #349 | </div> |
| #350 | {editingDesc ? ( |
| #351 | <textarea |
| #352 | ref={descRef} |
| #353 | value={descValue} |
| #354 | onChange={(e) => setDescValue(e.target.value)} |
| #355 | onBlur={handleSaveDesc} |
| #356 | className="textarea min-h-[150px]" |
| #357 | autoFocus |
| #358 | /> |
| #359 | ) : ( |
| #360 | <div |
| #361 | onClick={() => setEditingDesc(true)} |
| #362 | className="text-sm text-zinc-400 cursor-text hover:text-zinc-300 transition-colors min-h-[60px] whitespace-pre-wrap" |
| #363 | > |
| #364 | {currentIssue.description || "Add a description..."} |
| #365 | </div> |
| #366 | )} |
| #367 | </div> |
| #368 | |
| #369 | <div className="px-5 border-t border-border"> |
| #370 | <div className="flex gap-4 pt-3"> |
| #371 | <button |
| #372 | onClick={() => setActiveTab("comments")} |
| #373 | className={clsx( |
| #374 | "flex items-center gap-1.5 text-sm pb-2 border-b-2 transition-colors", |
| #375 | activeTab === "comments" |
| #376 | ? "border-accent text-zinc-100" |
| #377 | : "border-transparent text-zinc-500 hover:text-zinc-300" |
| #378 | )} |
| #379 | > |
| #380 | <MessageSquare size={14} /> |
| #381 | Comments |
| #382 | <span className="text-2xs text-zinc-500"> |
| #383 | {issueComments.length} |
| #384 | </span> |
| #385 | </button> |
| #386 | <button |
| #387 | onClick={() => setActiveTab("activity")} |
| #388 | className={clsx( |
| #389 | "flex items-center gap-1.5 text-sm pb-2 border-b-2 transition-colors", |
| #390 | activeTab === "activity" |
| #391 | ? "border-accent text-zinc-100" |
| #392 | : "border-transparent text-zinc-500 hover:text-zinc-300" |
| #393 | )} |
| #394 | > |
| #395 | <Activity size={14} /> |
| #396 | Activity |
| #397 | </button> |
| #398 | </div> |
| #399 | </div> |
| #400 | |
| #401 | <div className="px-5 py-4"> |
| #402 | {activeTab === "comments" ? ( |
| #403 | <div className="space-y-4"> |
| #404 | {issueComments.map((comment) => { |
| #405 | const author = users.find( |
| #406 | (u) => u.id === comment.userId |
| #407 | ); |
| #408 | return ( |
| #409 | <div key={comment.id} className="flex gap-3"> |
| #410 | <Avatar |
| #411 | name={author?.name || ""} |
| #412 | size="sm" |
| #413 | /> |
| #414 | <div className="flex-1 min-w-0"> |
| #415 | <div className="flex items-center gap-2"> |
| #416 | <span className="text-sm font-medium text-zinc-200"> |
| #417 | {author?.name} |
| #418 | </span> |
| #419 | <span className="text-2xs text-zinc-600"> |
| #420 | {formatDistanceToNow( |
| #421 | new Date(comment.createdAt), |
| #422 | { addSuffix: true } |
| #423 | )} |
| #424 | </span> |
| #425 | </div> |
| #426 | <p className="text-sm text-zinc-400 mt-1 whitespace-pre-wrap"> |
| #427 | {comment.content} |
| #428 | </p> |
| #429 | </div> |
| #430 | </div> |
| #431 | ); |
| #432 | })} |
| #433 | |
| #434 | {issueComments.length === 0 && ( |
| #435 | <p className="text-sm text-zinc-600 text-center py-4"> |
| #436 | No comments yet |
| #437 | </p> |
| #438 | )} |
| #439 | </div> |
| #440 | ) : ( |
| #441 | <div className="space-y-3"> |
| #442 | {issueActivities.map((act) => { |
| #443 | const user = users.find((u) => u.id === act.userId); |
| #444 | return ( |
| #445 | <div key={act.id} className="flex gap-3"> |
| #446 | <Avatar |
| #447 | name={user?.name || ""} |
| #448 | size="xs" |
| #449 | /> |
| #450 | <div> |
| #451 | <p className="text-sm text-zinc-400"> |
| #452 | <span className="text-zinc-300 font-medium"> |
| #453 | {user?.name} |
| #454 | </span>{" "} |
| #455 | {activityLabels[act.type] || act.type} |
| #456 | {act.oldValue && act.newValue && ( |
| #457 | <> |
| #458 | {" "} |
| #459 | from{" "} |
| #460 | <span className="text-zinc-300"> |
| #461 | {act.oldValue} |
| #462 | </span>{" "} |
| #463 | to{" "} |
| #464 | <span className="text-zinc-300"> |
| #465 | {act.newValue} |
| #466 | </span> |
| #467 | </> |
| #468 | )} |
| #469 | </p> |
| #470 | <p className="text-2xs text-zinc-600 mt-0.5"> |
| #471 | {formatDistanceToNow(new Date(act.createdAt), { |
| #472 | addSuffix: true, |
| #473 | })} |
| #474 | </p> |
| #475 | </div> |
| #476 | </div> |
| #477 | ); |
| #478 | })} |
| #479 | |
| #480 | {issueActivities.length === 0 && ( |
| #481 | <p className="text-sm text-zinc-600 text-center py-4"> |
| #482 | No activity yet |
| #483 | </p> |
| #484 | )} |
| #485 | </div> |
| #486 | )} |
| #487 | </div> |
| #488 | </div> |
| #489 | |
| #490 | {activeTab === "comments" && ( |
| #491 | <div className="px-5 py-3 border-t border-border shrink-0"> |
| #492 | <div className="flex gap-2"> |
| #493 | <input |
| #494 | value={commentText} |
| #495 | onChange={(e) => setCommentText(e.target.value)} |
| #496 | onKeyDown={(e) => { |
| #497 | if (e.key === "Enter" && !e.shiftKey) { |
| #498 | e.preventDefault(); |
| #499 | handleAddComment(); |
| #500 | } |
| #501 | }} |
| #502 | placeholder="Add a comment..." |
| #503 | className="input flex-1 text-sm py-2" |
| #504 | /> |
| #505 | <button |
| #506 | onClick={handleAddComment} |
| #507 | disabled={!commentText.trim()} |
| #508 | className="btn-primary px-3 py-2" |
| #509 | > |
| #510 | <Send size={14} /> |
| #511 | </button> |
| #512 | </div> |
| #513 | </div> |
| #514 | )} |
| #515 | </div> |
| #516 | </motion.div> |
| #517 | </AnimatePresence> |
| #518 | ); |
| #519 | } |
| #520 |