diff --git a/apps/desktop/src/renderer/components/ColorSelector/ColorSelector.tsx b/apps/desktop/src/renderer/components/ColorSelector/ColorSelector.tsx index 970dcd033ea..e7c8c095e18 100644 --- a/apps/desktop/src/renderer/components/ColorSelector/ColorSelector.tsx +++ b/apps/desktop/src/renderer/components/ColorSelector/ColorSelector.tsx @@ -12,6 +12,10 @@ interface ColorSelectorProps { selectedColor?: string | null; onSelectColor: (color: string) => void; variant?: ColorSelectorVariant; + /** Prepend a "Default" (no color) swatch; selects PROJECT_COLOR_DEFAULT. */ + includeDefault?: boolean; + /** Disable all swatches (e.g. while a selection is persisting). */ + disabled?: boolean; className?: string; } @@ -43,19 +47,25 @@ export function ColorSelector({ selectedColor, onSelectColor, variant = "inline", + includeDefault = false, + disabled = false, className, }: ColorSelectorProps) { const selectedValue = selectedColor ?? PROJECT_COLOR_DEFAULT; + const colors = includeDefault + ? [{ name: "Default", value: PROJECT_COLOR_DEFAULT }, ...PROJECT_COLORS] + : [...PROJECT_COLORS]; if (variant === "menu") { return ( <> - {PROJECT_COLORS.map((color) => { + {colors.map((color) => { const isSelected = selectedValue === color.value; return ( onSelectColor(color.value)} className="flex items-center gap-2" > @@ -73,7 +83,7 @@ export function ColorSelector({ return (
- {PROJECT_COLORS.map((color) => { + {colors.map((color) => { const isSelected = selectedValue === color.value; return ( @@ -83,10 +93,12 @@ export function ColorSelector({ title={color.name} aria-label={`Set color to ${color.name}`} aria-pressed={isSelected} + disabled={disabled} onClick={() => onSelectColor(color.value)} className={cn( "flex size-7 items-center justify-center rounded-full border-2 transition-transform hover:scale-110", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + "disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100", isSelected ? "scale-110 border-foreground" : "border-transparent", )} > diff --git a/apps/desktop/src/renderer/hooks/host-projects/resolveProjectIconUrl/index.ts b/apps/desktop/src/renderer/hooks/host-projects/resolveProjectIconUrl/index.ts index b0a73dd3293..e10b71cc2bf 100644 --- a/apps/desktop/src/renderer/hooks/host-projects/resolveProjectIconUrl/index.ts +++ b/apps/desktop/src/renderer/hooks/host-projects/resolveProjectIconUrl/index.ts @@ -1 +1,4 @@ -export { resolveProjectIconUrl } from "./resolveProjectIconUrl"; +export { + PROJECT_ICON_NONE, + resolveProjectIconUrl, +} from "./resolveProjectIconUrl"; diff --git a/apps/desktop/src/renderer/hooks/host-projects/resolveProjectIconUrl/resolveProjectIconUrl.test.ts b/apps/desktop/src/renderer/hooks/host-projects/resolveProjectIconUrl/resolveProjectIconUrl.test.ts index 33756c83712..4b2259a0107 100644 --- a/apps/desktop/src/renderer/hooks/host-projects/resolveProjectIconUrl/resolveProjectIconUrl.test.ts +++ b/apps/desktop/src/renderer/hooks/host-projects/resolveProjectIconUrl/resolveProjectIconUrl.test.ts @@ -20,4 +20,10 @@ describe("resolveProjectIconUrl", () => { test("returns null when there is neither a custom icon nor a repo owner", () => { expect(resolveProjectIconUrl({ icon: null, repoOwner: null })).toBeNull(); }); + + test("the 'none' sentinel suppresses the GitHub avatar fallback", () => { + expect( + resolveProjectIconUrl({ icon: "none", repoOwner: "acme" }), + ).toBeNull(); + }); }); diff --git a/apps/desktop/src/renderer/hooks/host-projects/resolveProjectIconUrl/resolveProjectIconUrl.ts b/apps/desktop/src/renderer/hooks/host-projects/resolveProjectIconUrl/resolveProjectIconUrl.ts index 5babcc9d4c0..0a470796db6 100644 --- a/apps/desktop/src/renderer/hooks/host-projects/resolveProjectIconUrl/resolveProjectIconUrl.ts +++ b/apps/desktop/src/renderer/hooks/host-projects/resolveProjectIconUrl/resolveProjectIconUrl.ts @@ -1,7 +1,15 @@ +/** + * Sentinel stored in the host `icon` column meaning "explicitly no icon": + * suppresses the GitHub avatar fallback so surfaces render the letter tile + * (v1's hide_image equivalent). Null means "default" (avatar when linked). + */ +export const PROJECT_ICON_NONE = "none"; + /** * Resolve the icon URL to display for a project: a custom local-first icon - * (data-URI, set in project settings) wins; otherwise fall back to the linked - * GitHub owner's avatar; otherwise none (callers render a placeholder). + * (data-URI, set in project settings) wins; the "none" sentinel suppresses + * every image so callers render the letter placeholder; otherwise fall back + * to the linked GitHub owner's avatar; otherwise none. * * This is the single source of truth for project-icon display — every surface * (sidebar, settings, pickers, workspace lists) must go through it so a custom @@ -11,6 +19,7 @@ export function resolveProjectIconUrl(project: { icon: string | null; repoOwner: string | null; }): string | null { + if (project.icon === PROJECT_ICON_NONE) return null; if (project.icon) return project.icon; if (project.repoOwner) { return `https://github.com/${project.repoOwner}.png?size=64`; diff --git a/apps/desktop/src/renderer/hooks/host-projects/useHostProjects/useHostProjects.utils.ts b/apps/desktop/src/renderer/hooks/host-projects/useHostProjects/useHostProjects.utils.ts index 09de380c3c0..a2d32363c4a 100644 --- a/apps/desktop/src/renderer/hooks/host-projects/useHostProjects/useHostProjects.utils.ts +++ b/apps/desktop/src/renderer/hooks/host-projects/useHostProjects/useHostProjects.utils.ts @@ -13,6 +13,8 @@ export interface HostProjectRow { worktreeBaseDir: string | null; /** Custom icon data-URI, or null to fall back to the GitHub avatar. */ icon: string | null; + /** Accent color as a `#rrggbb` hex, or null for the default. */ + color: string | null; createdAt: number; updatedAt: number; } @@ -34,6 +36,8 @@ export interface HostProjectItem { repoUrl: string | null; /** Custom icon data-URI, or null to fall back to the GitHub avatar. */ icon: string | null; + /** Accent color as a `#rrggbb` hex, or null for the default. */ + color: string | null; /** Hosts that serve this project. */ hostIds: string[]; /** False when no serving host answered live (snapshot data only). */ @@ -137,6 +141,7 @@ export function normalizeHostProjectRow( repoUrl: row.repoUrl ?? null, worktreeBaseDir: row.worktreeBaseDir ?? null, icon: row.icon ?? null, + color: row.color ?? null, createdAt: row.createdAt ?? 0, updatedAt: row.updatedAt ?? row.createdAt ?? 0, }; @@ -241,6 +246,7 @@ export function applyProjectChangedEvent( repoUrl: snapshot.repoUrl, worktreeBaseDir: snapshot.worktreeBaseDir, icon: snapshot.icon, + color: snapshot.color ?? null, createdAt: snapshot.createdAt, updatedAt: snapshot.updatedAt, }; @@ -282,6 +288,7 @@ export function mergeHostProjects({ repoName: row.repoName, repoUrl: row.repoUrl, icon: row.icon, + color: row.color, hostIds: [result.target.machineId], hostReachable: result.reachable, createdAt: row.createdAt, @@ -306,6 +313,7 @@ export function mergeHostProjects({ existing.repoOwner = row.repoOwner; existing.repoName = row.repoName; existing.icon = row.icon; + existing.color = row.color; } } } diff --git a/apps/desktop/src/renderer/lib/v1-migration/ipc.ts b/apps/desktop/src/renderer/lib/v1-migration/ipc.ts index 1018274f93f..03729d06bbc 100644 --- a/apps/desktop/src/renderer/lib/v1-migration/ipc.ts +++ b/apps/desktop/src/renderer/lib/v1-migration/ipc.ts @@ -12,6 +12,10 @@ export interface V1ProjectRow { name: string; mainRepoPath: string; githubOwner: string | null; + /** v1 accent color: a `#rrggbb` hex or the "default" sentinel. */ + color: string; + /** v1 "hide the GitHub avatar" flag — carries as the "none" icon. */ + hideImage: boolean | null; worktreeBaseDir: string | null; branchPrefixMode: BranchPrefixMode | null; branchPrefixCustom: string | null; diff --git a/apps/desktop/src/renderer/lib/v1-migration/projects.ts b/apps/desktop/src/renderer/lib/v1-migration/projects.ts index 3f9c8f04833..eb33636ef85 100644 --- a/apps/desktop/src/renderer/lib/v1-migration/projects.ts +++ b/apps/desktop/src/renderer/lib/v1-migration/projects.ts @@ -6,6 +6,10 @@ export interface V1ProjectLike { name: string; mainRepoPath: string; githubOwner: string | null; + /** v1 accent color: a `#rrggbb` hex or the "default" sentinel. */ + color?: string | null; + /** v1 "hide the GitHub avatar" flag — carries as the "none" icon. */ + hideImage?: boolean | null; } export type ProjectFindByPathResult = Awaited< @@ -126,6 +130,7 @@ export async function importV1Project({ allowRelocate, }, }); + await carryV1ProjectAppearance(hostClient, targetCandidate.id, project); return { kind: "imported", v2ProjectId: targetCandidate.id, @@ -148,6 +153,7 @@ export async function importV1Project({ name: project.name, mode: { kind: "importLocal", repoPath: project.mainRepoPath }, }); + await carryV1ProjectAppearance(hostClient, result.projectId, project); return { kind: "imported", v2ProjectId: result.projectId, @@ -155,3 +161,43 @@ export async function importV1Project({ repoPath: result.repoPath, }; } + +/** + * Best-effort: copy v1 appearance onto the imported v2 project — the accent + * color (v1 stores either a hex or the "default" sentinel; only real hexes + * carry over) and the hide-avatar flag (v2's "none" icon sentinel). Never + * fails the import (appearance is cosmetic, and older hosts lack setColor). + */ +async function carryV1ProjectAppearance( + hostClient: HostServiceClient, + v2ProjectId: string, + project: Pick, +): Promise { + const { color } = project; + if (color && /^#[0-9a-fA-F]{6}$/.test(color)) { + try { + await hostClient.project.setColor.mutate({ + projectId: v2ProjectId, + color, + }); + } catch (err) { + console.error("[v1-migration] carrying project color failed", { + v2ProjectId, + err, + }); + } + } + if (project.hideImage) { + try { + await hostClient.project.setIcon.mutate({ + projectId: v2ProjectId, + icon: "none", + }); + } catch (err) { + console.error("[v1-migration] carrying hide-image flag failed", { + v2ProjectId, + err, + }); + } + } +} diff --git a/apps/desktop/src/renderer/lib/v1-migration/runV1Migration.test.ts b/apps/desktop/src/renderer/lib/v1-migration/runV1Migration.test.ts index 94af5337883..9daef9fdb9b 100644 --- a/apps/desktop/src/renderer/lib/v1-migration/runV1Migration.test.ts +++ b/apps/desktop/src/renderer/lib/v1-migration/runV1Migration.test.ts @@ -110,6 +110,16 @@ class FakeHost { } : null, }, + setColor: { + mutate: async (args: unknown) => { + this.mutations.push({ kind: "project.setColor", args }); + }, + }, + setIcon: { + mutate: async (args: unknown) => { + this.mutations.push({ kind: "project.setIcon", args }); + }, + }, setWorktreeBaseDir: { mutate: async (args: unknown) => { this.mutations.push({ kind: "project.setWorktreeBaseDir", args }); @@ -225,11 +235,18 @@ class FakeIpc implements V1MigrationIpc { } } -const project = (id: string, repoPath: string): V1ProjectRow => ({ +const project = ( + id: string, + repoPath: string, + color = "default", + hideImage: boolean | null = null, +): V1ProjectRow => ({ id, name: id, mainRepoPath: repoPath, githubOwner: null, + color, + hideImage, worktreeBaseDir: null, branchPrefixMode: null, branchPrefixCustom: null, @@ -274,6 +291,33 @@ describe("runV1Migration scenarios", () => { expect(second.gateComplete).toBe(true); }); + test("custom v1 color and hide-image carry over; defaults do not", async () => { + const ipc = new FakeIpc(); + const host = new FakeHost(); + ipc.projects = [ + project("colored", "/repo/a", "#ef4444"), + project("plain", "/repo/b"), // "default" sentinel, no hideImage + project("hidden", "/repo/c", "default", true), + ]; + + const summary = await run(ipc, host); + expect(summary.projects.migrated).toBe(3); + expect(host.mutations.filter((m) => m.kind === "project.setColor")).toEqual( + [ + { + kind: "project.setColor", + args: { projectId: "v2p-1", color: "#ef4444" }, + }, + ], + ); + expect(host.mutations.filter((m) => m.kind === "project.setIcon")).toEqual([ + { + kind: "project.setIcon", + args: { projectId: "v2p-5", icon: "none" }, + }, + ]); + }); + test("broken repo blocks the gate but not other entities; recovery unblocks", async () => { const ipc = new FakeIpc(); const host = new FakeHost(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx index 27cad9eea95..66c3d52fd50 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx @@ -93,6 +93,7 @@ export function DashboardSidebarProjectSection({ projectId={project.id} projectName={project.name} iconUrl={project.iconUrl} + projectColor={project.color} isCollapsed={project.isCollapsed} totalWorkspaceCount={totalWorkspaceCount} projectChildren={project.children} @@ -119,6 +120,7 @@ export function DashboardSidebarProjectSection({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectRow/DashboardSidebarProjectRow.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectRow/DashboardSidebarProjectRow.tsx index a0f23490aac..9b847959da6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectRow/DashboardSidebarProjectRow.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarProjectRow/DashboardSidebarProjectRow.tsx @@ -9,6 +9,7 @@ interface DashboardSidebarProjectRowProps extends ComponentPropsWithoutRef<"div"> { projectName: string; iconUrl: string | null; + projectColor: string | null; isCollapsed: boolean; isRenaming: boolean; renameValue: string; @@ -28,6 +29,7 @@ export const DashboardSidebarProjectRow = forwardRef< { projectName, iconUrl, + projectColor, isCollapsed, isRenaming, renameValue, @@ -72,6 +74,7 @@ export const DashboardSidebarProjectRow = forwardRef< (null); const firstLetter = projectName.charAt(0).toUpperCase(); + const hasColor = isHexColor(color); if (iconUrl && failedUrl !== iconUrl) { return (
{firstLetter}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportProjectsPage/ImportProjectsPage.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportProjectsPage/ImportProjectsPage.tsx index 256cd2e6b71..a668a59a451 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportProjectsPage/ImportProjectsPage.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V1ImportModal/ImportProjectsPage/ImportProjectsPage.tsx @@ -33,6 +33,10 @@ type V1Project = { name: string; mainRepoPath: string; githubOwner: string | null; + /** v1 accent color: a `#rrggbb` hex or the "default" sentinel. */ + color: string; + /** v1 "hide the GitHub avatar" flag — carries as the "none" icon. */ + hideImage: boolean | null; }; export function ImportProjectsPage({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx index ee70199b1fb..856c4750d8c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/projects/components/ProjectsSettingsSidebar/ProjectsSettingsSidebar.tsx @@ -5,6 +5,7 @@ import { useHostProjects } from "renderer/hooks/host-projects/useHostProjects"; import { useIsV2CloudEnabled } from "renderer/hooks/useIsV2CloudEnabled"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { ProjectThumbnail } from "renderer/routes/_authenticated/components/ProjectThumbnail"; +import { PROJECT_COLOR_DEFAULT } from "shared/constants/project-colors"; import { type SettingsListGroup, SettingsListSidebar, @@ -16,6 +17,7 @@ interface ProjectRow { id: string; name: string; iconUrl: string | null; + color: string | null; } interface ProjectsSettingsSidebarProps { @@ -37,6 +39,7 @@ export function ProjectsSettingsSidebar({ id: project.projectKey, name: project.name, iconUrl: resolveProjectIconUrl(project), + color: project.color, })), [hostProjects], ); @@ -48,6 +51,7 @@ export function ProjectsSettingsSidebar({ id: p.id, name: p.name, iconUrl: p.iconUrl ?? null, + color: p.color, })); return [{ id: "v2", title: "v2", rows: v2Rows }]; } @@ -57,6 +61,7 @@ export function ProjectsSettingsSidebar({ id: g.project.id, name: g.project.name, iconUrl: g.project.iconUrl, + color: g.project.color === PROJECT_COLOR_DEFAULT ? null : g.project.color, })); return [{ id: "v1", title: "v1", rows: v1Rows }]; }, [groups, v2Projects, isV2CloudEnabled]); @@ -83,6 +88,7 @@ export function ProjectsSettingsSidebar({ {row.name} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx index bb6ef56cc2a..7f453659e24 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/V2ProjectSettings.tsx @@ -2,7 +2,10 @@ import { Label } from "@superset/ui/label"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { useEffect, useMemo, useRef } from "react"; -import { resolveProjectIconUrl } from "renderer/hooks/host-projects/resolveProjectIconUrl"; +import { + PROJECT_ICON_NONE, + resolveProjectIconUrl, +} from "renderer/hooks/host-projects/resolveProjectIconUrl"; import { useHostProjects } from "renderer/hooks/host-projects/useHostProjects"; import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; @@ -144,6 +147,8 @@ export function V2ProjectSettings({ icon: projectIcon, repoOwner: project.repoOwner, }); + // Accent color follows the same per-host precedence as the icon. + const projectColor = hostProject ? hostProject.color : project.color; const canRename = Boolean( targetHostUrl && targetHostId && project.hostIds.includes(targetHostId), ); @@ -152,7 +157,11 @@ export function V2ProjectSettings({
- +

{project.name}

{hasMultipleHosts && targetHostId ? ( @@ -190,13 +199,18 @@ export function V2ProjectSettings({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/IconUploadField/IconUploadField.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/IconUploadField/IconUploadField.tsx index c737f782c4a..47c63b2fe48 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/IconUploadField/IconUploadField.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/v2-project/$projectId/components/V2ProjectSettings/components/IconUploadField/IconUploadField.tsx @@ -1,14 +1,30 @@ -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@superset/ui/dropdown-menu"; +import { Button } from "@superset/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@superset/ui/popover"; import { toast } from "@superset/ui/sonner"; -import { useCallback, useRef, useState } from "react"; -import { LuImagePlus, LuRotateCcw, LuUpload } from "react-icons/lu"; +import { cn } from "@superset/ui/utils"; +import { createElement, useCallback, useRef, useState } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { IconType } from "react-icons"; +import { + LuBug, + LuCode, + LuFlame, + LuFolder, + LuGlobe, + LuHeart, + LuLeaf, + LuPackage, + LuRocket, + LuStar, + LuTerminal, + LuUpload, + LuZap, +} from "react-icons/lu"; +import { ColorSelector } from "renderer/components/ColorSelector"; +import { PROJECT_ICON_NONE } from "renderer/hooks/host-projects/resolveProjectIconUrl"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { ProjectThumbnail } from "renderer/routes/_authenticated/components/ProjectThumbnail"; +import { PROJECT_COLOR_DEFAULT } from "shared/constants/project-colors"; const ACCEPTED_MIME_TYPES = "image/png,image/jpeg,image/webp"; // Guard the source file before we bother decoding it; the stored icon is the @@ -16,14 +32,80 @@ const ACCEPTED_MIME_TYPES = "image/png,image/jpeg,image/webp"; const MAX_SOURCE_BYTES = 10 * 1024 * 1024; const ICON_SIZE = 128; +// Neutral glyph tint when the project has no accent color (slate). +const DEFAULT_GLYPH_COLOR = "#64748b"; + +const GLYPHS: Array<{ name: string; icon: IconType }> = [ + { name: "Folder", icon: LuFolder }, + { name: "Rocket", icon: LuRocket }, + { name: "Star", icon: LuStar }, + { name: "Heart", icon: LuHeart }, + { name: "Zap", icon: LuZap }, + { name: "Flame", icon: LuFlame }, + { name: "Leaf", icon: LuLeaf }, + { name: "Globe", icon: LuGlobe }, + { name: "Code", icon: LuCode }, + { name: "Terminal", icon: LuTerminal }, + { name: "Bug", icon: LuBug }, + { name: "Package", icon: LuPackage }, +]; + interface IconUploadFieldProps { projectId: string; - /** Host serving this project; null when unreachable (upload disabled). */ + /** For the letter placeholder when no icon resolves. */ + projectName: string; + /** Host serving this project; null when unreachable (picker disabled). */ hostUrl: string | null; /** Resolved icon to preview (custom icon, else GitHub avatar, else none). */ iconUrl: string | null; - /** True when a custom icon is set — enables "Reset to default". */ + /** True when a custom icon (glyph/upload) is set. */ hasCustomIcon: boolean; + /** True when the icon is explicitly removed (the "none" sentinel). */ + isIconRemoved: boolean; + /** Persisted accent color (`#rrggbb`), or null for the default. */ + color: string | null; +} + +function hexToRgba(hex: string, alpha: number): string { + const r = Number.parseInt(hex.slice(1, 3), 16); + const g = Number.parseInt(hex.slice(3, 5), 16); + const b = Number.parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +/** Rasterize an SVG string to a square PNG data-URI. */ +async function svgToPngDataUri(svg: string): Promise { + const img = await new Promise((resolve, reject) => { + const el = new Image(); + el.onload = () => resolve(el); + el.onerror = () => reject(new Error("Could not render icon")); + el.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + }); + const canvas = document.createElement("canvas"); + canvas.width = ICON_SIZE; + canvas.height = ICON_SIZE; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Could not render icon"); + ctx.drawImage(img, 0, 0, ICON_SIZE, ICON_SIZE); + return canvas.toDataURL("image/png"); +} + +/** Bake a glyph onto a tinted tile as a PNG data-URI (same treatment as the + * letter fallback: full-strength glyph on a 15%-alpha background). */ +function glyphToDataUri( + glyph: IconType, + color: string | null, +): Promise { + const stroke = color ?? DEFAULT_GLYPH_COLOR; + const bg = hexToRgba(stroke, color ? 0.15 : 0.12); + const glyphMarkup = renderToStaticMarkup( + createElement(glyph, { size: 72, color: stroke }), + ); + const svg = + `` + + `` + + `${glyphMarkup}`; + return svgToPngDataUri(svg); } /** Downscale an image file to a small square PNG data-URI (cover-fit). */ @@ -53,19 +135,25 @@ async function toIconDataUri(file: File): Promise { export function IconUploadField({ projectId, + projectName, hostUrl, iconUrl, hasCustomIcon, + isIconRemoved, + color, }: IconUploadFieldProps) { const fileInputRef = useRef(null); const [isPending, setIsPending] = useState(false); + // Glyph picked in this popover session — lets a later color pick re-bake + // it, since a baked PNG can't follow the color on its own. + const [sessionGlyph, setSessionGlyph] = useState(null); const disabled = isPending || !hostUrl; const setIcon = useCallback( - async (icon: string | null) => { + async (icon: string | null): Promise => { if (!hostUrl) { toast.error("This project's host is offline"); - return; + return false; } setIsPending(true); try { @@ -73,8 +161,10 @@ export function IconUploadField({ projectId, icon, }); + return true; } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to set icon"); + return false; } finally { setIsPending(false); } @@ -82,6 +172,63 @@ export function IconUploadField({ [hostUrl, projectId], ); + const setColor = useCallback( + async (nextColor: string | null): Promise => { + if (!hostUrl) { + toast.error("This project's host is offline"); + return false; + } + setIsPending(true); + try { + await getHostServiceClientByUrl(hostUrl).project.setColor.mutate({ + projectId, + color: nextColor, + }); + return true; + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to set color"); + return false; + } finally { + setIsPending(false); + } + }, + [hostUrl, projectId], + ); + + const handleSelectGlyph = useCallback( + async (glyph: IconType) => { + let dataUri: string; + try { + dataUri = await glyphToDataUri(glyph, color); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Could not set icon"); + return; + } + // Only a stored glyph may re-bake on later color picks. + if (await setIcon(dataUri)) setSessionGlyph(() => glyph); + }, + [color, setIcon], + ); + + const handleSelectColor = useCallback( + async (value: string) => { + const nextColor = value === PROJECT_COLOR_DEFAULT ? null : value; + // A rejected color (offline host, old host without setColor) must not + // re-tint the icon — the tile would show a color the project never + // stored. + if (!(await setColor(nextColor))) return; + // Keep a glyph picked this session in sync with the new color. + if (sessionGlyph) { + try { + await setIcon(await glyphToDataUri(sessionGlyph, nextColor)); + } catch { + // Color persisted; a failed re-bake keeps the previous tile. + } + } + }, + [sessionGlyph, setColor, setIcon], + ); + const handleClickUpload = useCallback(() => { if (!fileInputRef.current) return; fileInputRef.current.value = ""; @@ -106,63 +253,111 @@ export function IconUploadField({ ); return; } + setSessionGlyph(null); await setIcon(dataUri); }, [setIcon], ); - const Thumbnail = ( - - ); - return ( <> - {hasCustomIcon ? ( - - {Thumbnail} - - - - Upload image… - - - + + + + +
+

+ Color +

+ { - void setIcon(null); + onSelectColor={(value) => { + void handleSelectColor(value); }} + /> +
+
+

+ Icon +

+
+ {GLYPHS.map((glyph) => ( + + ))} +
+
+
+ + {iconUrl && ( + + )} + {(hasCustomIcon || isIconRemoved) && ( + + )} +
+
+ { + const row = updateLocalProject( + { db: ctx.db, eventBus: ctx.eventBus }, + input.projectId, + { color: input.color }, + ); + if (!row) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Project is not set up on this host", + }); + } + return toProjectSnapshot(row); + }), + setWorktreeBaseDir: protectedProcedure .input( z.object({