refactor(web): polish Issues page UI to match Linear design
- Status: use Lucide circle-variant icons (CircleDashed, Circle, CircleDot, Eye, CircleCheck) instead of colored dots - Priority: use SVG bar indicators (4-bar urgent → 1-bar low) instead of P0/P1 text badges - Board cards: cleaner layout, tighter spacing, comment count icon - List rows: compact 36px height, proper alignment - Toolbar: smaller, denser header with refined toggle Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
931ca53b17
commit
8fd149231a
1 changed files with 123 additions and 90 deletions
|
|
@ -7,12 +7,19 @@ import {
|
|||
List,
|
||||
Plus,
|
||||
Bot,
|
||||
Calendar,
|
||||
Circle,
|
||||
CircleDashed,
|
||||
CircleDot,
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
CircleAlert,
|
||||
Eye,
|
||||
Minus,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
import type { IssueStatus, IssuePriority } from "@multica/types";
|
||||
import {
|
||||
MOCK_ISSUES,
|
||||
STATUS_ORDER,
|
||||
STATUS_CONFIG,
|
||||
PRIORITY_CONFIG,
|
||||
type MockIssue,
|
||||
|
|
@ -20,31 +27,83 @@ import {
|
|||
} from "./_data/mock";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared sub-components
|
||||
// Shared icon components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PriorityBadge({ priority }: { priority: IssuePriority }) {
|
||||
const STATUS_ICONS: Record<IssueStatus, typeof Circle> = {
|
||||
backlog: CircleDashed,
|
||||
todo: Circle,
|
||||
in_progress: CircleDot,
|
||||
in_review: Eye,
|
||||
done: CircleCheck,
|
||||
blocked: CircleAlert,
|
||||
cancelled: CircleX,
|
||||
};
|
||||
|
||||
export function StatusIcon({
|
||||
status,
|
||||
className = "h-4 w-4",
|
||||
}: {
|
||||
status: IssueStatus;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = STATUS_ICONS[status];
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
return <Icon className={`${className} ${cfg.iconColor}`} />;
|
||||
}
|
||||
|
||||
export function PriorityIcon({
|
||||
priority,
|
||||
className = "",
|
||||
}: {
|
||||
priority: IssuePriority;
|
||||
className?: string;
|
||||
}) {
|
||||
const cfg = PRIORITY_CONFIG[priority];
|
||||
if (cfg.bars === 0) {
|
||||
return <Minus className={`h-3.5 w-3.5 text-muted-foreground ${className}`} />;
|
||||
}
|
||||
return (
|
||||
<span className={`shrink-0 text-xs font-medium ${cfg.color}`}>
|
||||
{cfg.shortLabel}
|
||||
</span>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className={`h-3.5 w-3.5 ${cfg.color} ${className}`}
|
||||
fill="currentColor"
|
||||
>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x={1 + i * 4}
|
||||
y={12 - (i + 1) * 3}
|
||||
width="3"
|
||||
height={(i + 1) * 3}
|
||||
rx="0.5"
|
||||
opacity={i < cfg.bars ? 1 : 0.2}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AssigneeAvatar({ assignee }: { assignee: MockAssignee | null }) {
|
||||
function AssigneeAvatar({
|
||||
assignee,
|
||||
size = "sm",
|
||||
}: {
|
||||
assignee: MockAssignee | null;
|
||||
size?: "sm" | "md";
|
||||
}) {
|
||||
if (!assignee) return null;
|
||||
const sizeClass = size === "sm" ? "h-5 w-5 text-[10px]" : "h-6 w-6 text-xs";
|
||||
return (
|
||||
<div
|
||||
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-medium ${
|
||||
className={`flex shrink-0 items-center justify-center rounded-full font-medium ${sizeClass} ${
|
||||
assignee.type === "agent"
|
||||
? "bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300"
|
||||
? "bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
title={assignee.name}
|
||||
>
|
||||
{assignee.type === "agent" ? (
|
||||
<Bot className="h-3 w-3" />
|
||||
<Bot className={size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5"} />
|
||||
) : (
|
||||
assignee.avatar.charAt(0)
|
||||
)}
|
||||
|
|
@ -52,15 +111,11 @@ function AssigneeAvatar({ assignee }: { assignee: MockAssignee | null }) {
|
|||
);
|
||||
}
|
||||
|
||||
function StatusDot({ status }: { status: IssueStatus }) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
return <span className={`h-2 w-2 shrink-0 rounded-full ${cfg.dotColor}`} />;
|
||||
}
|
||||
|
||||
function formatDueDate(date: string | null): string | null {
|
||||
if (!date) return null;
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
function formatDate(date: string): string {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -68,35 +123,29 @@ function formatDueDate(date: string | null): string | null {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BoardCard({ issue }: { issue: MockIssue }) {
|
||||
const due = formatDueDate(issue.dueDate);
|
||||
const isOverdue =
|
||||
issue.dueDate && new Date(issue.dueDate) < new Date() && issue.status !== "done";
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${issue.id}`}
|
||||
className="block rounded-lg border bg-background p-3 shadow-sm transition-shadow hover:shadow-md"
|
||||
className="block rounded-lg border bg-background p-3 transition-colors hover:border-border/80 hover:bg-accent/30"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<PriorityBadge priority={issue.priority} />
|
||||
<span className="text-xs text-muted-foreground">{issue.key}</span>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
<span>{issue.key}</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-sm font-medium leading-snug">{issue.title}</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<AssigneeAvatar assignee={issue.assignee} />
|
||||
{due && (
|
||||
<span
|
||||
className={`flex items-center gap-1 text-xs ${
|
||||
isOverdue ? "text-red-500" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Calendar className="h-3 w-3" />
|
||||
{due}
|
||||
</span>
|
||||
)}
|
||||
{issue.comments.length > 0 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{issue.comments.length} 💬
|
||||
<p className="mt-1.5 text-[13px] leading-snug">{issue.title}</p>
|
||||
<div className="mt-2.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AssigneeAvatar assignee={issue.assignee} />
|
||||
{issue.comments.length > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-xs text-muted-foreground">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{issue.comments.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{issue.dueDate && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(issue.dueDate)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -114,20 +163,18 @@ function BoardView() {
|
|||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-full gap-4 overflow-x-auto p-4">
|
||||
<div className="flex h-full gap-3 overflow-x-auto p-4">
|
||||
{visibleStatuses.map((status) => {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const issues = MOCK_ISSUES.filter((i) => i.status === status);
|
||||
return (
|
||||
<div key={status} className="flex w-72 shrink-0 flex-col">
|
||||
{/* Column header */}
|
||||
<div className="mb-3 flex items-center gap-2 px-1">
|
||||
<span className={`h-2 w-2 rounded-full ${cfg.dotColor}`} />
|
||||
<span className="text-sm font-semibold">{cfg.label}</span>
|
||||
<div key={status} className="flex w-64 shrink-0 flex-col">
|
||||
<div className="mb-2 flex items-center gap-2 px-1">
|
||||
<StatusIcon status={status} className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">{cfg.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{issues.length}</span>
|
||||
</div>
|
||||
{/* Cards */}
|
||||
<div className="flex-1 space-y-2 overflow-y-auto">
|
||||
<div className="flex-1 space-y-1.5 overflow-y-auto">
|
||||
{issues.map((issue) => (
|
||||
<BoardCard key={issue.id} issue={issue} />
|
||||
))}
|
||||
|
|
@ -144,26 +191,18 @@ function BoardView() {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ListRow({ issue }: { issue: MockIssue }) {
|
||||
const due = formatDueDate(issue.dueDate);
|
||||
const isOverdue =
|
||||
issue.dueDate && new Date(issue.dueDate) < new Date() && issue.status !== "done";
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${issue.id}`}
|
||||
className="flex items-center gap-3 px-4 py-2 text-sm transition-colors hover:bg-accent/50"
|
||||
className="flex h-9 items-center gap-2 px-4 text-[13px] transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<PriorityBadge priority={issue.priority} />
|
||||
<span className="shrink-0 text-xs text-muted-foreground w-16">{issue.key}</span>
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
<span className="w-16 shrink-0 text-xs text-muted-foreground">{issue.key}</span>
|
||||
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
|
||||
<span className="min-w-0 flex-1 truncate">{issue.title}</span>
|
||||
{due && (
|
||||
<span
|
||||
className={`flex shrink-0 items-center gap-1 text-xs ${
|
||||
isOverdue ? "text-red-500" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Calendar className="h-3 w-3" />
|
||||
{due}
|
||||
{issue.dueDate && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatDate(issue.dueDate)}
|
||||
</span>
|
||||
)}
|
||||
<AssigneeAvatar assignee={issue.assignee} />
|
||||
|
|
@ -172,7 +211,7 @@ function ListRow({ issue }: { issue: MockIssue }) {
|
|||
}
|
||||
|
||||
function ListView() {
|
||||
const visibleStatuses: IssueStatus[] = [
|
||||
const groupOrder: IssueStatus[] = [
|
||||
"in_review",
|
||||
"in_progress",
|
||||
"todo",
|
||||
|
|
@ -182,24 +221,20 @@ function ListView() {
|
|||
|
||||
return (
|
||||
<div className="overflow-y-auto">
|
||||
{visibleStatuses.map((status) => {
|
||||
{groupOrder.map((status) => {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const issues = MOCK_ISSUES.filter((i) => i.status === status);
|
||||
if (issues.length === 0) return null;
|
||||
return (
|
||||
<div key={status}>
|
||||
{/* Group header */}
|
||||
<div className="flex items-center gap-2 border-b bg-muted/30 px-4 py-2">
|
||||
<span className={`h-2 w-2 rounded-full ${cfg.dotColor}`} />
|
||||
<span className="text-xs font-semibold">{cfg.label}</span>
|
||||
<div className="flex h-8 items-center gap-2 border-b px-4">
|
||||
<StatusIcon status={status} className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium">{cfg.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{issues.length}</span>
|
||||
</div>
|
||||
{/* Rows */}
|
||||
<div className="divide-y">
|
||||
{issues.map((issue) => (
|
||||
<ListRow key={issue.id} issue={issue} />
|
||||
))}
|
||||
</div>
|
||||
{issues.map((issue) => (
|
||||
<ListRow key={issue.id} issue={issue} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -218,43 +253,41 @@ export default function IssuesPage() {
|
|||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Toolbar */}
|
||||
<div className="flex h-11 shrink-0 items-center justify-between border-b px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-sm font-semibold">All Issues</h1>
|
||||
{/* View toggle */}
|
||||
<div className="flex items-center rounded-md border p-0.5">
|
||||
<div className="ml-2 flex items-center rounded-md border p-0.5">
|
||||
<button
|
||||
onClick={() => setView("board")}
|
||||
className={`flex items-center gap-1.5 rounded-sm px-2 py-1 text-xs transition-colors ${
|
||||
className={`flex items-center gap-1 rounded px-2 py-0.5 text-xs transition-colors ${
|
||||
view === "board"
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Columns3 className="h-3.5 w-3.5" />
|
||||
<Columns3 className="h-3 w-3" />
|
||||
Board
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("list")}
|
||||
className={`flex items-center gap-1.5 rounded-sm px-2 py-1 text-xs transition-colors ${
|
||||
className={`flex items-center gap-1 rounded px-2 py-0.5 text-xs transition-colors ${
|
||||
view === "list"
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<List className="h-3.5 w-3.5" />
|
||||
<List className="h-3 w-3" />
|
||||
List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground">
|
||||
<button className="flex items-center gap-1 rounded-md bg-primary px-2.5 py-1 text-xs text-primary-foreground transition-colors hover:bg-primary/90">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New Issue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{view === "board" ? <BoardView /> : <ListView />}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue