1import * as Schema from "effect/Schema";
2import { deriveProjectIdentity } from "../../projectIdentity";
3import { ProjectMonogram } from "../ProjectMonogram";
4import {
5 ProjectMonogramText,
6 type ProjectIconColor,
7 type ProjectIconOverride,
8} from "@t3tools/contracts";
9import { DynamicIcon, type IconName } from "lucide-react/dynamic";
10import { useEffect, useMemo, useRef, useState } from "react";
11import {
12 filterProjectIconNames,
13 firstEmoji,
14 PROJECT_EMOJIS,
15 PROJECT_ICON_COLORS,
16 projectIconColorClassName,
17} from "../../projectIconOptions";
18import { cn } from "~/lib/utils";
19import { Button } from "../ui/button";
20import {
21 Dialog,
22 DialogDescription,
23 DialogFooter,
24 DialogHeader,
25 DialogPanel,
26 DialogPopup,
27 DialogTitle,
28} from "../ui/dialog";
29import { Input } from "../ui/input";
30import { ScrollArea } from "../ui/scroll-area";
31import { Toggle, ToggleGroup } from "../ui/toggle-group";
32
33const DEFAULT_ICON: IconName = "folder-code";
34const isMonogramText = Schema.is(ProjectMonogramText);
35
36function iconLabel(name: string): string {
37 return name
38 .split("-")
39 .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
40 .join(" ");
41}
42
43export function ProjectIconPickerDialog({
44 current,
45 projectName,
46 open,
47 onOpenChange,
48 onSelect,
49}: {
50 readonly current: ProjectIconOverride | null;
51 readonly projectName: string;
52 readonly open: boolean;
53 readonly onOpenChange: (open: boolean) => void;
54 readonly onSelect: (icon: ProjectIconOverride) => void;
55}) {
56 const automatic = deriveProjectIdentity(projectName);
57 const [mode, setMode] = useState<ProjectIconOverride["kind"]>(current?.kind ?? "lucide");
58 const [iconName, setIconName] = useState<IconName>(
59 current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON,
60 );
61 const [color, setColor] = useState<ProjectIconColor>(
62 current && current.kind !== "emoji" ? current.color : automatic.color,
63 );
64 const [letters, setLetters] = useState(
65 current?.kind === "monogram" ? current.text : automatic.monogram,
66 );
67 const [emoji, setEmoji] = useState(current?.kind === "emoji" ? current.emoji : "💻");
68 const [query, setQuery] = useState("");
69 const [customEmoji, setCustomEmoji] = useState("");
70 const previousOpenRef = useRef(false);
71
72 useEffect(() => {
73 if (open && !previousOpenRef.current) {
74 setMode(current?.kind ?? "lucide");
75 setIconName(current?.kind === "lucide" ? (current.name as IconName) : DEFAULT_ICON);
76 setColor(current && current.kind !== "emoji" ? current.color : automatic.color);
77 setLetters(current?.kind === "monogram" ? current.text : automatic.monogram);
78 setEmoji(current?.kind === "emoji" ? current.emoji : "💻");
79 setQuery("");
80 setCustomEmoji("");
81 }
82 previousOpenRef.current = open;
83 }, [current, open, automatic.color, automatic.monogram]);
84
85 const icons = useMemo(() => filterProjectIconNames(query), [query]);
86 const selectedColorClassName = projectIconColorClassName(color);
87 const monogram = letters.normalize("NFKC").trim().toUpperCase();
88 const validMonogram = isMonogramText(monogram);
89 const save = () => {
90 if (mode === "monogram" && !validMonogram) return;
91 onSelect(
92 mode === "monogram"
93 ? { kind: "monogram", text: monogram, color }
94 : mode === "lucide"
95 ? { kind: "lucide", name: iconName, color }
96 : { kind: "emoji", emoji },
97 );
98 onOpenChange(false);
99 };
100
101 return (
102 <Dialog open={open} onOpenChange={onOpenChange}>
103 <DialogPopup className="w-full sm:w-[32rem]">
104 <DialogHeader>
105 <DialogTitle>Choose project icon</DialogTitle>
106 <DialogDescription>Choose an icon, emoji, or monogram.</DialogDescription>
107 </DialogHeader>
108 <DialogPanel className="flex min-h-0 flex-col">
109 <ToggleGroup
110 aria-label="Icon type"
111 variant="segmented"
112 value={[mode]}
113 onValueChange={(next) => {
114 const value = next[0];
115 if (value === "lucide" || value === "emoji" || value === "monogram") setMode(value);
116 }}
117 >
118 <Toggle value="lucide">Icons</Toggle>
119 <Toggle value="emoji">Emoji</Toggle>
120 <Toggle value="monogram">Monogram</Toggle>
121 </ToggleGroup>
122
123 {mode !== "emoji" ? (
124 <div>
125 <div className="mb-2 text-xs font-medium text-muted-foreground">Color</div>
126 <div className="flex flex-wrap gap-1.5" role="group" aria-label="Icon color">
127 {PROJECT_ICON_COLORS.map((option) => (
128 <button
129 key={option.value}
130 type="button"
131 aria-label={option.label}
132 aria-pressed={color === option.value}
133 className={cn(
134 "flex size-6 items-center justify-center rounded-full border border-transparent outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
135 color === option.value && "border-foreground/64",
136 )}
137 onClick={() => setColor(option.value)}
138 >
139 <span className={cn("size-4 rounded-full", option.swatchClassName)} />
140 </button>
141 ))}
142 </div>
143 </div>
144 ) : null}
145
146 {mode === "lucide" ? (
147 <>
148 <Input
149 type="search"
150 value={query}
151 aria-label="Search Lucide icons"
152 placeholder="Search all Lucide icons"
153 onChange={(event) => setQuery(event.currentTarget.value)}
154 />
155 <ScrollArea scrollFade className="max-h-64">
156 <div className="grid grid-cols-8 gap-1 p-0.5 sm:grid-cols-10">
157 {icons.map((name) => (
158 <button
159 key={name}
160 type="button"
161 aria-label={iconLabel(name)}
162 aria-pressed={iconName === name}
163 className={cn(
164 "flex aspect-square items-center justify-center rounded-md border border-transparent outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
165 iconName === name && "border-border bg-accent",
166 selectedColorClassName,
167 )}
168 onClick={() => setIconName(name)}
169 >
170 <DynamicIcon name={name} className="size-5" />
171 </button>
172 ))}
173 </div>
174 </ScrollArea>
175 {icons.length === 0 ? (
176 <p className="py-8 text-center text-sm text-muted-foreground">No icons found.</p>
177 ) : null}
178 </>
179 ) : mode === "monogram" ? (
180 <div className="flex items-center gap-4 py-2">
181 <ProjectMonogram
182 text={validMonogram ? monogram : automatic.monogram}
183 color={color}
184 className="size-12"
185 />
186 <div className="flex-1 space-y-2">
187 <label htmlFor="project-monogram" className="text-sm font-medium">
188 Letters
189 </label>
190 <Input
191 id="project-monogram"
192 value={letters}
193 onChange={(event) => setLetters(event.currentTarget.value)}
194 aria-describedby="project-monogram-hint"
195 aria-invalid={!validMonogram}
196 autoComplete="off"
197 />
198 <p id="project-monogram-hint" className="text-xs text-muted-foreground">
199 One or two letters or numbers.
200 </p>
201 </div>
202 </div>
203 ) : (
204 <>
205 <ScrollArea scrollFade className="max-h-64">
206 <div className="grid grid-cols-8 gap-1 p-0.5 sm:grid-cols-10">
207 {PROJECT_EMOJIS.map((option) => (
208 <button
209 key={option.emoji}
210 type="button"
211 aria-label={option.label}
212 aria-pressed={emoji === option.emoji}
213 className={cn(
214 "flex aspect-square items-center justify-center rounded-md border border-transparent text-xl outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
215 emoji === option.emoji && "border-border bg-accent",
216 )}
217 onClick={() => setEmoji(option.emoji)}
218 >
219 {option.emoji}
220 </button>
221 ))}
222 </div>
223 </ScrollArea>
224 <div>
225 <div className="mb-2 text-xs font-medium text-muted-foreground">
226 Or paste any emoji
227 </div>
228 <Input
229 value={customEmoji}
230 aria-label="Custom emoji"
231 placeholder="Paste an emoji"
232 onChange={(event) => {
233 const value = event.currentTarget.value;
234 setCustomEmoji(value);
235 const nextEmoji = firstEmoji(value);
236 if (nextEmoji) setEmoji(nextEmoji);
237 }}
238 />
239 </div>
240 </>
241 )}
242 </DialogPanel>
243 <DialogFooter>
244 <Button variant="outline" onClick={() => onOpenChange(false)}>
245 Cancel
246 </Button>
247 <Button onClick={save} disabled={mode === "monogram" && !validMonogram}>
248 Save icon
249 </Button>
250 </DialogFooter>
251 </DialogPopup>
252 </Dialog>
253 );
254}