Skip to content
Source
Sign inSign up

projectIcon.ts

132 lines5.4 KB

1import { isScratchProject } from "@t3tools/client-runtime/state/projects";
2import { ProjectIconColor, type ProjectIconOverride } from "@t3tools/contracts";
3import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon";
4import type { AppSymbolName } from "../components/ui/AppSymbol";
5​
6export type ProjectIconGlyph =
7 | { readonly kind: "emoji"; readonly emoji: string }
8 | { readonly kind: "monogram"; readonly text: string; readonly color: ProjectIconColor };
9​
10/** Hermes lacks Intl.Segmenter; fold combining marks into base characters instead. */
11export function countGlyphs(text: string): number {
12 return Array.from(text.replace(/\p{M}/gu, "")).length;
14​
15function projectMonogram(projectName: string): string {
16 const words =
17 projectName
18 .normalize("NFKC")
19 .trim()
20 .match(/[\p{L}\p{N}]+/gu) ?? [];
21 const firstWord = words[0];
22 if (!firstWord) return "PR";
23​
24 const glyphs = Array.from(firstWord);
25 const first = glyphs[0] ?? "P";
26 const second =
27 glyphs.slice(1).find((glyph) => /\p{N}/u.test(glyph)) ??
28 (words.length > 1 ? Array.from(words.at(-1) ?? "")[0] : glyphs.at(-1)) ??
29 first;
30 return Array.from(`${first}${second}`.toUpperCase()).slice(0, 2).join("");
32​
33function projectMonogramColor(projectName: string): ProjectIconColor {
34 const seed = projectName.normalize("NFKC").trim().toLocaleLowerCase("en-US") || "project";
35 let index = 0;
36 for (const glyph of seed) {
37 index = (index * 31 + (glyph.codePointAt(0) ?? 0)) % ProjectIconColor.literals.length;
38 }
39 return ProjectIconColor.literals[index] ?? "blue";
41​
42export function projectAutoMonogram(projectTitle: string): ProjectIconGlyph | null {
43 if (!projectTitle.trim()) return null;
44 return {
45 kind: "monogram",
46 text: projectMonogram(projectTitle),
47 color: projectMonogramColor(projectTitle),
48 };
50​
51export function resolveProjectIconGlyph(
52 projectIcon: ProjectIconOverride | null | undefined,
53 projectTitle: string,
54): ProjectIconGlyph | null {
55 switch (projectIcon?.kind) {
56 case "emoji":
57 return { kind: "emoji", emoji: projectIcon.emoji };
58 case "monogram":
59 return { kind: "monogram", text: projectIcon.text, color: projectIcon.color };
60 case "lucide":
61 return { kind: "monogram", text: projectMonogram(projectTitle), color: projectIcon.color };
62 case undefined:
63 return null;
64 }
66​
67const SYMBOL_BY_LUCIDE_NAME: Partial<Record<string, AppSymbolName>> = {
68 "message-square-dashed": "text.bubble",
69};
70​
71export function resolveProjectIconSymbol(
72 projectIcon: ProjectIconOverride | null | undefined,
73): { readonly name: AppSymbolName; readonly color: ProjectIconColor } | null {
74 if (projectIcon?.kind !== "lucide") return null;
75 const name = SYMBOL_BY_LUCIDE_NAME[projectIcon.name];
76 return name ? { name, color: projectIcon.color } : null;
78​
79export type ProjectChipIcon = { readonly kind: "favicon"; readonly uri: string } | ProjectIconGlyph;
80​
81export function resolveProjectChipIcon(
82 project: {
83 readonly title: string;
84 readonly workspaceRoot: string;
85 readonly projectIcon?: ProjectIconOverride | null;
86 } | null,
87 scratchWorkspaceRoot: string | null | undefined,
88 faviconUrl: () => string | null,
89): ProjectChipIcon | null {
90 if (!project || isScratchProject(project, scratchWorkspaceRoot)) return null;
91 const glyph = resolveProjectIconGlyph(project.projectIcon, project.title);
92 if (glyph) return glyph;
93 const url = faviconUrl();
94 if (url && !isProjectFaviconFallbackUrl(url)) return { kind: "favicon", uri: url };
95 return projectAutoMonogram(project.title);
97​
98const PROJECT_ICON_COLOR_CLASSES: Record<
99 ProjectIconColor,
100 { readonly text: string; readonly tint: string; readonly background: string }
101> = {
102 gray: { text: "text-gray-500", tint: "accent-gray-500", background: "bg-gray-500/15" },
103 red: { text: "text-red-500", tint: "accent-red-500", background: "bg-red-500/15" },
104 orange: { text: "text-orange-500", tint: "accent-orange-500", background: "bg-orange-500/15" },
105 amber: { text: "text-amber-500", tint: "accent-amber-500", background: "bg-amber-500/15" },
106 yellow: { text: "text-yellow-500", tint: "accent-yellow-500", background: "bg-yellow-500/15" },
107 lime: { text: "text-lime-500", tint: "accent-lime-500", background: "bg-lime-500/15" },
108 green: { text: "text-green-500", tint: "accent-green-500", background: "bg-green-500/15" },
109 emerald: {
110 text: "text-emerald-500",
111 tint: "accent-emerald-500",
112 background: "bg-emerald-500/15",
113 },
114 teal: { text: "text-teal-500", tint: "accent-teal-500", background: "bg-teal-500/15" },
115 cyan: { text: "text-cyan-500", tint: "accent-cyan-500", background: "bg-cyan-500/15" },
116 sky: { text: "text-sky-500", tint: "accent-sky-500", background: "bg-sky-500/15" },
117 blue: { text: "text-blue-500", tint: "accent-blue-500", background: "bg-blue-500/15" },
118 indigo: { text: "text-indigo-500", tint: "accent-indigo-500", background: "bg-indigo-500/15" },
119 violet: { text: "text-violet-500", tint: "accent-violet-500", background: "bg-violet-500/15" },
120 purple: { text: "text-purple-500", tint: "accent-purple-500", background: "bg-purple-500/15" },
121 fuchsia: {
122 text: "text-fuchsia-500",
123 tint: "accent-fuchsia-500",
124 background: "bg-fuchsia-500/15",
125 },
126 pink: { text: "text-pink-500", tint: "accent-pink-500", background: "bg-pink-500/15" },
127 rose: { text: "text-rose-500", tint: "accent-rose-500", background: "bg-rose-500/15" },
128};
129​
130export function projectIconColorClassNames(color: ProjectIconColor) {
131 return PROJECT_ICON_COLOR_CLASSES[color];