mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 01:45:52 +02:00
refactor(views): centralize project icon rendering and fix nav active state (#1738)
Extract <ProjectIcon> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-muted/60 px-1.5 py-0.5 text-[11px] text-muted-foreground max-w-[160px]">
|
||||
<span aria-hidden="true" className="shrink-0">{project!.icon || "📁"}</span>
|
||||
<ProjectIcon project={project} size="sm" />
|
||||
<span className="truncate">{project!.title}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<HoverCheck checked={checked} />
|
||||
<span className="size-3.5 flex items-center justify-center shrink-0">
|
||||
{p.icon || <FolderKanban className="size-3.5 text-muted-foreground" />}
|
||||
</span>
|
||||
<ProjectIcon project={p} size="sm" />
|
||||
<span className="truncate">{p.title}</span>
|
||||
{count > 0 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
|
||||
@@ -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({
|
||||
</span>
|
||||
{showProject && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground max-w-[140px]">
|
||||
<span aria-hidden="true" className="shrink-0">{project!.icon || "📁"}</span>
|
||||
<ProjectIcon project={project} size="sm" />
|
||||
<span className="truncate">{project!.title}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -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 <PinSkeleton />;
|
||||
if (projectQuery.isError || !projectQuery.data) return null;
|
||||
const project = projectQuery.data;
|
||||
const iconNode = (
|
||||
<span className="flex size-3.5 shrink-0 items-center justify-center text-xs leading-none">
|
||||
{project.icon || "📁"}
|
||||
</span>
|
||||
);
|
||||
const iconNode = <ProjectIcon project={project} size="sm" />;
|
||||
return (
|
||||
<SortablePinItem
|
||||
pin={pin}
|
||||
@@ -588,7 +593,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
<SidebarMenu className="gap-0.5">
|
||||
{personalNav.map((item) => {
|
||||
const href = p[item.key]();
|
||||
const isActive = pathname === href;
|
||||
const isActive = isNavActive(pathname, href);
|
||||
return (
|
||||
<SidebarMenuItem key={item.key}>
|
||||
<SidebarMenuButton
|
||||
@@ -658,7 +663,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
<SidebarMenu className="gap-0.5">
|
||||
{workspaceNav.map((item) => {
|
||||
const href = p[item.key]();
|
||||
const isActive = pathname === href;
|
||||
const isActive = isNavActive(pathname, href);
|
||||
return (
|
||||
<SidebarMenuItem key={item.key}>
|
||||
<SidebarMenuButton
|
||||
@@ -682,7 +687,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
<SidebarMenu className="gap-0.5">
|
||||
{configureNav.map((item) => {
|
||||
const href = p[item.key]();
|
||||
const isActive = pathname === href;
|
||||
const isActive = isNavActive(pathname, href);
|
||||
return (
|
||||
<SidebarMenuItem key={item.key}>
|
||||
<SidebarMenuButton
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { projectListOptions, projectDetailOptions } from "@multica/core/projects/queries";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { ProjectIcon } from "./project-icon";
|
||||
|
||||
/**
|
||||
* Compact presentational representation of a project —
|
||||
@@ -10,9 +11,6 @@ import { useWorkspaceId } from "@multica/core/hooks";
|
||||
*
|
||||
* Not a link / button: callers wrap it in whatever interactive shell they
|
||||
* need. Pure UI — data is queried internally so callers can pass just an id.
|
||||
*
|
||||
* `📁` matches the fallback used elsewhere (project-picker, projects-page,
|
||||
* project-detail) so project affordances feel consistent across the app.
|
||||
*/
|
||||
export interface ProjectChipProps {
|
||||
projectId: string;
|
||||
@@ -45,7 +43,7 @@ export function ProjectChip({
|
||||
if (!project) {
|
||||
return (
|
||||
<span className={cls}>
|
||||
<span className="shrink-0">📁</span>
|
||||
<ProjectIcon size="md" />
|
||||
<span className="text-muted-foreground truncate">
|
||||
{fallbackLabel ?? "Project"}
|
||||
</span>
|
||||
@@ -55,7 +53,7 @@ export function ProjectChip({
|
||||
|
||||
return (
|
||||
<span className={cls}>
|
||||
<span className="shrink-0">{project.icon || "📁"}</span>
|
||||
<ProjectIcon project={project} size="md" />
|
||||
<span className="text-foreground truncate">{project.title}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
31
packages/views/projects/components/project-icon.tsx
Normal file
31
packages/views/projects/components/project-icon.tsx
Normal file
@@ -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<Project, "icon"> | null;
|
||||
size?: ProjectIconSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SIZE_CLASS: Record<ProjectIconSize, string> = {
|
||||
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 (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center justify-center",
|
||||
SIZE_CLASS[size],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{project?.icon || "📁"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
<FolderKanban className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
{current ? (
|
||||
<ProjectIcon project={current} size="sm" />
|
||||
) : (
|
||||
<FolderKanban className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate">{current ? current.title : "No project"}</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={align} className="w-52">
|
||||
{projects.map((p) => (
|
||||
<DropdownMenuItem key={p.id} onClick={() => onUpdate({ project_id: p.id })}>
|
||||
<span className="mr-1">{p.icon || "📁"}</span>
|
||||
<ProjectIcon project={p} size="md" className="mr-1" />
|
||||
<span className="truncate">{p.title}</span>
|
||||
{p.id === projectId && <Check className="ml-auto h-3.5 w-3.5 shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<span className="shrink-0 w-[24px] text-center text-base">{project.icon || "📁"}</span>
|
||||
<ProjectIcon project={project} size="md" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{project.title}</span>
|
||||
</AppLink>
|
||||
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="size-4 shrink-0 text-center text-sm leading-4">
|
||||
{project.icon || <FolderKanban className="size-4 text-muted-foreground" />}
|
||||
</span>
|
||||
<ProjectIcon project={project} size="md" />
|
||||
<span className="truncate">
|
||||
<HighlightText text={project.title} query={query} />
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user