refactor(web): polish Issue detail page to match Linear design
- Properties sidebar: compact rows with icon-based status/priority - Activity timeline: tighter spacing, smaller avatars, cleaner separation between comments and status changes - Breadcrumb: minimal, text-only navigation - Content area: max-width constraint for readability - Typography: 13px body text, refined heading sizes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8fd149231a
commit
44c3cecc8e
1 changed files with 119 additions and 142 deletions
|
|
@ -7,7 +7,6 @@ import {
|
|||
Bot,
|
||||
Calendar,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
|
|
@ -16,6 +15,7 @@ import {
|
|||
PRIORITY_CONFIG,
|
||||
} from "../_data/mock";
|
||||
import type { MockAssignee } from "../_data/mock";
|
||||
import { StatusIcon, PriorityIcon } from "../page";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
|
|
@ -32,7 +32,7 @@ function timeAgo(dateStr: string): string {
|
|||
}
|
||||
|
||||
function formatDate(date: string | null): string {
|
||||
if (!date) return "—";
|
||||
if (!date) return "None";
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
|
|
@ -40,16 +40,41 @@ function formatDate(date: string | null): string {
|
|||
});
|
||||
}
|
||||
|
||||
function ActorBadge({ actor }: { actor: MockAssignee }) {
|
||||
function ActorAvatar({ actor, size = "sm" }: { actor: MockAssignee; size?: "sm" | "md" }) {
|
||||
const sizeClass = size === "sm" ? "h-5 w-5 text-[10px]" : "h-6 w-6 text-xs";
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-sm font-medium ${
|
||||
actor.type === "agent" ? "text-purple-600 dark:text-purple-400" : ""
|
||||
<div
|
||||
className={`flex shrink-0 items-center justify-center rounded-full font-medium ${sizeClass} ${
|
||||
actor.type === "agent"
|
||||
? "bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{actor.type === "agent" && <Bot className="h-3 w-3" />}
|
||||
{actor.name}
|
||||
</span>
|
||||
{actor.type === "agent" ? (
|
||||
<Bot className={size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5"} />
|
||||
) : (
|
||||
actor.avatar.charAt(0)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Properties Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PropertyRow({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<div className="flex items-center gap-1.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -67,7 +92,7 @@ export default function IssueDetailPage({
|
|||
|
||||
if (!issue) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Issue not found
|
||||
</div>
|
||||
);
|
||||
|
|
@ -78,18 +103,17 @@ export default function IssueDetailPage({
|
|||
const isOverdue =
|
||||
issue.dueDate && new Date(issue.dueDate) < new Date() && issue.status !== "done";
|
||||
|
||||
// Merge comments + activity into a single timeline sorted by time
|
||||
const timeline = [
|
||||
...issue.activity.map((a) => ({
|
||||
id: a.id,
|
||||
type: "activity" as const,
|
||||
kind: "activity" as const,
|
||||
actor: a.actor,
|
||||
content: a.action,
|
||||
createdAt: a.createdAt,
|
||||
})),
|
||||
...issue.comments.map((c) => ({
|
||||
id: c.id,
|
||||
type: "comment" as const,
|
||||
kind: "comment" as const,
|
||||
actor: c.author,
|
||||
content: c.body,
|
||||
createdAt: c.createdAt,
|
||||
|
|
@ -100,60 +124,54 @@ export default function IssueDetailPage({
|
|||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Left column — content */}
|
||||
{/* ---- Left: Content ---- */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex h-12 items-center gap-2 border-b px-6">
|
||||
{/* Breadcrumb bar */}
|
||||
<div className="flex h-11 items-center gap-1.5 border-b px-6 text-xs">
|
||||
<Link
|
||||
href="/issues"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Issues
|
||||
</Link>
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">{issue.key}</span>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{issue.key}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="max-w-2xl px-10 py-8">
|
||||
{/* Title */}
|
||||
<h1 className="text-xl font-bold leading-tight">{issue.title}</h1>
|
||||
<h1 className="text-lg font-semibold leading-snug">{issue.title}</h1>
|
||||
|
||||
{/* Description */}
|
||||
{issue.description && (
|
||||
<div className="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-foreground/80">
|
||||
<div className="mt-4 whitespace-pre-wrap text-[13px] leading-relaxed text-foreground/80">
|
||||
{issue.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Activity section */}
|
||||
{/* Activity */}
|
||||
<div className="mt-10">
|
||||
<h2 className="text-sm font-semibold">Activity</h2>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Activity
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{timeline.map((entry) =>
|
||||
entry.type === "comment" ? (
|
||||
<div key={entry.id} className="flex gap-3">
|
||||
<div
|
||||
className={`mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-medium ${
|
||||
entry.actor.type === "agent"
|
||||
? "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{entry.actor.type === "agent" ? (
|
||||
<Bot className="h-3 w-3" />
|
||||
) : (
|
||||
entry.actor.avatar.charAt(0)
|
||||
)}
|
||||
</div>
|
||||
entry.kind === "comment" ? (
|
||||
<div key={entry.id} className="flex gap-2.5">
|
||||
<ActorAvatar actor={entry.actor} size="md" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<ActorBadge actor={entry.actor} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-[13px] font-medium">
|
||||
{entry.actor.name}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{timeAgo(entry.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 whitespace-pre-wrap rounded-lg border px-3 py-2 text-sm text-foreground/80">
|
||||
<div className="mt-1 whitespace-pre-wrap rounded-md border px-3 py-2 text-[13px] leading-relaxed text-foreground/80">
|
||||
{entry.content}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -161,24 +179,28 @@ export default function IssueDetailPage({
|
|||
) : (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-center gap-3 text-xs text-muted-foreground"
|
||||
className="flex items-center gap-2.5 pl-1 text-[12px] text-muted-foreground"
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center">
|
||||
<Circle className="h-1.5 w-1.5 fill-current" />
|
||||
<div className="flex h-6 w-6 items-center justify-center">
|
||||
<span className="h-1 w-1 rounded-full bg-muted-foreground/50" />
|
||||
</div>
|
||||
<ActorBadge actor={entry.actor} />
|
||||
<span className="font-medium text-foreground/70">
|
||||
{entry.actor.name}
|
||||
</span>
|
||||
<span>{entry.content}</span>
|
||||
<span className="ml-auto">{timeAgo(entry.createdAt)}</span>
|
||||
<span className="ml-auto shrink-0">
|
||||
{timeAgo(entry.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Comment input placeholder */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground">
|
||||
<User className="h-3 w-3" />
|
||||
{/* Comment placeholder */}
|
||||
<div className="flex gap-2.5 pt-2">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
<User className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 rounded-lg border px-3 py-2 text-sm text-muted-foreground">
|
||||
<div className="min-w-0 flex-1 cursor-text rounded-md border px-3 py-2 text-[13px] text-muted-foreground">
|
||||
Leave a comment...
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -187,102 +209,57 @@ export default function IssueDetailPage({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column — properties sidebar */}
|
||||
<div className="w-64 shrink-0 overflow-y-auto border-l p-4">
|
||||
<div className="space-y-5">
|
||||
{/* Status */}
|
||||
<div>
|
||||
<div className="mb-1.5 text-xs text-muted-foreground">Status</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${statusCfg.dotColor}`} />
|
||||
<span className={`text-sm font-medium ${statusCfg.color}`}>
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* ---- Right: Properties ---- */}
|
||||
<div className="w-56 shrink-0 overflow-y-auto border-l px-4 py-4">
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Properties
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div>
|
||||
<div className="mb-1.5 text-xs text-muted-foreground">Priority</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-medium ${priorityCfg.color}`}>
|
||||
{priorityCfg.shortLabel}
|
||||
</span>
|
||||
<span className="text-sm">{priorityCfg.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
<PropertyRow label="Status">
|
||||
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
|
||||
<span className={`text-xs font-medium ${statusCfg.iconColor}`}>
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
</PropertyRow>
|
||||
|
||||
{/* Assignee */}
|
||||
<div>
|
||||
<div className="mb-1.5 text-xs text-muted-foreground">Assignee</div>
|
||||
<PropertyRow label="Priority">
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
<span className="text-xs">{priorityCfg.label}</span>
|
||||
</PropertyRow>
|
||||
|
||||
<PropertyRow label="Assignee">
|
||||
{issue.assignee ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-medium ${
|
||||
issue.assignee.type === "agent"
|
||||
? "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{issue.assignee.type === "agent" ? (
|
||||
<Bot className="h-3 w-3" />
|
||||
) : (
|
||||
issue.assignee.avatar.charAt(0)
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm">{issue.assignee.name}</span>
|
||||
{issue.assignee.type === "agent" && (
|
||||
<span className="text-[10px] text-purple-500">Agent</span>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<ActorAvatar actor={issue.assignee} />
|
||||
<span className="text-xs">{issue.assignee.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Unassigned</span>
|
||||
<span className="text-xs text-muted-foreground">Unassigned</span>
|
||||
)}
|
||||
</div>
|
||||
</PropertyRow>
|
||||
|
||||
{/* Due Date */}
|
||||
<div>
|
||||
<div className="mb-1.5 text-xs text-muted-foreground">Due Date</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span
|
||||
className={`text-sm ${isOverdue ? "text-red-500 font-medium" : ""}`}
|
||||
>
|
||||
{formatDate(issue.dueDate)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<PropertyRow label="Due Date">
|
||||
<Calendar className="h-3 w-3 text-muted-foreground" />
|
||||
<span
|
||||
className={`text-xs ${isOverdue ? "font-medium text-red-500" : ""}`}
|
||||
>
|
||||
{formatDate(issue.dueDate)}
|
||||
</span>
|
||||
</PropertyRow>
|
||||
|
||||
{/* Creator */}
|
||||
<div>
|
||||
<div className="mb-1.5 text-xs text-muted-foreground">Created by</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-medium ${
|
||||
issue.creator.type === "agent"
|
||||
? "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{issue.creator.type === "agent" ? (
|
||||
<Bot className="h-3 w-3" />
|
||||
) : (
|
||||
issue.creator.avatar.charAt(0)
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm">{issue.creator.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<PropertyRow label="Created by">
|
||||
<ActorAvatar actor={issue.creator} />
|
||||
<span className="text-xs">{issue.creator.name}</span>
|
||||
</PropertyRow>
|
||||
|
||||
{/* Dates */}
|
||||
<div>
|
||||
<div className="mb-1.5 text-xs text-muted-foreground">Created</div>
|
||||
<span className="text-sm">{formatDate(issue.createdAt)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1.5 text-xs text-muted-foreground">Updated</div>
|
||||
<span className="text-sm">{formatDate(issue.updatedAt)}</span>
|
||||
</div>
|
||||
<PropertyRow label="Created">
|
||||
<span className="text-xs">{formatDate(issue.createdAt)}</span>
|
||||
</PropertyRow>
|
||||
|
||||
<PropertyRow label="Updated">
|
||||
<span className="text-xs">{formatDate(issue.updatedAt)}</span>
|
||||
</PropertyRow>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue