Skip to content
Source
Sign inSign up

ProjectFavicon.tsx

202 lines5.7 KB

1import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
2import {
3 getProjectFaviconResourceKey,
4 isProjectFaviconFallbackUrl,
5} from "@t3tools/shared/projectFavicon";
6import { FolderCodeIcon } from "lucide-react";
7import type { ComponentType } from "react";
8import { createElement, lazy, Suspense, useState } from "react";
9import { useAtomValue } from "@effect/atom-react";
10import { projectFaviconUrlAtom } from "../state/assets";
11import { deriveProjectIdentity } from "../projectIdentity";
12import { projectIconColorClassName } from "../projectIconColors";
13import { ProjectMonogram } from "./ProjectMonogram";
14import { cn } from "~/lib/utils";
15​
16// All lucide icons load as one chunk, only once a project uses a lucide override.
17const LucideProjectIcon = lazy(() =>
18 import("../lib/lucideIconByName").then(({ lucideIconByName }) => ({
19 default: function LucideProjectIcon(props: { name: string; className: string }) {
20 return createElement(lucideIconByName(props.name) ?? FolderCodeIcon, {
21 className: props.className,
22 });
23 },
24 })),
25);
26​
27function DynamicProjectIconFallback() {
28 return <FolderCodeIcon className="size-full text-inherit" />;
30​
31export type ProjectFaviconProject = Pick<
32 EnvironmentProject,
33 "environmentId" | "workspaceRoot" | "title" | "faviconPath" | "projectIcon"
34>;
35export function ProjectFavicon(input: {
36 project: ProjectFaviconProject;
37 className?: string | undefined;
38 fallbackIcon?: ComponentType<{ className?: string }>;
39}) {
40 const { project } = input;
41 if (project.projectIcon?.kind === "monogram") {
42 return (
43 <ProjectMonogram
44 text={project.projectIcon.text}
45 color={project.projectIcon.color}
46 className={input.className}
47 />
48 );
49 }
50 if (project.projectIcon?.kind === "emoji") {
51 return (
52 <ProjectFaviconFallback
53 className={input.className}
54 icon={FolderCodeIcon}
55 emoji={project.projectIcon.emoji}
56 />
57 );
58 }
59 if (project.projectIcon?.kind === "lucide") {
60 const colorClassName = projectIconColorClassName(project.projectIcon.color);
61 const iconClassName = cn(
62 "inline-flex size-3.5 shrink-0 items-center justify-center",
63 colorClassName,
64 input.className,
65 );
66 return (
67 <span aria-hidden="true" className={iconClassName}>
68 <Suspense fallback={<DynamicProjectIconFallback />}>
69 <LucideProjectIcon
70 name={project.projectIcon.name}
71 className={cn("size-full", colorClassName)}
72 />
73 </Suspense>
74 </span>
75 );
76 }
77 return <AutomaticProjectFavicon {...input} />;
79​
80function AutomaticProjectFavicon(input: Parameters<typeof ProjectFavicon>[0]) {
81 const { project } = input;
82 const src = useAtomValue(
83 projectFaviconUrlAtom({
84 environmentId: project.environmentId,
85 cwd: project.workspaceRoot,
86 faviconPath: project.faviconPath,
87 }),
88 );
89 const FallbackIcon = input.fallbackIcon ?? FolderCodeIcon;
90​
91 if (!src || isProjectFaviconFallbackUrl(src)) {
92 return (
93 <ProjectFaviconFallback
94 className={input.className}
95 icon={FallbackIcon}
96 projectName={project.title}
97 />
98 );
99 }
100​
101 const cacheKey = getProjectFaviconResourceKey(
102 project.environmentId,
103 project.workspaceRoot,
104 project.faviconPath,
105 );
106​
107 return (
108 <ProjectFaviconImage
109 key={cacheKey}
110 src={src}
111 className={input.className}
112 fallbackIcon={FallbackIcon}
113 fallbackProjectName={project.title}
114 />
115 );
117​
118function ProjectFaviconFallback({
119 className,
120 icon: Icon,
121 emoji,
122 projectName,
123}: {
124 readonly className?: string | undefined;
125 readonly icon: ComponentType<{ className?: string }>;
126 readonly emoji?: string | undefined;
127 readonly projectName?: string | undefined;
128}) {
129 if (projectName && projectName.trim().length > 0) {
130 const identity = deriveProjectIdentity(projectName);
131 return (
132 <ProjectMonogram text={identity.monogram} color={identity.color} className={className} />
133 );
134 }
135​
136 if (emoji) {
137 return (
138 <span
139 aria-hidden="true"
140 className={cn(
141 "inline-flex size-3.5 shrink-0 items-center justify-center leading-none [container-type:size]",
142 className,
143 )}
144 >
145 <span className="text-[length:80cqh] leading-none">{emoji}</span>
146 </span>
147 );
148 }
149​
150 return <Icon className={cn("size-3.5 shrink-0 text-icon-muted", className)} />;
152​
153function ProjectFaviconImage({
154 src,
155 className,
156 fallbackIcon: FallbackIcon,
157 fallbackProjectName,
158}: {
159 readonly src: string;
160 readonly className?: string | undefined;
161 readonly fallbackIcon: ComponentType<{ className?: string }>;
162 readonly fallbackProjectName?: string | undefined;
163}) {
164 const [displayedSrc, setDisplayedSrc] = useState<string | null>(() =>
165 src.startsWith("data:image/") ? src : null,
166 );
167 const isLoading = displayedSrc !== src;
168 const handleLoadError = (failedSrc: string) => {
169 setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc));
170 };
171​
172 return (
173 <>
174 {displayedSrc === null ? (
175 <ProjectFaviconFallback
176 className={className}
177 icon={FallbackIcon}
178 projectName={fallbackProjectName}
179 />
180 ) : null}
181 {displayedSrc ? (
182 <img
183 src={displayedSrc}
184 alt=""
185 className={cn("size-3.5 shrink-0 rounded-[25%] object-contain", className)}
186 onError={() => handleLoadError(displayedSrc)}
187 />
188 ) : null}
189 {isLoading ? (
190 <img
191 src={src}
192 alt=""
193 className="hidden"
194 onLoad={() => {
195 setDisplayedSrc(src);
196 }}
197 onError={() => handleLoadError(src)}
198 />
199 ) : null}
200 </>
201 );