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 <mazewinther@gmail.com>
This commit is contained in:
enkei64
2025-08-15 02:11:21 +02:00
committed by GitHub
co-authored by Maze Winther
parent defff2fc46
commit c3f3345d7b
17 changed files with 1257 additions and 44 deletions
+242
View File
@@ -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<Response> {
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<string, IconSet>;
}
export interface CollectionInfo {
prefix: string;
total: number;
title?: string;
uncategorized?: string[];
categories?: Record<string, string[]>;
hidden?: string[];
aliases?: Record<string, string>;
}
export async function getCollections(
category?: string
): Promise<Record<string, IconSet>> {
try {
const response = await fetchWithFallback("/collections?pretty=1");
const data = (await response.json()) as Record<string, IconSet>;
if (category) {
const filtered = Object.fromEntries(
Object.entries(data).filter(
([_key, info]) => info.category === category
)
) as Record<string, IconSet>;
return filtered;
}
return data;
} catch (error) {
console.error("Failed to fetch collections:", error);
return {};
}
}
export async function getCollection(
prefix: string
): Promise<CollectionInfo | null> {
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<IconSearchResult> {
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<typeof buildIconSvgUrl>[2]
): string {
return buildIconSvgUrl(currentHost, iconName, params);
}
export async function downloadSvgAsText(
iconName: string,
params?: Parameters<typeof getIconSvgUrl>[1]
): Promise<string> {
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, IconSet>
): string[] {
const categories = new Set<string>();
Object.values(collections).forEach((collection) => {
if (collection.category) {
categories.add(collection.category);
}
});
return Array.from(categories).sort();
}
+18 -3
View File
@@ -142,6 +142,7 @@ class StorageService {
width: mediaItem.width,
height: mediaItem.height,
duration: mediaItem.duration,
ephemeral: mediaItem.ephemeral,
};
await mediaMetadataAdapter.set(mediaItem.id, metadata);
@@ -161,8 +162,22 @@ class StorageService {
if (!file || !metadata) return null;
// Create new object URL for the file
const url = URL.createObjectURL(file);
let url: string;
if (metadata.type === "image" && (!file.type || file.type === "")) {
try {
const text = await file.text();
if (text.trim().startsWith("<svg")) {
const svgBlob = new Blob([text], { type: "image/svg+xml" });
url = URL.createObjectURL(svgBlob);
} else {
url = URL.createObjectURL(file);
}
} catch {
url = URL.createObjectURL(file);
}
} else {
url = URL.createObjectURL(file);
}
return {
id: metadata.id,
@@ -173,7 +188,7 @@ class StorageService {
width: metadata.width,
height: metadata.height,
duration: metadata.duration,
// thumbnailUrl would need to be regenerated or cached separately
ephemeral: metadata.ephemeral,
};
}
+2
View File
@@ -18,6 +18,8 @@ export interface MediaFileData {
width?: number;
height?: number;
duration?: number;
ephemeral?: boolean;
sourceStickerIconName?: string;
// File will be stored separately in OPFS
}