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:
Jiayuan Zhang 2026-03-21 14:24:18 +08:00
parent 8fd149231a
commit 44c3cecc8e

View file

@ -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>