mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
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:
1 parent
ee196badee
commit
f5e6d346e6
1 file changed
+80
-42
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user