| 1 | import * as Schema from "effect/Schema"; |
| 2 | import { deriveProjectIdentity } from "../../projectIdentity"; |
| 3 | import { ProjectMonogram } from "../ProjectMonogram"; |
| 4 | import { |
| 5 | ProjectMonogramText, |
| 6 | type ProjectIconColor, |
| 7 | type ProjectIconOverride, |
| 8 | } from "@t3tools/contracts"; |
| 9 | import { DynamicIcon, type IconName } from "lucide-react/dynamic"; |
| 10 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 11 | import { |
| 12 | filterProjectIconNames, |
| 13 | firstEmoji, |
| 14 | PROJECT_EMOJIS, |
| 15 | PROJECT_ICON_COLORS, |
| 16 | projectIconColorClassName, |
| 17 | } from "../../projectIconOptions"; |
| 18 | import { cn } from "~/lib/utils"; |
| 19 | import { Button } from "../ui/button"; |
| 20 | import { |
| 21 | Dialog, |
| 22 | DialogDescription, |
| 23 | DialogFooter, |
| 24 | DialogHeader, |
| 25 | DialogPanel, |
| 26 | DialogPopup, |
| 27 | DialogTitle, |
| 28 | } from "../ui/dialog"; |
| 29 | import { Input } from "../ui/input"; |
| 30 | import { ScrollArea } from "../ui/scroll-area"; |
| 31 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; |
| 32 | |
| 33 | const DEFAULT_ICON: IconName = "folder-code"; |
| 34 | const isMonogramText = Schema.is(ProjectMonogramText); |
| 35 | |
| 36 | function iconLabel(name: string): string { |
| 37 | return name |
| 38 | .split("-") |
| 39 | .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) |
| 40 | .join(" "); |
| 41 | } |
| 42 | |
| 43 | export 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 | } |