| 1 | import type { ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; |
| 2 | |
| 3 | export type ProjectIconGlyph = |
| 4 | | { readonly kind: "emoji"; readonly emoji: string } |
| 5 | | { readonly kind: "monogram"; readonly text: string; readonly color: ProjectIconColor }; |
| 6 | |
| 7 | /** |
| 8 | * Visible glyph count for sizing monogram text. Hermes has no Intl.Segmenter, so combining |
| 9 | * marks are folded into their base character instead of full grapheme segmentation. |
| 10 | */ |
| 11 | export function countGlyphs(text: string): number { |
| 12 | return Array.from(text.replace(/\p{M}/gu, "")).length; |
| 13 | } |
| 14 | |
| 15 | /** Mirrors the automatic monogram web derives from a project name when it has no favicon. */ |
| 16 | export function projectMonogram(projectName: string): string { |
| 17 | const words = |
| 18 | projectName |
| 19 | .normalize("NFKC") |
| 20 | .trim() |
| 21 | .match(/[\p{L}\p{N}]+/gu) ?? []; |
| 22 | const firstWord = words[0]; |
| 23 | if (!firstWord) return "PR"; |
| 24 | |
| 25 | const glyphs = Array.from(firstWord); |
| 26 | const first = glyphs[0] ?? "P"; |
| 27 | const second = |
| 28 | glyphs.slice(1).find((glyph) => /\p{N}/u.test(glyph)) ?? |
| 29 | (words.length > 1 ? Array.from(words.at(-1) ?? "")[0] : glyphs.at(-1)) ?? |
| 30 | first; |
| 31 | return Array.from(`${first}${second}`.toUpperCase()).slice(0, 2).join(""); |
| 32 | } |
| 33 | |
| 34 | /** |
| 35 | * Picks what mobile draws for an assigned project icon. Mobile does not bundle |
| 36 | * the Lucide set, so a Lucide override keeps its color and falls back to the |
| 37 | * project's monogram instead of the folder glyph. |
| 38 | */ |
| 39 | export function resolveProjectIconGlyph( |
| 40 | projectIcon: ProjectIconOverride | null | undefined, |
| 41 | projectTitle: string, |
| 42 | ): ProjectIconGlyph | null { |
| 43 | switch (projectIcon?.kind) { |
| 44 | case "emoji": |
| 45 | return { kind: "emoji", emoji: projectIcon.emoji }; |
| 46 | case "monogram": |
| 47 | return { kind: "monogram", text: projectIcon.text, color: projectIcon.color }; |
| 48 | case "lucide": |
| 49 | return { kind: "monogram", text: projectMonogram(projectTitle), color: projectIcon.color }; |
| 50 | case undefined: |
| 51 | return null; |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | const PROJECT_ICON_COLOR_CLASSES: Record< |
| 56 | ProjectIconColor, |
| 57 | { readonly text: string; readonly background: string } |
| 58 | > = { |
| 59 | gray: { text: "text-gray-500", background: "bg-gray-500/15" }, |
| 60 | red: { text: "text-red-500", background: "bg-red-500/15" }, |
| 61 | orange: { text: "text-orange-500", background: "bg-orange-500/15" }, |
| 62 | amber: { text: "text-amber-500", background: "bg-amber-500/15" }, |
| 63 | yellow: { text: "text-yellow-500", background: "bg-yellow-500/15" }, |
| 64 | lime: { text: "text-lime-500", background: "bg-lime-500/15" }, |
| 65 | green: { text: "text-green-500", background: "bg-green-500/15" }, |
| 66 | emerald: { text: "text-emerald-500", background: "bg-emerald-500/15" }, |
| 67 | teal: { text: "text-teal-500", background: "bg-teal-500/15" }, |
| 68 | cyan: { text: "text-cyan-500", background: "bg-cyan-500/15" }, |
| 69 | sky: { text: "text-sky-500", background: "bg-sky-500/15" }, |
| 70 | blue: { text: "text-blue-500", background: "bg-blue-500/15" }, |
| 71 | indigo: { text: "text-indigo-500", background: "bg-indigo-500/15" }, |
| 72 | violet: { text: "text-violet-500", background: "bg-violet-500/15" }, |
| 73 | purple: { text: "text-purple-500", background: "bg-purple-500/15" }, |
| 74 | fuchsia: { text: "text-fuchsia-500", background: "bg-fuchsia-500/15" }, |
| 75 | pink: { text: "text-pink-500", background: "bg-pink-500/15" }, |
| 76 | rose: { text: "text-rose-500", background: "bg-rose-500/15" }, |
| 77 | }; |
| 78 | |
| 79 | export function projectIconColorClassNames(color: ProjectIconColor) { |
| 80 | return PROJECT_ICON_COLOR_CLASSES[color]; |
| 81 | } |