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