From e268ee3e71820ddbfa79df77cc98bb5da4a2eab1 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:42:56 +0800 Subject: [PATCH] refactor(views): centralize project icon rendering and fix nav active state (#1738) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract with sm/md/lg sizes and a single 📁 fallback, replacing 9 inline render sites that had drifted into 6 different sizes and a mixed FolderKanban/emoji fallback. Two visible fixes fall out of the centralization: - ProjectPicker trigger now shows the selected project's icon (most visibly in the issue detail right Properties panel, where it had always been a generic FolderKanban). - Sidebar parent nav (Projects, Issues, Settings, ...) now stays highlighted on child detail routes via a small isNavActive helper. Pinned items keep strict equality. Co-authored-by: Claude Opus 4.7 (1M context) --- .../views/issues/components/board-card.tsx | 3 +- .../views/issues/components/issues-header.tsx | 5 ++- packages/views/issues/components/list-row.tsx | 3 +- packages/views/layout/app-sidebar.tsx | 21 ++++++++----- .../projects/components/project-chip.tsx | 8 ++--- .../projects/components/project-icon.tsx | 31 +++++++++++++++++++ .../projects/components/project-picker.tsx | 9 ++++-- .../projects/components/projects-page.tsx | 3 +- packages/views/search/search-command.tsx | 5 ++- 9 files changed, 64 insertions(+), 24 deletions(-) create mode 100644 packages/views/projects/components/project-icon.tsx diff --git a/packages/views/issues/components/board-card.tsx b/packages/views/issues/components/board-card.tsx index d4b206e8d2..7a940a5a90 100644 --- a/packages/views/issues/components/board-card.tsx +++ b/packages/views/issues/components/board-card.tsx @@ -14,6 +14,7 @@ import { useUpdateIssue } from "@multica/core/issues/mutations"; import { useWorkspacePaths } from "@multica/core/paths"; import { useWorkspaceId } from "@multica/core/hooks"; import { projectListOptions } from "@multica/core/projects/queries"; +import { ProjectIcon } from "../../projects/components/project-icon"; import { PriorityIcon } from "./priority-icon"; import { PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers"; import { PRIORITY_CONFIG } from "@multica/core/issues/config"; @@ -101,7 +102,7 @@ export const BoardCardContent = memo(function BoardCardContent({ )} {showProject && ( - + {project!.title} )} diff --git a/packages/views/issues/components/issues-header.tsx b/packages/views/issues/components/issues-header.tsx index ce945cdaa1..878b6e9482 100644 --- a/packages/views/issues/components/issues-header.tsx +++ b/packages/views/issues/components/issues-header.tsx @@ -49,6 +49,7 @@ import { useQuery } from "@tanstack/react-query"; import { useWorkspaceId } from "@multica/core/hooks"; import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries"; import { projectListOptions } from "@multica/core/projects/queries"; +import { ProjectIcon } from "../../projects/components/project-icon"; import { ActorAvatar } from "../../common/actor-avatar"; import { SORT_OPTIONS, @@ -353,9 +354,7 @@ function ProjectSubContent({ className={FILTER_ITEM_CLASS} > - - {p.icon || } - + {p.title} {count > 0 && ( diff --git a/packages/views/issues/components/list-row.tsx b/packages/views/issues/components/list-row.tsx index 8d6f20b27f..54d63b3164 100644 --- a/packages/views/issues/components/list-row.tsx +++ b/packages/views/issues/components/list-row.tsx @@ -10,6 +10,7 @@ import { useWorkspacePaths } from "@multica/core/paths"; import { useWorkspaceId } from "@multica/core/hooks"; import { useViewStore } from "@multica/core/issues/stores/view-store-context"; import { projectListOptions } from "@multica/core/projects/queries"; +import { ProjectIcon } from "../../projects/components/project-icon"; import { PriorityIcon } from "./priority-icon"; import { ProgressRing } from "./progress-ring"; import { IssueActionsContextMenu } from "../actions"; @@ -90,7 +91,7 @@ export const ListRow = memo(function ListRow({ {showProject && ( - + {project!.title} )} diff --git a/packages/views/layout/app-sidebar.tsx b/packages/views/layout/app-sidebar.tsx index 748edcaa6c..28220e8e47 100644 --- a/packages/views/layout/app-sidebar.tsx +++ b/packages/views/layout/app-sidebar.tsx @@ -78,6 +78,15 @@ import { issueDetailOptions } from "@multica/core/issues/queries"; import { projectDetailOptions } from "@multica/core/projects/queries"; import type { PinnedItem } from "@multica/core/types"; import { useLogout } from "../auth"; +import { ProjectIcon } from "../projects/components/project-icon"; + +// Top-level nav items stay active when the user is on a child route +// (e.g. "Projects" stays lit on /:slug/projects/:id). Pinned items keep +// strict equality elsewhere — a pinned project shouldn't highlight on +// sub-pages of itself. +function isNavActive(pathname: string, href: string): boolean { + return pathname === href || pathname.startsWith(href + "/"); +} // Stable empty arrays for query defaults. Using an inline `= []` default on // `useQuery` creates a new array reference on every render when `data` is @@ -269,11 +278,7 @@ function PinRow({ if (projectQuery.isPending) return ; if (projectQuery.isError || !projectQuery.data) return null; const project = projectQuery.data; - const iconNode = ( - - {project.icon || "📁"} - - ); + const iconNode = ; return ( {personalNav.map((item) => { const href = p[item.key](); - const isActive = pathname === href; + const isActive = isNavActive(pathname, href); return ( {workspaceNav.map((item) => { const href = p[item.key](); - const isActive = pathname === href; + const isActive = isNavActive(pathname, href); return ( {configureNav.map((item) => { const href = p[item.key](); - const isActive = pathname === href; + const isActive = isNavActive(pathname, href); return ( - 📁 + {fallbackLabel ?? "Project"} @@ -55,7 +53,7 @@ export function ProjectChip({ return ( - {project.icon || "📁"} + {project.title} ); diff --git a/packages/views/projects/components/project-icon.tsx b/packages/views/projects/components/project-icon.tsx new file mode 100644 index 0000000000..a07e9ec9c2 --- /dev/null +++ b/packages/views/projects/components/project-icon.tsx @@ -0,0 +1,31 @@ +import type { Project } from "@multica/core/types"; +import { cn } from "@multica/ui/lib/utils"; + +export type ProjectIconSize = "sm" | "md" | "lg"; + +export interface ProjectIconProps { + project?: Pick | null; + size?: ProjectIconSize; + className?: string; +} + +const SIZE_CLASS: Record = { + sm: "size-3.5 text-xs leading-none", + md: "size-4 text-sm leading-none", + lg: "size-6 text-2xl leading-none", +}; + +export function ProjectIcon({ project, size = "sm", className }: ProjectIconProps) { + return ( + + ); +} diff --git a/packages/views/projects/components/project-picker.tsx b/packages/views/projects/components/project-picker.tsx index 1dff9c1761..6147c12d0e 100644 --- a/packages/views/projects/components/project-picker.tsx +++ b/packages/views/projects/components/project-picker.tsx @@ -12,6 +12,7 @@ import { DropdownMenuTrigger, DropdownMenuSeparator, } from "@multica/ui/components/ui/dropdown-menu"; +import { ProjectIcon } from "./project-icon"; export function ProjectPicker({ projectId, @@ -34,13 +35,17 @@ export function ProjectPicker({ className={triggerRender ? undefined : "flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden"} render={triggerRender} > - + {current ? ( + + ) : ( + + )} {current ? current.title : "No project"} {projects.map((p) => ( onUpdate({ project_id: p.id })}> - {p.icon || "📁"} + {p.title} {p.id === projectId && } diff --git a/packages/views/projects/components/projects-page.tsx b/packages/views/projects/components/projects-page.tsx index 8f2647c445..3e5a3b9e32 100644 --- a/packages/views/projects/components/projects-page.tsx +++ b/packages/views/projects/components/projects-page.tsx @@ -36,6 +36,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ import type { Project, ProjectStatus, ProjectPriority, UpdateProjectRequest } from "@multica/core/types"; import { PageHeader } from "../../layout/page-header"; import { PriorityIcon } from "../../issues/components/priority-icon"; +import { ProjectIcon } from "./project-icon"; function formatRelativeDate(date: string): string { const diff = Date.now() - new Date(date).getTime(); @@ -77,7 +78,7 @@ function ProjectRow({ project }: { project: Project }) { href={wsPaths.projectDetail(project.id)} className="flex min-w-0 flex-1 items-center gap-2" > - {project.icon || "📁"} + {project.title} diff --git a/packages/views/search/search-command.tsx b/packages/views/search/search-command.tsx index 97c6cc0d56..ba63b624ec 100644 --- a/packages/views/search/search-command.tsx +++ b/packages/views/search/search-command.tsx @@ -36,6 +36,7 @@ import type { WorkspacePaths } from "@multica/core/paths"; import { useModalStore } from "@multica/core/modals"; import { workspaceListOptions } from "@multica/core/workspace/queries"; import { StatusIcon } from "../issues/components"; +import { ProjectIcon } from "../projects/components/project-icon"; import { STATUS_CONFIG } from "@multica/core/issues/config"; import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config"; import type { ProjectStatus } from "@multica/core/types"; @@ -573,9 +574,7 @@ export function SearchCommand() { className="flex cursor-default select-none flex-col gap-1 rounded-lg px-3 py-2.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-accent" >
- - {project.icon || } - +