From c3f3345d7b1ac820b411a5adbca3172378642953 Mon Sep 17 00:00:00 2001 From: enkei64 Date: Fri, 15 Aug 2025 10:11:21 +1000 Subject: feat: stickers panel (#539) * Stickers panel base complete * Improve dark mode stickers background for visibility * Prevent stickers from being too small or too big * Improve UI, added credit for Iconify API * Allow manually loading more collections * Add a maximum width of 200px so stickers aren't too big * cleanup * style: hover state of button * refactor: input component * fix: mark input component as client * so much stuff --------- Co-authored-by: Maze Winther --- apps/web/src/lib/iconify-api.ts | 242 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 apps/web/src/lib/iconify-api.ts (limited to 'apps/web/src/lib/iconify-api.ts') diff --git a/apps/web/src/lib/iconify-api.ts b/apps/web/src/lib/iconify-api.ts new file mode 100644 index 00000000..9d1bcb97 --- /dev/null +++ b/apps/web/src/lib/iconify-api.ts @@ -0,0 +1,242 @@ + +export const ICONIFY_HOSTS = [ + "https://api.iconify.design", + "https://api.simplesvg.com", + "https://api.unisvg.com", +]; + +let currentHost = ICONIFY_HOSTS[0]; + +async function fetchWithFallback(path: string): Promise { + for (const host of ICONIFY_HOSTS) { + try { + const response = await fetch(`${host}${path}`, { + signal: AbortSignal.timeout(2000), + }); + if (response.ok) { + currentHost = host; + return response; + } + } catch (error) { + console.warn(`Failed to fetch from ${host}:`, error); + } + } + throw new Error("All API hosts failed"); +} + +export interface IconSet { + prefix: string; + name: string; + total: number; + author?: { + name: string; + url?: string; + }; + license?: { + title: string; + spdx?: string; + url?: string; + }; + samples?: string[]; + category?: string; + palette?: boolean; +} + +export interface IconSearchResult { + icons: string[]; + total: number; + limit: number; + start: number; + collections: Record; +} + +export interface CollectionInfo { + prefix: string; + total: number; + title?: string; + uncategorized?: string[]; + categories?: Record; + hidden?: string[]; + aliases?: Record; +} + +export async function getCollections( + category?: string +): Promise> { + try { + const response = await fetchWithFallback("/collections?pretty=1"); + const data = (await response.json()) as Record; + + if (category) { + const filtered = Object.fromEntries( + Object.entries(data).filter( + ([_key, info]) => info.category === category + ) + ) as Record; + return filtered; + } + + return data; + } catch (error) { + console.error("Failed to fetch collections:", error); + return {}; + } +} + +export async function getCollection( + prefix: string +): Promise { + try { + const response = await fetchWithFallback( + `/collection?prefix=${prefix}&pretty=1` + ); + return await response.json(); + } catch (error) { + console.error(`Failed to fetch collection ${prefix}:`, error); + return null; + } +} + +export async function searchIcons( + query: string, + limit: number = 64, + prefixes?: string[], + category?: string +): Promise { + const params = new URLSearchParams({ + query, + limit: limit.toString(), + pretty: "1", + }); + + if (prefixes?.length) { + params.append("prefixes", prefixes.join(",")); + } + + if (category) { + params.append("category", category); + } + + try { + const response = await fetchWithFallback(`/search?${params}`); + return await response.json(); + } catch (error) { + console.error("Failed to search icons:", error); + return { + icons: [], + total: 0, + limit, + start: 0, + collections: {}, + }; + } +} + +export function buildIconSvgUrl( + host: string, + iconName: string, + params?: { + color?: string; + width?: number; + height?: number; + flip?: "horizontal" | "vertical" | "horizontal,vertical"; + rotate?: number | string; + } +): string { + const [prefix, name] = iconName.includes(":") + ? iconName.split(":") + : ["", iconName]; + + if (!prefix || !name) { + throw new Error('Invalid icon name format. Expected "prefix:name"'); + } + + const urlParams = new URLSearchParams(); + + if (params?.color) { + urlParams.append("color", params.color.replace("#", "%23")); + } + + if (params?.width) { + urlParams.append("width", params.width.toString()); + } + + if (params?.height) { + urlParams.append("height", params.height.toString()); + } + + if (params?.flip) { + urlParams.append("flip", params.flip); + } + + if (params?.rotate) { + urlParams.append("rotate", params.rotate.toString()); + } + + const queryString = urlParams.toString(); + return `${host}/${prefix}/${name}.svg${queryString ? "?" + queryString : ""}`; +} + +export function getIconSvgUrl( + iconName: string, + params?: Parameters[2] +): string { + return buildIconSvgUrl(currentHost, iconName, params); +} + +export async function downloadSvgAsText( + iconName: string, + params?: Parameters[1] +): Promise { + const url = getIconSvgUrl(iconName, params); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download SVG: ${response.statusText}`); + } + return await response.text(); +} + +export function svgToFile(svgText: string, fileName: string): File { + const blob = new Blob([svgText], { type: "image/svg+xml" }); + return new File([blob], fileName, { type: "image/svg+xml" }); +} + +export const POPULAR_COLLECTIONS = { + general: [ + { prefix: "mdi", name: "Material Design Icons" }, + { prefix: "ic", name: "Google Material Icons" }, + { prefix: "ph", name: "Phosphor" }, + { prefix: "heroicons", name: "Heroicons" }, + { prefix: "lucide", name: "Lucide" }, + { prefix: "tabler", name: "Tabler Icons" }, + { prefix: "fe", name: "Feather Icons" }, + { prefix: "bi", name: "Bootstrap Icons" }, + ], + brands: [ + { prefix: "simple-icons", name: "Simple Icons" }, + { prefix: "logos", name: "SVG Logos" }, + { prefix: "skill-icons", name: "Skill Icons" }, + { prefix: "devicon", name: "Devicon" }, + { prefix: "fa-brands", name: "Font Awesome Brands" }, + ], + emoji: [ + { prefix: "noto", name: "Noto Emoji" }, + { prefix: "twemoji", name: "Twemoji" }, + { prefix: "fluent-emoji", name: "Fluent Emoji" }, + { prefix: "fluent-emoji-flat", name: "Fluent Emoji Flat" }, + { prefix: "emojione", name: "EmojiOne" }, + { prefix: "openmoji", name: "OpenMoji" }, + ], +}; + +export function getCategoriesFromCollections( + collections: Record +): string[] { + const categories = new Set(); + Object.values(collections).forEach((collection) => { + if (collection.category) { + categories.add(collection.category); + } + }); + return Array.from(categories).sort(); +} -- cgit v1.3.1