264 lines · 7.3 KB · TypeScript
RawDownload
import type { ProjectIconColor } from "@t3tools/contracts";import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";import {  getProjectFaviconResourceKey,  isProjectFaviconFallbackUrl,} from "@t3tools/shared/projectFavicon";import {  BotIcon,  BookOpenIcon,  BracesIcon,  CircuitBoardIcon,  CloudCogIcon,  Code2Icon,  DatabaseIcon,  FlaskConicalIcon,  FolderCodeIcon,  Gamepad2Icon,  Globe2Icon,  ImageIcon,  Layers3Icon,  MonitorIcon,  MusicIcon,  PackageIcon,  ServerIcon,  ShieldCheckIcon,  ShoppingBagIcon,  SmartphoneIcon,  TerminalIcon,  VideoIcon,} from "lucide-react";import type { IconName } from "lucide-react/dynamic";import type { ComponentType } from "react";import { lazy, Suspense, useState } from "react";import { useAtomValue } from "@effect/atom-react";import { projectFaviconUrlAtom } from "../state/assets";import { selectProjectIcon, type ProjectIconName } from "../projectIconModel";import { projectIconColorClassName } from "../projectIconColors";import { cn } from "~/lib/utils"; const DynamicIcon = lazy(() =>  import("lucide-react/dynamic").then((module) => ({ default: module.DynamicIcon })),); function DynamicProjectIconFallback() {  return <FolderCodeIcon className="size-full text-[inherit]" />;} const PROJECT_ICONS: Record<ProjectIconName, ComponentType<{ className?: string }>> = {  ai: BotIcon,  book: BookOpenIcon,  braces: BracesIcon,  circuit: CircuitBoardIcon,  cloud: CloudCogIcon,  code: Code2Icon,  database: DatabaseIcon,  desktop: MonitorIcon,  "folder-code": FolderCodeIcon,  game: Gamepad2Icon,  image: ImageIcon,  layers: Layers3Icon,  mobile: SmartphoneIcon,  music: MusicIcon,  package: PackageIcon,  security: ShieldCheckIcon,  server: ServerIcon,  shopping: ShoppingBagIcon,  terminal: TerminalIcon,  test: FlaskConicalIcon,  video: VideoIcon,  web: Globe2Icon,}; const PROJECT_ICON_COLOR_BY_NAME: Record<ProjectIconName, ProjectIconColor> = {  ai: "violet",  book: "amber",  braces: "purple",  circuit: "teal",  cloud: "sky",  code: "blue",  database: "cyan",  desktop: "indigo",  "folder-code": "orange",  game: "emerald",  image: "pink",  layers: "fuchsia",  mobile: "lime",  music: "fuchsia",  package: "orange",  security: "teal",  server: "blue",  shopping: "rose",  terminal: "green",  test: "yellow",  video: "red",  web: "sky",}; // The slice of a project that decides its icon. Every surface must pass the// project record itself (or a snapshot spread from it) so the saved title, favicon// and icon override always travel together. Passing a display label as the title// changes the automatic icon, which is how the command palette drifted once.export type ProjectFaviconProject = Pick<  EnvironmentProject,  "environmentId" | "workspaceRoot" | "title" | "faviconPath" | "projectIcon">; export function ProjectFavicon(input: {  project: ProjectFaviconProject;  className?: string | undefined;  fallbackIcon?: ComponentType<{ className?: string }>;}) {  const { project } = input;  const src = useAtomValue(    projectFaviconUrlAtom({      environmentId: project.environmentId,      cwd: project.workspaceRoot,      faviconPath: project.faviconPath,    }),  );  if (project.projectIcon?.kind === "emoji") {    return <ProjectFaviconFallback className={input.className} emoji={project.projectIcon.emoji} />;  }  if (project.projectIcon?.kind === "lucide") {    const colorClassName = projectIconColorClassName(project.projectIcon.color);    const iconClassName = cn(      "inline-flex size-3.5 shrink-0 items-center justify-center",      colorClassName,      input.className,    );    return (      <span aria-hidden="true" className={iconClassName}>        <Suspense fallback={<DynamicProjectIconFallback />}>          <DynamicIcon            name={project.projectIcon.name as IconName}            className={cn("size-full", colorClassName)}            fallback={DynamicProjectIconFallback}          />        </Suspense>      </span>    );  }  const automaticIconName = input.fallbackIcon    ? null    : selectProjectIcon(project.title, project.workspaceRoot);  const FallbackIcon =    input.fallbackIcon ??    (automaticIconName?.kind === "lucide" ? PROJECT_ICONS[automaticIconName.icon] : undefined);  const fallbackEmoji = automaticIconName?.kind === "emoji" ? automaticIconName.emoji : undefined;  const fallbackColorClassName =    automaticIconName?.kind === "lucide"      ? projectIconColorClassName(PROJECT_ICON_COLOR_BY_NAME[automaticIconName.icon])      : undefined;   if (!src || isProjectFaviconFallbackUrl(src)) {    return (      <ProjectFaviconFallback        className={input.className}        colorClassName={fallbackColorClassName}        icon={FallbackIcon}        emoji={fallbackEmoji}      />    );  }   const cacheKey = getProjectFaviconResourceKey(    project.environmentId,    project.workspaceRoot,    project.faviconPath,  );   return (    <ProjectFaviconImage      key={cacheKey}      src={src}      className={input.className}      fallbackIcon={FallbackIcon}      fallbackEmoji={fallbackEmoji}      fallbackColorClassName={fallbackColorClassName}    />  );} function ProjectFaviconFallback({  className,  colorClassName,  icon: Icon,  emoji,}: {  readonly className?: string | undefined;  readonly colorClassName?: string | undefined;  readonly icon?: ComponentType<{ className?: string }> | undefined;  readonly emoji?: string | undefined;}) {  if (emoji) {    return (      <span        aria-hidden="true"        className={cn(          "inline-flex size-3.5 shrink-0 items-center justify-center leading-none [container-type:size]",          className,        )}      >        <span className="text-[length:80cqh] leading-none">{emoji}</span>      </span>    );  }   if (!Icon) return null;  return <Icon className={cn("size-3.5 shrink-0 text-icon-muted", colorClassName, className)} />;} function ProjectFaviconImage({  src,  className,  fallbackIcon: FallbackIcon,  fallbackEmoji,  fallbackColorClassName,}: {  readonly src: string;  readonly className?: string | undefined;  readonly fallbackIcon?: ComponentType<{ className?: string }> | undefined;  readonly fallbackEmoji?: string | undefined;  readonly fallbackColorClassName?: string | undefined;}) {  const [displayedSrc, setDisplayedSrc] = useState<string | null>(() =>    src.startsWith("data:image/") ? src : null,  );  const isLoading = displayedSrc !== src;  const handleLoadError = (failedSrc: string) => {    setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc));  };   return (    <>      {displayedSrc === null ? (        <ProjectFaviconFallback          className={className}          colorClassName={fallbackColorClassName}          icon={FallbackIcon}          emoji={fallbackEmoji}        />      ) : null}      {displayedSrc ? (        <img          src={displayedSrc}          alt=""          className={cn("size-3.5 shrink-0 rounded-[37.5%] object-contain", className)}          onError={() => handleLoadError(displayedSrc)}        />      ) : null}      {isLoading ? (        <img          src={src}          alt=""          className="hidden"          onLoad={() => {            setDisplayedSrc(src);          }}          onError={() => handleLoadError(src)}        />      ) : null}    </>  );}