From c88dfc176299c157b6d41896e1c1235762269520 Mon Sep 17 00:00:00 2001 From: null Date: Tue, 19 May 2026 23:11:32 -0500 Subject: [PATCH] fix: (dashboard) total ui rewrite --- frontend/src/app/dashboard/page.tsx | 396 +++--------------- .../dashboard/DashboardEmptyState.tsx | 35 ++ .../dashboard/DashboardInfoBlock.tsx | 40 ++ .../dashboard/DashboardMetricCard.tsx | 51 +++ .../components/dashboard/DashboardSection.tsx | 60 +++ .../dashboard/PendingApprovalsSection.tsx | 80 ++++ .../dashboard/RecentActivitySection.tsx | 78 ++++ .../components/dashboard/SessionsSection.tsx | 93 ++++ frontend/src/components/dashboard/index.ts | 8 + frontend/src/components/dashboard/tokens.ts | 43 ++ 10 files changed, 537 insertions(+), 347 deletions(-) create mode 100644 frontend/src/components/dashboard/DashboardEmptyState.tsx create mode 100644 frontend/src/components/dashboard/DashboardInfoBlock.tsx create mode 100644 frontend/src/components/dashboard/DashboardMetricCard.tsx create mode 100644 frontend/src/components/dashboard/DashboardSection.tsx create mode 100644 frontend/src/components/dashboard/PendingApprovalsSection.tsx create mode 100644 frontend/src/components/dashboard/RecentActivitySection.tsx create mode 100644 frontend/src/components/dashboard/SessionsSection.tsx create mode 100644 frontend/src/components/dashboard/index.ts create mode 100644 frontend/src/components/dashboard/tokens.ts diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 6c4f396..df9f060 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -3,26 +3,24 @@ export const dynamic = "force-dynamic"; import { type KeyboardEvent, type MouseEvent, useMemo } from "react"; -import Link from "next/link"; import { useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { SignedIn, SignedOut, useAuth } from "@/auth/clerk"; -import { - Activity, - ArrowUpRight, - Bot, - Info, - LayoutGrid, - Shield, - Timer, -} from "lucide-react"; +import { Activity, Bot, LayoutGrid, Timer } from "lucide-react"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardShell } from "@/components/templates/DashboardShell"; -import { Markdown } from "@/components/atoms/Markdown"; import { SignedOutPanel } from "@/components/auth/SignedOutPanel"; import { ForgejoIssueMetricCards } from "@/components/git/ForgejoIssueMetricCards"; +import { + DashboardMetricCard, + DashboardInfoBlock, + DashboardEmptyState, + PendingApprovalsSection, + SessionsSection, + RecentActivitySection, +} from "@/components/dashboard"; import { ApiError } from "@/api/mutator"; import { type dashboardMetricsApiV1MetricsDashboardGetResponse, @@ -386,129 +384,6 @@ const toSessionSummaries = ( }); }; -function TopMetricCard({ - title, - value, - secondary, - infoText, - icon, - accent, -}: { - title: string; - value: string; - secondary?: string; - infoText?: string; - icon: React.ReactNode; - accent: "blue" | "green" | "violet" | "emerald"; -}) { - const iconTone = - accent === "blue" - ? "bg-[color:var(--accent-soft)] text-[color:var(--accent)]" - : accent === "green" - ? "bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]" - : accent === "violet" - ? "bg-[color:rgba(251,191,36,0.15)] text-[color:var(--warning)]" - : "bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]"; - - return ( -
-
-
-
-

- {title} -

- {infoText ? ( - - - - ) : null} -
-
-

- {value} -

- {secondary ? ( -

{secondary}

- ) : null} -
-
-
{icon}
-
-
- ); -} - -function InfoBlock({ - title, - badge, - infoText, - rows, -}: { - title: string; - badge?: { text: string; tone: "online" | "offline" | "neutral" }; - infoText?: string; - rows: SummaryRow[]; -}) { - return ( -
-
-
-

{title}

- {infoText ? ( - - - - ) : null} -
- {badge ? ( - - {badge.text} - - ) : null} -
-
- {rows.map((row) => ( -
- {row.label} - - {row.value} - -
- ))} -
-
- ); -} export default function DashboardPage() { const router = useRouter(); @@ -1031,34 +906,34 @@ export default function DashboardPage() { ) : null}
- } - accent="blue" + tone="accent" /> - } - accent="green" + tone="success" /> - } - accent="violet" + tone="warning" /> - } - accent="emerald" + tone="success" />
@@ -1072,222 +947,49 @@ export default function DashboardPage() {
- - + -
-
-
-

- Pending Approvals -

- - Open global approvals - - -
- - {!metrics && metricsQuery.isLoading ? ( -
- Loading pending approvals... -
- ) : !metrics && metricsQuery.error ? ( -
- Pending approvals are temporarily unavailable. -
- ) : hasPendingApprovals ? ( -
-
- {pendingApprovalItems.map((item) => ( - - - - {item.task_title || "Pending approval"} - - - {item.board_name} · {item.confidence}% score - - - - {formatRelativeTimestamp(item.created_at)} - - - ))} -
- {pendingApprovalsTotal > pendingApprovalItems.length ? ( -

- Showing latest {formatCount(pendingApprovalItems.length)}{" "} - of {formatCount(pendingApprovalsTotal)} pending approvals. -

- ) : null} -
- ) : ( -
- No pending approvals across your boards. -
- )} -
+
-
-
-

- Sessions -

- - {formatCount(activeSessions)} - -
-
- {!hasConfiguredGateways ? ( -
- No gateways are configured for any board yet. -
- ) : gatewayStatusesQuery.isLoading ? ( -
- Loading sessions... -
- ) : sessionSummaries.length > 0 ? ( - <> - {gatewayUnavailableCount > 0 ? ( -
- {formatCount(gatewayUnavailableCount)} gateway - {gatewayUnavailableCount === 1 ? "" : "s"}{" "} - unavailable; showing sessions from reachable gateways. -
- ) : null} - {sessionSummaries.map((session) => ( -
-
-
-

- - {session.title} -

-

- {session.subtitle} -

-
-
-

- {session.usage === DASH - ? "Usage unavailable" - : session.usage} -

-

- {session.lastSeenAt - ? formatRelativeTimestamp(session.lastSeenAt) - : "Activity unavailable"} -

-
-
-
- ))} - - ) : gatewayUnavailableCount === gatewayTargets.length ? ( -
- Session data is unavailable for all configured gateways. -
- ) : ( -
- No active sessions detected. -
- )} -
-
- -
-
-

- Recent Activity -

- - Open feed - - -
-
- {recentLogs.length > 0 ? ( - recentLogs.map((event) => { - const eventHref = buildActivityEventHref(event); - return ( -
- handleLogRowClick(interactionEvent, eventHref) - } - onKeyDown={(interactionEvent) => - handleLogRowKeyDown(interactionEvent, eventHref) - } - className="cursor-pointer overflow-hidden rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2 transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-strong)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]" - > -
-
-
- -
-

- {event.event_type} -

-
-
-

{formatRelativeTimestamp(event.created_at)}

-

{formatTimestamp(event.created_at)}

-
-
-
- ); - }) - ) : ( -
- - No activity yet -

- Activity appears here when events are emitted. -

-
- )} -
-
+ +
diff --git a/frontend/src/components/dashboard/DashboardEmptyState.tsx b/frontend/src/components/dashboard/DashboardEmptyState.tsx new file mode 100644 index 0000000..bb78099 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardEmptyState.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from "react"; +import { toneBanner, type Tone } from "./tokens"; + +interface DashboardEmptyStateProps { + icon?: ReactNode; + message: string; + sub?: string; + tone?: Tone; +} + +/** + * Reusable empty / status callout used throughout dashboard sections. + * One component replaces five copies of the same inline div pattern. + */ +export function DashboardEmptyState({ + icon, + message, + sub, + tone = "default", +}: DashboardEmptyStateProps) { + if (icon) { + return ( +
+ {icon} + {message} + {sub &&

{sub}

} +
+ ); + } + return ( +
+ {message} +
+ ); +} diff --git a/frontend/src/components/dashboard/DashboardInfoBlock.tsx b/frontend/src/components/dashboard/DashboardInfoBlock.tsx new file mode 100644 index 0000000..7c37548 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardInfoBlock.tsx @@ -0,0 +1,40 @@ +import { toneText, type Tone } from "./tokens"; +import { DashboardSection } from "./DashboardSection"; +import type { BadgeTone } from "./tokens"; + +export type InfoRow = { + label: string; + value: string; + tone?: Tone; +}; + +interface DashboardInfoBlockProps { + title: string; + infoText?: string; + badge?: { text: string; tone: BadgeTone }; + rows: InfoRow[]; +} + +/** + * Labeled key/value block used for Workload, Throughput, Gateway Health. + * Tone → color is a lookup, not a ternary chain. + */ +export function DashboardInfoBlock({ title, infoText, badge, rows }: DashboardInfoBlockProps) { + return ( + +
+ {rows.map((row) => ( +
+ {row.label} + + {row.value} + +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/DashboardMetricCard.tsx b/frontend/src/components/dashboard/DashboardMetricCard.tsx new file mode 100644 index 0000000..9f7adfc --- /dev/null +++ b/frontend/src/components/dashboard/DashboardMetricCard.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from "react"; +import { Info } from "lucide-react"; +import { toneIcon, type MetricToneKey } from "./tokens"; + +interface DashboardMetricCardProps { + title: string; + value: string; + secondary?: string; + infoText?: string; + icon: ReactNode; + tone: MetricToneKey; +} + +/** + * Top-row metric card. Tone drives the icon container color only — + * card background and text always come from design tokens. + */ +export function DashboardMetricCard({ + title, + value, + secondary, + infoText, + icon, + tone, +}: DashboardMetricCardProps) { + return ( +
+
+
+
+

+ {title} +

+ {infoText && ( + + + + )} +
+
+

{value}

+ {secondary && ( +

{secondary}

+ )} +
+
+
{icon}
+
+
+ ); +} diff --git a/frontend/src/components/dashboard/DashboardSection.tsx b/frontend/src/components/dashboard/DashboardSection.tsx new file mode 100644 index 0000000..18fb077 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardSection.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from "react"; +import Link from "next/link"; +import { ArrowUpRight, Info } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toneBadge, type BadgeTone } from "./tokens"; + +interface DashboardSectionProps { + title: string; + infoText?: string; + badge?: { text: string; tone: BadgeTone }; + action?: { label: string; href: string }; + children: ReactNode; + className?: string; +} + +/** + * Standard dashboard card section. + * Uses .surface-card so all color values come from design tokens — + * no hardcoded palette classes. + */ +export function DashboardSection({ + title, + infoText, + badge, + action, + children, + className, +}: DashboardSectionProps) { + return ( +
+
+
+

{title}

+ {infoText && ( + + + + )} +
+
+ {badge && ( + + {badge.text} + + )} + {action && ( + + {action.label} + + + )} +
+
+ {children} +
+ ); +} diff --git a/frontend/src/components/dashboard/PendingApprovalsSection.tsx b/frontend/src/components/dashboard/PendingApprovalsSection.tsx new file mode 100644 index 0000000..f41814d --- /dev/null +++ b/frontend/src/components/dashboard/PendingApprovalsSection.tsx @@ -0,0 +1,80 @@ +import Link from "next/link"; +import { DashboardSection } from "./DashboardSection"; +import { DashboardEmptyState } from "./DashboardEmptyState"; + +type ApprovalItem = { + approval_id: string; + board_id: string; + board_name: string; + task_title?: string | null; + confidence: number; + created_at: string; +}; + +interface PendingApprovalsSectionProps { + items: ApprovalItem[]; + total: number; + isLoading: boolean; + hasError: boolean; + formatCount: (n: number) => string; + formatRelative: (ts: string) => string; +} + +export function PendingApprovalsSection({ + items, + total, + isLoading, + hasError, + formatCount, + formatRelative, +}: PendingApprovalsSectionProps) { + return ( + + {isLoading ? ( + + ) : hasError ? ( + + ) : items.length > 0 ? ( +
+
+ {items.map((item) => ( + + + + {item.task_title || "Pending approval"} + + + {item.board_name} · {item.confidence}% score + + + + {formatRelative(item.created_at)} + + + ))} +
+ {total > items.length && ( +

+ Showing latest {formatCount(items.length)} of {formatCount(total)} pending approvals. +

+ )} +
+ ) : ( + + )} +
+ ); +} diff --git a/frontend/src/components/dashboard/RecentActivitySection.tsx b/frontend/src/components/dashboard/RecentActivitySection.tsx new file mode 100644 index 0000000..a82991f --- /dev/null +++ b/frontend/src/components/dashboard/RecentActivitySection.tsx @@ -0,0 +1,78 @@ +import { type KeyboardEvent, type MouseEvent } from "react"; +import { Shield } from "lucide-react"; +import { DashboardSection } from "./DashboardSection"; +import { DashboardEmptyState } from "./DashboardEmptyState"; +import { Markdown } from "@/components/atoms/Markdown"; +import type { ActivityEventRead } from "@/api/generated/model"; + +export type ActivityEvent = ActivityEventRead; + +interface RecentActivitySectionProps { + events: ActivityEvent[]; + feedHref: string; + onRowClick: (e: MouseEvent, href: string) => void; + onRowKeyDown: (e: KeyboardEvent, href: string) => void; + buildHref: (event: ActivityEvent) => string; + formatRelative: (ts: string) => string; + formatTimestamp: (ts: string) => string; +} + +export function RecentActivitySection({ + events, + feedHref, + onRowClick, + onRowKeyDown, + buildHref, + formatRelative, + formatTimestamp, +}: RecentActivitySectionProps) { + return ( + +
+ {events.length > 0 ? ( + events.map((event) => { + const href = buildHref(event); + return ( +
onRowClick(e, href)} + onKeyDown={(e) => onRowKeyDown(e, href)} + className="cursor-pointer overflow-hidden rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2 transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-strong)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]" + > +
+
+
+ +
+

+ {event.event_type} +

+
+
+

{formatRelative(event.created_at)}

+

{formatTimestamp(event.created_at)}

+
+
+
+ ); + }) + ) : ( + } + message="No activity yet" + sub="Activity appears here when events are emitted." + /> + )} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/SessionsSection.tsx b/frontend/src/components/dashboard/SessionsSection.tsx new file mode 100644 index 0000000..ebd0ef0 --- /dev/null +++ b/frontend/src/components/dashboard/SessionsSection.tsx @@ -0,0 +1,93 @@ +import { DashboardSection } from "./DashboardSection"; +import { DashboardEmptyState } from "./DashboardEmptyState"; + +type SessionSummary = { + key: string; + title: string; + subtitle: string; + usage: string; + lastSeenAt: string | null; + isMain: boolean; +}; + +interface SessionsSectionProps { + sessions: SessionSummary[]; + activeSessions: number; + hasConfiguredGateways: boolean; + isLoading: boolean; + gatewayUnavailableCount: number; + gatewayTargetsCount: number; + formatCount: (n: number) => string; + formatRelative: (ts: string) => string; + dash: string; +} + +export function SessionsSection({ + sessions, + activeSessions, + hasConfiguredGateways, + isLoading, + gatewayUnavailableCount, + gatewayTargetsCount, + formatCount, + formatRelative, + dash, +}: SessionsSectionProps) { + return ( + +
+ {!hasConfiguredGateways ? ( + + ) : isLoading ? ( + + ) : sessions.length > 0 ? ( + <> + {gatewayUnavailableCount > 0 && ( + + )} + {sessions.map((session) => ( +
+
+
+

+ + {session.title} +

+

{session.subtitle}

+
+
+

+ {session.usage === dash ? "Usage unavailable" : session.usage} +

+

+ {session.lastSeenAt ? formatRelative(session.lastSeenAt) : "Activity unavailable"} +

+
+
+
+ ))} + + ) : gatewayUnavailableCount === gatewayTargetsCount ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/index.ts b/frontend/src/components/dashboard/index.ts new file mode 100644 index 0000000..e7e5e99 --- /dev/null +++ b/frontend/src/components/dashboard/index.ts @@ -0,0 +1,8 @@ +export { DashboardSection } from "./DashboardSection"; +export { DashboardMetricCard } from "./DashboardMetricCard"; +export { DashboardInfoBlock, type InfoRow } from "./DashboardInfoBlock"; +export { DashboardEmptyState } from "./DashboardEmptyState"; +export { PendingApprovalsSection } from "./PendingApprovalsSection"; +export { SessionsSection } from "./SessionsSection"; +export { RecentActivitySection } from "./RecentActivitySection"; +export { toneText, toneBanner, toneBadge, toneIcon, type Tone, type BadgeTone, type MetricToneKey } from "./tokens"; diff --git a/frontend/src/components/dashboard/tokens.ts b/frontend/src/components/dashboard/tokens.ts new file mode 100644 index 0000000..9cca506 --- /dev/null +++ b/frontend/src/components/dashboard/tokens.ts @@ -0,0 +1,43 @@ +/** Central tone → className map. Use this instead of ternary chains throughout dashboard components. */ + +export type Tone = "default" | "success" | "warning" | "danger"; +export type BadgeTone = "online" | "offline" | "neutral"; +export type MetricToneKey = "accent" | "success" | "warning" | "danger"; + +/** Inline text color for a data value. */ +export const toneText: Record = { + default: "text-strong", + success: "text-[color:var(--success)]", + warning: "text-[color:var(--warning)]", + danger: "text-[color:var(--danger)]", +}; + +/** Soft background + text for banners and empty-state callouts. */ +export const toneBanner: Record = { + default: + "border-[color:var(--border)] bg-[color:var(--surface-muted)] text-muted", + success: + "border-[color:rgba(52,211,153,0.35)] bg-[color:rgba(52,211,153,0.08)] text-[color:var(--success)]", + warning: + "border-[color:rgba(251,191,36,0.35)] bg-[color:rgba(251,191,36,0.08)] text-[color:var(--warning)]", + danger: + "border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] text-[color:var(--danger)]", +}; + +/** Small pill / badge background + text. */ +export const toneBadge: Record = { + online: + "bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]", + offline: + "bg-[color:rgba(248,113,113,0.12)] text-[color:var(--danger)]", + neutral: + "bg-[color:var(--surface-strong)] text-muted", +}; + +/** Icon container background + icon color for metric cards. */ +export const toneIcon: Record = { + accent: "bg-[color:var(--accent-soft)] text-[color:var(--accent)]", + success: "bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]", + warning: "bg-[color:rgba(251,191,36,0.15)] text-[color:var(--warning)]", + danger: "bg-[color:rgba(248,113,113,0.12)] text-[color:var(--danger)]", +};