| 1 | "use client"; |
| 2 | |
| 3 | import { type IconType } from "react-icons"; |
| 4 | |
| 5 | type IconModule = Record<string, IconType>; |
| 6 | |
| 7 | type IconLibraryKey = |
| 8 | | "fa" |
| 9 | | "fi" |
| 10 | | "ai" |
| 11 | | "bs" |
| 12 | | "bi" |
| 13 | | "gi" |
| 14 | | "hi" |
| 15 | | "im" |
| 16 | | "io" |
| 17 | | "md" |
| 18 | | "ri" |
| 19 | | "si" |
| 20 | | "ti" |
| 21 | | "vsc" |
| 22 | | "wi"; |
| 23 | |
| 24 | type SearchableIcon = { |
| 25 | Component: IconType; |
| 26 | name: string; |
| 27 | normalizedKey: string; |
| 28 | normalizedLabel: string; |
| 29 | }; |
| 30 | |
| 31 | export type ResolvedPresentationIcon = { |
| 32 | Component: IconType; |
| 33 | name: string; |
| 34 | }; |
| 35 | |
| 36 | export const DEFAULT_PRESENTATION_ICON = "FaHome"; |
| 37 | |
| 38 | const ICON_LIBRARY_LOADERS: Record<IconLibraryKey, () => Promise<IconModule>> = |
| 39 | { |
| 40 | fa: async () => (await import("react-icons/fa")) as unknown as IconModule, |
| 41 | fi: async () => (await import("react-icons/fi")) as unknown as IconModule, |
| 42 | ai: async () => (await import("react-icons/ai")) as unknown as IconModule, |
| 43 | bs: async () => (await import("react-icons/bs")) as unknown as IconModule, |
| 44 | bi: async () => (await import("react-icons/bi")) as unknown as IconModule, |
| 45 | gi: async () => (await import("react-icons/gi")) as unknown as IconModule, |
| 46 | hi: async () => (await import("react-icons/hi")) as unknown as IconModule, |
| 47 | im: async () => (await import("react-icons/im")) as unknown as IconModule, |
| 48 | io: async () => (await import("react-icons/io")) as unknown as IconModule, |
| 49 | md: async () => (await import("react-icons/md")) as unknown as IconModule, |
| 50 | ri: async () => (await import("react-icons/ri")) as unknown as IconModule, |
| 51 | si: async () => (await import("react-icons/si")) as unknown as IconModule, |
| 52 | ti: async () => (await import("react-icons/ti")) as unknown as IconModule, |
| 53 | vsc: async () => (await import("react-icons/vsc")) as unknown as IconModule, |
| 54 | wi: async () => (await import("react-icons/wi")) as unknown as IconModule, |
| 55 | }; |
| 56 | |
| 57 | const ICON_LIBRARY_PREFIXES: ReadonlyArray<readonly [string, IconLibraryKey]> = |
| 58 | [ |
| 59 | ["vsc", "vsc"], |
| 60 | ["fa", "fa"], |
| 61 | ["fi", "fi"], |
| 62 | ["ai", "ai"], |
| 63 | ["bs", "bs"], |
| 64 | ["bi", "bi"], |
| 65 | ["gi", "gi"], |
| 66 | ["hi", "hi"], |
| 67 | ["im", "im"], |
| 68 | ["io", "io"], |
| 69 | ["md", "md"], |
| 70 | ["ri", "ri"], |
| 71 | ["si", "si"], |
| 72 | ["ti", "ti"], |
| 73 | ["wi", "wi"], |
| 74 | ]; |
| 75 | |
| 76 | const ICON_SEARCH_ORDER: ReadonlyArray<IconLibraryKey> = [ |
| 77 | "fa", |
| 78 | "fi", |
| 79 | "ai", |
| 80 | "bs", |
| 81 | "bi", |
| 82 | "md", |
| 83 | "ri", |
| 84 | "si", |
| 85 | "gi", |
| 86 | "hi", |
| 87 | "im", |
| 88 | "io", |
| 89 | "ti", |
| 90 | "vsc", |
| 91 | "wi", |
| 92 | ]; |
| 93 | |
| 94 | const POPULAR_ICON_NAMES: ReadonlyArray<string> = [ |
| 95 | "FaHome", |
| 96 | "FaUser", |
| 97 | "FaCog", |
| 98 | "FaSearch", |
| 99 | "FaBell", |
| 100 | "FaCalendar", |
| 101 | "FaEnvelope", |
| 102 | "FaHeart", |
| 103 | "FaStar", |
| 104 | "FaBookmark", |
| 105 | "FaCheck", |
| 106 | "FaTimes", |
| 107 | "FaEdit", |
| 108 | "FaTrash", |
| 109 | "FaDownload", |
| 110 | "FaUpload", |
| 111 | "FaShare", |
| 112 | "FaLink", |
| 113 | "FaMapMarker", |
| 114 | "FaClock", |
| 115 | "FaCamera", |
| 116 | "FaVideo", |
| 117 | "FaMusic", |
| 118 | "FaFile", |
| 119 | "FaFolder", |
| 120 | "FaComments", |
| 121 | "FaThumbsUp", |
| 122 | "FaPhone", |
| 123 | "FaLock", |
| 124 | "FaUserPlus", |
| 125 | ]; |
| 126 | |
| 127 | const iconModuleCache = new Map<IconLibraryKey, Promise<IconModule>>(); |
| 128 | const resolvedIconCache = new Map< |
| 129 | string, |
| 130 | Promise<ResolvedPresentationIcon | null> |
| 131 | >(); |
| 132 | let iconSearchIndexPromise: Promise<SearchableIcon[]> | null = null; |
| 133 | |
| 134 | function normalizeWhitespace(value: string) { |
| 135 | return value.trim().replace(/\s+/g, " "); |
| 136 | } |
| 137 | |
| 138 | function stripLibraryPrefix(value: string) { |
| 139 | const normalized = value.trim(); |
| 140 | const lowerValue = normalized.toLowerCase(); |
| 141 | |
| 142 | for (const [prefix] of ICON_LIBRARY_PREFIXES) { |
| 143 | if (lowerValue.startsWith(prefix)) { |
| 144 | return normalized.slice(prefix.length); |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | return normalized; |
| 149 | } |
| 150 | |
| 151 | function normalizeForSearch(value: string) { |
| 152 | return stripLibraryPrefix(value) |
| 153 | .replace(/([a-z0-9])([A-Z])/g, "$1 $2") |
| 154 | .replace(/[_-]+/g, " ") |
| 155 | .replace(/[^a-zA-Z0-9 ]+/g, " ") |
| 156 | .toLowerCase() |
| 157 | .replace(/\s+/g, " ") |
| 158 | .trim(); |
| 159 | } |
| 160 | |
| 161 | function tokenizeSearchValue(value: string) { |
| 162 | return normalizeForSearch(value).split(" ").filter(Boolean); |
| 163 | } |
| 164 | |
| 165 | function getIconLibraryHint(iconName: string) { |
| 166 | const normalizedName = iconName.trim().toLowerCase(); |
| 167 | |
| 168 | for (const [prefix, libraryKey] of ICON_LIBRARY_PREFIXES) { |
| 169 | if (normalizedName.startsWith(prefix)) { |
| 170 | return libraryKey; |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | return undefined; |
| 175 | } |
| 176 | |
| 177 | async function loadIconModule(libraryKey: IconLibraryKey) { |
| 178 | const cachedModule = iconModuleCache.get(libraryKey); |
| 179 | |
| 180 | if (cachedModule) { |
| 181 | return cachedModule; |
| 182 | } |
| 183 | |
| 184 | const nextModulePromise = ICON_LIBRARY_LOADERS[libraryKey](); |
| 185 | iconModuleCache.set(libraryKey, nextModulePromise); |
| 186 | return nextModulePromise; |
| 187 | } |
| 188 | |
| 189 | async function buildIconSearchIndex() { |
| 190 | const modules = await Promise.all( |
| 191 | ICON_SEARCH_ORDER.map(async (libraryKey) => ({ |
| 192 | libraryKey, |
| 193 | module: await loadIconModule(libraryKey), |
| 194 | })), |
| 195 | ); |
| 196 | |
| 197 | return modules.flatMap(({ module }) => |
| 198 | Object.entries(module).map(([name, Component]) => ({ |
| 199 | Component, |
| 200 | name, |
| 201 | normalizedKey: name.toLowerCase(), |
| 202 | normalizedLabel: normalizeForSearch(name), |
| 203 | })), |
| 204 | ); |
| 205 | } |
| 206 | |
| 207 | async function getIconSearchIndex() { |
| 208 | iconSearchIndexPromise ??= buildIconSearchIndex(); |
| 209 | return iconSearchIndexPromise; |
| 210 | } |
| 211 | |
| 212 | function scoreIconMatch(query: string, icon: SearchableIcon) { |
| 213 | const normalizedQuery = normalizeForSearch(query); |
| 214 | |
| 215 | if (!normalizedQuery) { |
| 216 | return 0; |
| 217 | } |
| 218 | |
| 219 | if (icon.normalizedKey === query.toLowerCase()) { |
| 220 | return 1000; |
| 221 | } |
| 222 | |
| 223 | if (icon.normalizedLabel === normalizedQuery) { |
| 224 | return 900; |
| 225 | } |
| 226 | |
| 227 | if (icon.normalizedKey.startsWith(query.toLowerCase())) { |
| 228 | return 850; |
| 229 | } |
| 230 | |
| 231 | if (icon.normalizedLabel.startsWith(normalizedQuery)) { |
| 232 | return 800; |
| 233 | } |
| 234 | |
| 235 | const queryTokens = tokenizeSearchValue(query); |
| 236 | const labelTokens = tokenizeSearchValue(icon.name); |
| 237 | |
| 238 | if ( |
| 239 | queryTokens.length > 0 && |
| 240 | queryTokens.every((token) => |
| 241 | labelTokens.some((labelToken) => labelToken.includes(token)), |
| 242 | ) |
| 243 | ) { |
| 244 | return 700 - Math.max(labelTokens.length - queryTokens.length, 0); |
| 245 | } |
| 246 | |
| 247 | if (icon.normalizedKey.includes(query.toLowerCase())) { |
| 248 | return 500; |
| 249 | } |
| 250 | |
| 251 | if (icon.normalizedLabel.includes(normalizedQuery)) { |
| 252 | return 450; |
| 253 | } |
| 254 | |
| 255 | return 0; |
| 256 | } |
| 257 | |
| 258 | async function findExactIconByName(iconName: string) { |
| 259 | const hintedLibrary = getIconLibraryHint(iconName); |
| 260 | const searchOrder = hintedLibrary |
| 261 | ? [ |
| 262 | hintedLibrary, |
| 263 | ...ICON_SEARCH_ORDER.filter((key) => key !== hintedLibrary), |
| 264 | ] |
| 265 | : [...ICON_SEARCH_ORDER]; |
| 266 | |
| 267 | for (const libraryKey of searchOrder) { |
| 268 | const iconModule = await loadIconModule(libraryKey); |
| 269 | const exactIcon = iconModule[iconName]; |
| 270 | |
| 271 | if (exactIcon) { |
| 272 | return { |
| 273 | name: iconName, |
| 274 | Component: exactIcon, |
| 275 | } satisfies ResolvedPresentationIcon; |
| 276 | } |
| 277 | |
| 278 | const normalizedName = iconName.toLowerCase(); |
| 279 | const matchingName = Object.keys(iconModule).find( |
| 280 | (candidateName) => candidateName.toLowerCase() === normalizedName, |
| 281 | ); |
| 282 | |
| 283 | if (matchingName) { |
| 284 | return { |
| 285 | name: matchingName, |
| 286 | Component: iconModule[matchingName]!, |
| 287 | } satisfies ResolvedPresentationIcon; |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | return null; |
| 292 | } |
| 293 | |
| 294 | export async function resolvePresentationIcon(iconName: string) { |
| 295 | const normalizedName = normalizeWhitespace(iconName); |
| 296 | |
| 297 | if (!normalizedName) { |
| 298 | return null; |
| 299 | } |
| 300 | |
| 301 | const cachedIcon = resolvedIconCache.get(normalizedName); |
| 302 | if (cachedIcon) return cachedIcon; |
| 303 | |
| 304 | const iconPromise = (async () => { |
| 305 | const exactMatch = await findExactIconByName(normalizedName); |
| 306 | |
| 307 | if (exactMatch) { |
| 308 | return exactMatch; |
| 309 | } |
| 310 | |
| 311 | const fuzzyMatches = await searchPresentationIcons(normalizedName, 1); |
| 312 | return fuzzyMatches[0] ?? null; |
| 313 | })().catch((error: unknown) => { |
| 314 | resolvedIconCache.delete(normalizedName); |
| 315 | throw error; |
| 316 | }); |
| 317 | |
| 318 | resolvedIconCache.set(normalizedName, iconPromise); |
| 319 | return iconPromise; |
| 320 | } |
| 321 | |
| 322 | export async function searchPresentationIcons( |
| 323 | query: string, |
| 324 | limit = 60, |
| 325 | ): Promise<ResolvedPresentationIcon[]> { |
| 326 | const normalizedQuery = normalizeWhitespace(query); |
| 327 | |
| 328 | if (!normalizedQuery) { |
| 329 | return getPopularPresentationIcons(limit); |
| 330 | } |
| 331 | |
| 332 | const iconSearchIndex = await getIconSearchIndex(); |
| 333 | const dedupedMatches = new Map< |
| 334 | string, |
| 335 | { icon: ResolvedPresentationIcon; score: number } |
| 336 | >(); |
| 337 | |
| 338 | for (const icon of iconSearchIndex) { |
| 339 | const score = scoreIconMatch(normalizedQuery, icon); |
| 340 | |
| 341 | if (score <= 0) { |
| 342 | continue; |
| 343 | } |
| 344 | |
| 345 | const existingMatch = dedupedMatches.get(icon.name); |
| 346 | |
| 347 | if (!existingMatch || score > existingMatch.score) { |
| 348 | dedupedMatches.set(icon.name, { |
| 349 | score, |
| 350 | icon: { |
| 351 | name: icon.name, |
| 352 | Component: icon.Component, |
| 353 | }, |
| 354 | }); |
| 355 | } |
| 356 | } |
| 357 | |
| 358 | return [...dedupedMatches.values()] |
| 359 | .sort((left, right) => { |
| 360 | if (right.score !== left.score) { |
| 361 | return right.score - left.score; |
| 362 | } |
| 363 | |
| 364 | return left.icon.name.localeCompare(right.icon.name); |
| 365 | }) |
| 366 | .slice(0, limit) |
| 367 | .map(({ icon }) => icon); |
| 368 | } |
| 369 | |
| 370 | export async function getPopularPresentationIcons(limit = 30) { |
| 371 | const popularIcons = await Promise.all( |
| 372 | POPULAR_ICON_NAMES.map((iconName) => resolvePresentationIcon(iconName)), |
| 373 | ); |
| 374 | |
| 375 | return popularIcons |
| 376 | .filter((icon): icon is ResolvedPresentationIcon => icon !== null) |
| 377 | .slice(0, limit); |
| 378 | } |
| 379 |