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