PAP-72: combined icon+color picker popover on project detail (PAP-68 part 4)

Replace the detail-page color-only popover with a single combined picker:
icon search/grid (reusing PROJECT_ICONS + PROJECT_ICON_NAMES infra from part 3)
on top, color swatch row (with neutral-gray reset) below a divider. Clicking
the header tile opens one popover; choosing an icon -> updateProject({icon}),
choosing a color -> updateProject({color}). Popover stays open across
selections so both can be set in one pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Opus 4.8 committed 2026-06-05 15:18:54 -07:00
1 parent ee196badee
commit f5e6d346e6
1 file changed
+80 -42
+80 -42
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { Link, useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared";
import { PROJECT_COLORS, PROJECT_ICON_NAMES, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared";
import { budgetsApi } from "../api/budgets";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { instanceSettingsApi } from "../api/instanceSettings";
@@ -28,7 +28,11 @@ import { MembershipAction } from "../components/MembershipAction";
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
import { collectLiveIssueIds } from "../lib/liveIssueIds";
import { projectRouteRef } from "../lib/utils";
import { PROJECT_ICONS } from "../lib/project-icons";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { Tabs } from "@/components/ui/tabs";
import { PluginLauncherOutlet } from "@/plugins/launchers";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
@@ -104,52 +108,88 @@ function OverviewContent({
);
}
/* ── Color picker popover ── */
/* ── Combined icon + color picker popover (PAP-72 / PAP-68 part 4) ── */
function ColorPicker({
const DEFAULT_PROJECT_ICON = "folder";
function ProjectTilePicker({
color,
icon,
onSelect,
onSelectIcon,
onSelectColor,
}: {
color: string | null;
icon?: string | null;
onSelect: (color: string | null) => void;
icon: string | null;
onSelectIcon: (icon: string) => void;
onSelectColor: (color: string | null) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const [search, setSearch] = useState("");
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
const filteredIcons = useMemo(() => {
const entries = PROJECT_ICON_NAMES.map((name) => [name, PROJECT_ICONS[name]] as const);
if (!search) return entries;
const q = search.toLowerCase();
return entries.filter(([name]) => name.includes(q));
}, [search]);
// Keep the popover open across selections so the user can pick both an icon
// and a color in one pass; reset the search when it closes.
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen(!open)}
className="shrink-0 rounded-lg cursor-pointer hover:ring-2 hover:ring-foreground/20 transition-[box-shadow]"
aria-label="Change project color"
>
<ProjectTile color={color} icon={icon ?? null} size="md" />
</button>
{open && (
<div className="absolute top-full left-0 mt-2 p-3 bg-popover border border-border rounded-lg shadow-lg z-50 w-max">
<p className="text-xs font-medium text-muted-foreground mb-2">Project color</p>
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setSearch("");
}}
>
<PopoverTrigger asChild>
<button
type="button"
className="shrink-0 rounded-lg cursor-pointer hover:ring-2 hover:ring-foreground/20 transition-[box-shadow]"
aria-label="Change project icon and color"
>
<ProjectTile color={color} icon={icon} size="md" />
</button>
</PopoverTrigger>
<PopoverContent className="w-72 p-3" align="start">
{/* Icon search + grid */}
<p className="text-xs font-medium text-muted-foreground mb-2">Icon</p>
<Input
placeholder="Search icons..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="mb-2 h-8 text-sm"
autoFocus
/>
<div className="grid grid-cols-7 gap-1 max-h-40 overflow-y-auto">
{filteredIcons.map(([name, Icon]) => (
<button
key={name}
type="button"
onClick={() => onSelectIcon(name)}
className={cn(
"flex items-center justify-center h-8 w-8 rounded hover:bg-accent transition-colors",
(icon ?? DEFAULT_PROJECT_ICON) === name && "bg-accent ring-1 ring-primary",
)}
title={name}
>
<Icon className="h-4 w-4" />
</button>
))}
{filteredIcons.length === 0 && (
<p className="col-span-7 text-xs text-muted-foreground text-center py-2">No icons match</p>
)}
</div>
{/* Color swatches */}
<div className="mt-3 border-t border-border pt-3">
<p className="text-xs font-medium text-muted-foreground mb-2">Color</p>
<div className="grid grid-cols-5 gap-1.5">
{/* Neutral / reset-to-gray option (folder icon is fixed this phase) */}
{/* Neutral / reset-to-gray option */}
<button
type="button"
onClick={() => {
onSelect(null);
setOpen(false);
}}
onClick={() => onSelectColor(null)}
className={`h-6 w-6 cursor-pointer transition-[transform,box-shadow] duration-150 hover:scale-110 ${
color === null
? "ring-2 ring-foreground ring-offset-1 ring-offset-background rounded-md"
@@ -164,10 +204,7 @@ function ColorPicker({
<button
key={swatch}
type="button"
onClick={() => {
onSelect(swatch);
setOpen(false);
}}
onClick={() => onSelectColor(swatch)}
className={`h-6 w-6 rounded-md cursor-pointer transition-[transform,box-shadow] duration-150 hover:scale-110 ${
swatch === color
? "ring-2 ring-foreground ring-offset-1 ring-offset-background"
@@ -179,8 +216,8 @@ function ColorPicker({
))}
</div>
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}
@@ -722,10 +759,11 @@ export function ProjectDetail() {
) : null}
<div className="flex items-start gap-3">
<div className="h-7 flex items-center">
<ColorPicker
<ProjectTilePicker
color={project.color ?? null}
icon={project.icon ?? null}
onSelect={(color) => updateProject.mutate({ color })}
onSelectIcon={(icon) => updateProject.mutate({ icon })}
onSelectColor={(color) => updateProject.mutate({ color })}
/>
</div>
<div className="min-w-0 space-y-2">