Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions apps/web/src/components/ProjectFavicon.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,6 @@ vi.mock("react", async (importOriginal) => {
});

vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache }));
vi.mock("lucide-react/dynamic", () => ({
DynamicIcon: "dynamic-icon",
iconNames: ["alarm-clock", "folder-code"],
}));
vi.mock("@effect/atom-react", () => ({
useAtomValue: () => testState.faviconUrl,
}));
Expand Down
19 changes: 12 additions & 7 deletions apps/web/src/components/ProjectFavicon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,24 @@ import {
isProjectFaviconFallbackUrl,
} from "@t3tools/shared/projectFavicon";
import { FolderCodeIcon } from "lucide-react";
import type { IconName } from "lucide-react/dynamic";
import type { ComponentType } from "react";
import { lazy, Suspense, useState } from "react";
import { createElement, lazy, Suspense, useState } from "react";
import { useAtomValue } from "@effect/atom-react";
import { projectFaviconUrlAtom } from "../state/assets";
import { deriveProjectIdentity } from "../projectIdentity";
import { projectIconColorClassName } from "../projectIconColors";
import { ProjectMonogram } from "./ProjectMonogram";
import { cn } from "~/lib/utils";

const DynamicIcon = lazy(() =>
import("lucide-react/dynamic").then((module) => ({ default: module.DynamicIcon })),
// All lucide icons load as one chunk, only once a project uses a lucide override.
const LucideProjectIcon = lazy(() =>
import("../lib/lucideIconByName").then(({ lucideIconByName }) => ({
default: function LucideProjectIcon(props: { name: string; className: string }) {
return createElement(lucideIconByName(props.name) ?? FolderCodeIcon, {
className: props.className,
});
},
})),
);

function DynamicProjectIconFallback() {
Expand Down Expand Up @@ -71,10 +77,9 @@ export function ProjectFavicon(input: {
return (
<span aria-hidden="true" className={iconClassName}>
<Suspense fallback={<DynamicProjectIconFallback />}>
<DynamicIcon
name={project.projectIcon.name as IconName}
<LucideProjectIcon
name={project.projectIcon.name}
className={cn("size-full", colorClassName)}
fallback={DynamicProjectIconFallback}
/>
</Suspense>
</span>
Expand Down
19 changes: 12 additions & 7 deletions apps/web/src/components/settings/ProjectIconPickerDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import {
type ProjectIconColor,
type ProjectIconOverride,
} from "@t3tools/contracts";
import { DynamicIcon, type IconName } from "lucide-react/dynamic";
import { useEffect, useMemo, useRef, useState } from "react";
import { createElement, useEffect, useMemo, useRef, useState } from "react";
import {
filterProjectIconNames,
firstEmoji,
Expand All @@ -16,6 +15,7 @@ import {
projectIconColorClassName,
} from "../../projectIconOptions";
import { cn } from "~/lib/utils";
import { lucideIconByName } from "../../lib/lucideIconByName";
import { Button } from "../ui/button";
import {
Dialog,
Expand All @@ -30,9 +30,14 @@ import { Input } from "../ui/input";
import { ScrollArea } from "../ui/scroll-area";
import { Toggle, ToggleGroup } from "../ui/toggle-group";

const DEFAULT_ICON: IconName = "folder-code";
const DEFAULT_ICON = "folder-code";
const isMonogramText = Schema.is(ProjectMonogramText);

function PickerIcon({ name }: { name: string }) {
const icon = lucideIconByName(name);
return icon ? createElement(icon, { className: "size-5" }) : null;
}

function iconLabel(name: string): string {
return name
.split("-")
Expand All @@ -55,8 +60,8 @@ export function ProjectIconPickerDialog({
}) {
const automatic = deriveProjectIdentity(projectName);
const [mode, setMode] = useState<ProjectIconOverride["kind"]>(current?.kind ?? "lucide");
const [iconName, setIconName] = useState<IconName>(
current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON,
const [iconName, setIconName] = useState<string>(
current?.kind === "lucide" ? current.name : DEFAULT_ICON,
);
const [color, setColor] = useState<ProjectIconColor>(
current && current.kind !== "emoji" ? current.color : automatic.color,
Expand All @@ -72,7 +77,7 @@ export function ProjectIconPickerDialog({
useEffect(() => {
if (open && !previousOpenRef.current) {
setMode(current?.kind ?? "lucide");
setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON);
setIconName(current?.kind === "lucide" ? current.name : DEFAULT_ICON);
setColor(current && current.kind !== "emoji" ? current.color : automatic.color);
setLetters(current?.kind === "monogram" ? current.text : automatic.monogram);
setEmoji(current?.kind === "emoji" ? current.emoji : "💻");
Expand Down Expand Up @@ -167,7 +172,7 @@ export function ProjectIconPickerDialog({
)}
onClick={() => setIconName(name)}
>
<DynamicIcon name={name} className="size-5" />
<PickerIcon name={name} />
</button>
))}
</div>
Expand Down
32 changes: 32 additions & 0 deletions apps/web/src/lib/lucideIconByName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/* oxlint-disable eslint/no-restricted-imports -- Resolves saved project icons by name, so it needs the whole set. */
// Project icon overrides save lucide icons by kebab-case name. Import this module
// lazily: it holds every lucide icon as one chunk, where `lucide-react/dynamic`
// emits a separate chunk per icon (~1,900), which dominated build output.
import * as Lucide from "lucide-react";
import type { LucideIcon } from "lucide-react";

const iconComponents = new Set<unknown>(Object.values(Lucide.icons));
const lucideExports: Record<string, unknown> = Lucide;

// Lucide derives export names this way, so it also resolves its deprecated
// aliases ("alarm-check" -> AlarmCheck) that older overrides may have saved.
function toPascalName(name: string): string {
return name
.split("-")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
}

// Inverse of toPascalName for canonical exports: "ArrowDown01" -> "arrow-down-01".
// Lucide spells a handful of digit names differently ("arrow-down-0-1"); both
// forms resolve to the same icon through toPascalName.
function toKebabName(pascalName: string): string {
return (pascalName.match(/[A-Z][a-z]*|[0-9]+[a-z]*/g) ?? []).join("-").toLowerCase();
}

export const lucideIconNames: ReadonlyArray<string> = Object.keys(Lucide.icons).map(toKebabName);

export function lucideIconByName(name: string): LucideIcon | undefined {
const icon = lucideExports[toPascalName(name)];
return iconComponents.has(icon) ? (icon as LucideIcon) : undefined;
}
11 changes: 11 additions & 0 deletions apps/web/src/projectIconOptions.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { AlarmClockCheck, ArrowDown01, Layers3 } from "lucide-react";
import { describe, expect, it } from "vite-plus/test";
import { lucideIconByName } from "./lib/lucideIconByName";
import {
filterProjectIconNames,
firstEmoji,
Expand All @@ -11,6 +13,15 @@ describe("projectIconOptions", () => {
expect(filterProjectIconNames("alarm \tclock")).toContain("alarm-clock");
});

it("resolves every offered icon name, including saved legacy spellings", () => {
for (const name of filterProjectIconNames("")) expect(lucideIconByName(name)).toBeDefined();
expect(lucideIconByName("layers-3")).toBe(Layers3);
expect(lucideIconByName("arrow-down-01")).toBe(ArrowDown01);
expect(lucideIconByName("arrow-down-0-1")).toBe(ArrowDown01);
expect(lucideIconByName("alarm-check")).toBe(AlarmClockCheck);
expect(lucideIconByName("create-lucide-icon")).toBeUndefined();
});

it("extracts one complete emoji grapheme", () => {
expect(firstEmoji(" 👩🏽‍💻 hello")).toBe("👩🏽‍💻");
expect(firstEmoji("🇺🇸 project")).toBe("🇺🇸");
Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/projectIconOptions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { iconNames, type IconName } from "lucide-react/dynamic";
import { lucideIconNames } from "./lib/lucideIconByName";
export { PROJECT_ICON_COLORS, projectIconColorClassName } from "./projectIconColors";

const POPULAR_PROJECT_ICONS = [
const POPULAR_PROJECT_ICONS: ReadonlyArray<string> = [
"folder-code",
"code-2",
"terminal",
Expand All @@ -26,7 +26,7 @@ const POPULAR_PROJECT_ICONS = [
"workflow",
"wrench",
"layers-3",
] as const satisfies ReadonlyArray<IconName>;
];

export const PROJECT_EMOJIS: ReadonlyArray<{ readonly emoji: string; readonly label: string }> = [
{ emoji: "💻", label: "Computer" },
Expand Down Expand Up @@ -61,10 +61,10 @@ export const PROJECT_EMOJIS: ReadonlyArray<{ readonly emoji: string; readonly la
{ emoji: "🌱", label: "Seedling" },
];

export function filterProjectIconNames(query: string): ReadonlyArray<IconName> {
export function filterProjectIconNames(query: string): ReadonlyArray<string> {
const normalized = query.trim().toLowerCase().replaceAll(/\s+/g, "-");
if (!normalized) return POPULAR_PROJECT_ICONS;
return iconNames.filter((name) => name.includes(normalized)).slice(0, 60);
return lucideIconNames.filter((name) => name.includes(normalized)).slice(0, 60);
}

export function firstEmoji(value: string): string | null {
Expand Down
Loading