diff options
Diffstat (limited to 'apps/web/src/lib/stickers')
| -rw-r--r-- | apps/web/src/lib/stickers/__tests__/sticker-id.test.ts | 34 | ||||
| -rw-r--r-- | apps/web/src/lib/stickers/index.ts | 175 | ||||
| -rw-r--r-- | apps/web/src/lib/stickers/providers/emoji.ts | 119 | ||||
| -rw-r--r-- | apps/web/src/lib/stickers/providers/flags.ts | 167 | ||||
| -rw-r--r-- | apps/web/src/lib/stickers/providers/icons.ts | 122 | ||||
| -rw-r--r-- | apps/web/src/lib/stickers/providers/index.ts | 26 | ||||
| -rw-r--r-- | apps/web/src/lib/stickers/providers/shapes.ts | 118 | ||||
| -rw-r--r-- | apps/web/src/lib/stickers/registry.ts | 31 | ||||
| -rw-r--r-- | apps/web/src/lib/stickers/resolver.ts | 22 | ||||
| -rw-r--r-- | apps/web/src/lib/stickers/sticker-id.ts | 34 | ||||
| -rw-r--r-- | apps/web/src/lib/stickers/types.ts | 8 |
11 files changed, 856 insertions, 0 deletions
diff --git a/apps/web/src/lib/stickers/__tests__/sticker-id.test.ts b/apps/web/src/lib/stickers/__tests__/sticker-id.test.ts new file mode 100644 index 00000000..573dc251 --- /dev/null +++ b/apps/web/src/lib/stickers/__tests__/sticker-id.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { buildStickerId, parseStickerId } from "../sticker-id"; + +describe("sticker-id strict mode", () => { + test("parses provider-prefixed IDs", () => { + expect(parseStickerId({ stickerId: "icons:mdi:home" })).toEqual({ + providerId: "icons", + providerValue: "mdi:home", + }); + expect(parseStickerId({ stickerId: "emoji:noto:grinning-face" })).toEqual({ + providerId: "emoji", + providerValue: "noto:grinning-face", + }); + }); + + test("throws for IDs without provider prefix", () => { + expect(() => parseStickerId({ stickerId: "home" })).toThrow(); + }); + + test("throws for malformed IDs", () => { + expect(() => parseStickerId({ stickerId: "" })).toThrow(); + expect(() => parseStickerId({ stickerId: "icons:" })).toThrow(); + expect(() => parseStickerId({ stickerId: ":mdi:home" })).toThrow(); + }); + + test("builds sticker IDs unchanged", () => { + expect( + buildStickerId({ + providerId: "flags", + providerValue: "US", + }), + ).toBe("flags:US"); + }); +}); diff --git a/apps/web/src/lib/stickers/index.ts b/apps/web/src/lib/stickers/index.ts new file mode 100644 index 00000000..1f924c6d --- /dev/null +++ b/apps/web/src/lib/stickers/index.ts @@ -0,0 +1,175 @@ +import { STICKER_CATEGORIES } from "@/constants/sticker-constants"; +import type { StickerCategory } from "@/types/stickers"; +import { getAllProviders, getProvider } from "./registry"; +import { resolveStickerId } from "./resolver"; +import { registerDefaultStickerProviders } from "./providers"; +import type { StickerProvider, StickerSearchResult } from "./types"; + +const DEFAULT_SEARCH_LIMIT = 100; + +function mergeSearchResults({ + results, +}: { + results: StickerSearchResult[]; +}): StickerSearchResult { + const deduplicatedItems = new Map< + string, + StickerSearchResult["items"][number] + >(); + let total = 0; + let hasMore = false; + + for (const result of results) { + total += result.total; + hasMore = hasMore || result.hasMore; + for (const item of result.items) { + if (!deduplicatedItems.has(item.id)) { + deduplicatedItems.set(item.id, item); + } + } + } + + return { + items: Array.from(deduplicatedItems.values()), + total, + hasMore, + }; +} + +function getProviderByCategory({ + category, +}: { + category: StickerCategory; +}): StickerProvider | null { + if (category === "all") { + return null; + } + + try { + return getProvider({ providerId: category }); + } catch { + return null; + } +} + +export async function searchStickers({ + query, + category, + limit = DEFAULT_SEARCH_LIMIT, +}: { + query: string; + category: StickerCategory; + limit?: number; +}): Promise<StickerSearchResult> { + registerDefaultStickerProviders({}); + + const effectiveCategory = category in STICKER_CATEGORIES ? category : "all"; + if (effectiveCategory !== "all") { + const provider = getProviderByCategory({ category: effectiveCategory }); + if (!provider) { + return { + items: [], + total: 0, + hasMore: false, + }; + } + return provider.search({ + query, + options: { limit }, + }); + } + + const providers = getAllProviders(); + if (providers.length === 0) { + return { + items: [], + total: 0, + hasMore: false, + }; + } + + const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length)); + const settledResults = await Promise.allSettled( + providers.map((provider) => + provider.search({ + query, + options: { limit: perProviderLimit }, + }), + ), + ); + + const fulfilledResults = settledResults + .filter( + (result): result is PromiseFulfilledResult<StickerSearchResult> => + result.status === "fulfilled", + ) + .map((result) => result.value); + + return mergeSearchResults({ + results: fulfilledResults, + }); +} + +export async function browseStickers({ + category, + page = 1, + limit = DEFAULT_SEARCH_LIMIT, +}: { + category: StickerCategory; + page?: number; + limit?: number; +}): Promise<StickerSearchResult> { + registerDefaultStickerProviders({}); + + const effectiveCategory = category in STICKER_CATEGORIES ? category : "all"; + if (effectiveCategory !== "all") { + const provider = getProviderByCategory({ category: effectiveCategory }); + if (!provider) { + return { + items: [], + total: 0, + hasMore: false, + }; + } + return provider.browse({ + options: { page, limit }, + }); + } + + const providers = getAllProviders(); + if (providers.length === 0) { + return { + items: [], + total: 0, + hasMore: false, + }; + } + + const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length)); + const settledResults = await Promise.allSettled( + providers.map((provider) => + provider.browse({ + options: { page, limit: perProviderLimit }, + }), + ), + ); + + const fulfilledResults = settledResults + .filter( + (result): result is PromiseFulfilledResult<StickerSearchResult> => + result.status === "fulfilled", + ) + .map((result) => result.value); + + return mergeSearchResults({ + results: fulfilledResults, + }); +} + +export { resolveStickerId }; +export type { + StickerItem, + StickerProvider, + StickerResolveOptions, + StickerSearchResult, +} from "./types"; diff --git a/apps/web/src/lib/stickers/providers/emoji.ts b/apps/web/src/lib/stickers/providers/emoji.ts new file mode 100644 index 00000000..cc198236 --- /dev/null +++ b/apps/web/src/lib/stickers/providers/emoji.ts @@ -0,0 +1,119 @@ +import { + POPULAR_COLLECTIONS, + getIconSvgUrl, + searchIcons, +} from "@/lib/iconify-api"; +import { buildStickerId, parseStickerId } from "../sticker-id"; +import type { + StickerItem, + StickerProvider, + StickerSearchResult, +} from "../types"; + +const EMOJI_PROVIDER_ID = "emoji"; +const DEFAULT_SEARCH_LIMIT = 100; + +const EMOJI_PREFIXES = POPULAR_COLLECTIONS.emoji.map( + (collection) => collection.prefix, +); + +const DEFAULT_EMOJI_BROWSE = [ + "noto:grinning-face", + "noto:smiling-face-with-heart-eyes", + "noto:fire", + "noto:rocket", + "noto:party-popper", + "noto:clapping-hands", + "noto:sparkles", + "noto:red-heart", + "noto:thumbs-up", + "noto:eyes", + "noto:thinking-face", + "noto:hundred-points", +]; + +function getDisplayNameFromIconName({ + iconName, +}: { + iconName: string; +}): string { + const parts = iconName.split(":"); + const rawName = parts[parts.length - 1] ?? iconName; + return rawName.replaceAll("-", " ").replaceAll("_", " "); +} + +function toStickerItem({ iconName }: { iconName: string }): StickerItem { + return { + id: buildStickerId({ + providerId: EMOJI_PROVIDER_ID, + providerValue: iconName, + }), + provider: EMOJI_PROVIDER_ID, + name: getDisplayNameFromIconName({ iconName }), + previewUrl: getIconSvgUrl(iconName, { width: 64, height: 64 }), + metadata: { iconName }, + }; +} + +function computeHasMore({ + total, + limit, + start = 0, +}: { + total: number; + limit: number; + start?: number; +}): boolean { + return start + limit < total; +} + +export const emojiProvider: StickerProvider = { + id: EMOJI_PROVIDER_ID, + async search({ + query, + options, + }: { + query: string; + options?: { limit?: number }; + }): Promise<StickerSearchResult> { + const limit = options?.limit ?? DEFAULT_SEARCH_LIMIT; + const searchResult = await searchIcons(query, limit, EMOJI_PREFIXES); + return { + items: searchResult.icons.map((iconName) => toStickerItem({ iconName })), + total: searchResult.total, + hasMore: computeHasMore({ + total: searchResult.total, + limit: searchResult.limit, + start: searchResult.start, + }), + }; + }, + async browse({ + options, + }: { + options?: { page?: number; limit?: number }; + }): Promise<StickerSearchResult> { + const limit = options?.limit ?? DEFAULT_EMOJI_BROWSE.length; + const items = DEFAULT_EMOJI_BROWSE.slice(0, limit).map((iconName) => + toStickerItem({ iconName }), + ); + return { + items, + total: items.length, + hasMore: false, + }; + }, + resolveUrl({ + stickerId, + options, + }: { + stickerId: string; + options?: { width?: number; height?: number }; + }): string { + const { providerValue } = parseStickerId({ stickerId }); + return getIconSvgUrl(providerValue, { + width: options?.width, + height: options?.height, + }); + }, +}; diff --git a/apps/web/src/lib/stickers/providers/flags.ts b/apps/web/src/lib/stickers/providers/flags.ts new file mode 100644 index 00000000..91cc5489 --- /dev/null +++ b/apps/web/src/lib/stickers/providers/flags.ts @@ -0,0 +1,167 @@ +import { buildStickerId, parseStickerId } from "../sticker-id"; +import type { + StickerItem, + StickerProvider, + StickerSearchResult, +} from "../types"; + +const FLAGS_PROVIDER_ID = "flags"; +const FLAGS_DATASET_URL = "/countries.json"; +const DEFAULT_SEARCH_LIMIT = 100; +const DEFAULT_FLAGS_BASE_URL = "/flags"; + +type CountryRecord = { + name: string; + code: string; + languages?: string[]; + flag_colors?: string[]; + region?: string; +}; + +let countriesPromise: Promise<CountryRecord[]> | null = null; + +function getFlagsBaseUrl(): string { + return DEFAULT_FLAGS_BASE_URL.replace(/\/$/, ""); +} + +function buildFlagUrl({ code }: { code: string }): string { + const normalizedCode = code.toUpperCase(); + return `${getFlagsBaseUrl()}/${encodeURIComponent(normalizedCode)}.svg`; +} + +async function loadCountries(): Promise<CountryRecord[]> { + if (countriesPromise) { + return countriesPromise; + } + + countriesPromise = fetch(FLAGS_DATASET_URL) + .then(async (response) => { + if (!response.ok) { + throw new Error(`Failed to load countries: ${response.status}`); + } + return (await response.json()) as CountryRecord[]; + }) + .catch((error) => { + console.error("Failed to load countries dataset:", error); + return []; + }); + + return countriesPromise; +} + +function toStickerItem({ country }: { country: CountryRecord }): StickerItem { + const normalizedCode = country.code.toUpperCase(); + return { + id: buildStickerId({ + providerId: FLAGS_PROVIDER_ID, + providerValue: normalizedCode, + }), + provider: FLAGS_PROVIDER_ID, + name: country.name, + previewUrl: buildFlagUrl({ code: normalizedCode }), + metadata: { + code: normalizedCode, + region: country.region ?? null, + languages: country.languages ?? [], + flagColors: country.flag_colors ?? [], + }, + }; +} + +function normalizeQuery({ query }: { query: string }): string { + return query.trim().toLowerCase(); +} + +function filterCountriesByQuery({ + countries, + query, +}: { + countries: CountryRecord[]; + query: string; +}): CountryRecord[] { + if (!query) { + return countries; + } + + return countries.filter((country) => { + const normalizedName = country.name.toLowerCase(); + const normalizedCode = country.code.toLowerCase(); + const normalizedRegion = country.region?.toLowerCase() ?? ""; + return ( + normalizedName.includes(query) || + normalizedCode.includes(query) || + normalizedRegion.includes(query) + ); + }); +} + +function paginateCountries({ + countries, + options, +}: { + countries: CountryRecord[]; + options?: { page?: number; limit?: number }; +}): { items: CountryRecord[]; hasMore: boolean; total: number } { + const page = Math.max(1, options?.page ?? 1); + const limit = Math.max(1, options?.limit ?? DEFAULT_SEARCH_LIMIT); + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const pagedItems = countries.slice(startIndex, endIndex); + return { + items: pagedItems, + hasMore: endIndex < countries.length, + total: countries.length, + }; +} + +export const flagsProvider: StickerProvider = { + id: FLAGS_PROVIDER_ID, + async search({ + query, + options, + }: { + query: string; + options?: { limit?: number }; + }): Promise<StickerSearchResult> { + const countries = await loadCountries(); + const normalizedQuery = normalizeQuery({ query }); + const filteredCountries = filterCountriesByQuery({ + countries, + query: normalizedQuery, + }); + const paged = paginateCountries({ + countries: filteredCountries, + options: { + page: 1, + limit: options?.limit ?? DEFAULT_SEARCH_LIMIT, + }, + }); + return { + items: paged.items.map((country) => toStickerItem({ country })), + total: paged.total, + hasMore: paged.hasMore, + }; + }, + async browse({ + options, + }: { + options?: { page?: number; limit?: number }; + }): Promise<StickerSearchResult> { + const countries = await loadCountries(); + const paged = paginateCountries({ countries, options }); + return { + items: paged.items.map((country) => toStickerItem({ country })), + total: paged.total, + hasMore: paged.hasMore, + }; + }, + resolveUrl({ + stickerId, + }: { + stickerId: string; + options?: { width?: number; height?: number }; + }): string { + const { providerValue } = parseStickerId({ stickerId }); + return buildFlagUrl({ code: providerValue }); + }, +}; diff --git a/apps/web/src/lib/stickers/providers/icons.ts b/apps/web/src/lib/stickers/providers/icons.ts new file mode 100644 index 00000000..06b2a4c2 --- /dev/null +++ b/apps/web/src/lib/stickers/providers/icons.ts @@ -0,0 +1,122 @@ +import { + POPULAR_COLLECTIONS, + getIconSvgUrl, + searchIcons, +} from "@/lib/iconify-api"; +import { buildStickerId, parseStickerId } from "../sticker-id"; +import type { + StickerItem, + StickerProvider, + StickerSearchResult, +} from "../types"; + +const ICONS_PROVIDER_ID = "icons"; +const DEFAULT_SEARCH_LIMIT = 100; + +const ICONS_PREFIXES = Array.from( + new Set( + [...POPULAR_COLLECTIONS.general, ...POPULAR_COLLECTIONS.brands].map( + (collection) => collection.prefix, + ), + ), +); + +const DEFAULT_ICONS_BROWSE = [ + "mdi:home", + "mdi:star", + "mdi:heart", + "mdi:check-circle", + "mdi:account", + "mdi:camera", + "mdi:music", + "mdi:map-marker", + "mdi:calendar", + "mdi:lightning-bolt", + "mdi:cog", + "mdi:rocket", +]; + +function getDisplayNameFromIconName({ + iconName, +}: { + iconName: string; +}): string { + const [, rawName = iconName] = iconName.split(":"); + return rawName.replaceAll("-", " ").replaceAll("_", " "); +} + +function toStickerItem({ iconName }: { iconName: string }): StickerItem { + return { + id: buildStickerId({ + providerId: ICONS_PROVIDER_ID, + providerValue: iconName, + }), + provider: ICONS_PROVIDER_ID, + name: getDisplayNameFromIconName({ iconName }), + previewUrl: getIconSvgUrl(iconName, { width: 64, height: 64 }), + metadata: { iconName }, + }; +} + +function computeHasMore({ + total, + limit, + start = 0, +}: { + total: number; + limit: number; + start?: number; +}): boolean { + return start + limit < total; +} + +export const iconsProvider: StickerProvider = { + id: ICONS_PROVIDER_ID, + async search({ + query, + options, + }: { + query: string; + options?: { limit?: number }; + }): Promise<StickerSearchResult> { + const limit = options?.limit ?? DEFAULT_SEARCH_LIMIT; + const searchResult = await searchIcons(query, limit, ICONS_PREFIXES); + return { + items: searchResult.icons.map((iconName) => toStickerItem({ iconName })), + total: searchResult.total, + hasMore: computeHasMore({ + total: searchResult.total, + limit: searchResult.limit, + start: searchResult.start, + }), + }; + }, + async browse({ + options, + }: { + options?: { page?: number; limit?: number }; + }): Promise<StickerSearchResult> { + const limit = options?.limit ?? DEFAULT_ICONS_BROWSE.length; + const items = DEFAULT_ICONS_BROWSE.slice(0, limit).map((iconName) => + toStickerItem({ iconName }), + ); + return { + items, + total: items.length, + hasMore: false, + }; + }, + resolveUrl({ + stickerId, + options, + }: { + stickerId: string; + options?: { width?: number; height?: number }; + }): string { + const { providerValue } = parseStickerId({ stickerId }); + return getIconSvgUrl(providerValue, { + width: options?.width, + height: options?.height, + }); + }, +}; diff --git a/apps/web/src/lib/stickers/providers/index.ts b/apps/web/src/lib/stickers/providers/index.ts new file mode 100644 index 00000000..3c4faee9 --- /dev/null +++ b/apps/web/src/lib/stickers/providers/index.ts @@ -0,0 +1,26 @@ +import { hasProvider, registerProvider } from "../registry"; +import type { StickerProvider } from "@/types/stickers"; +import { emojiProvider } from "./emoji"; +import { flagsProvider } from "./flags"; +import { iconsProvider } from "./icons"; +import { shapesProvider } from "./shapes"; + +const defaultProviders: StickerProvider[] = [ + iconsProvider, + emojiProvider, + flagsProvider, + shapesProvider, +]; + +export function registerDefaultStickerProviders({ + providersToRegister = defaultProviders, +}: { + providersToRegister?: StickerProvider[]; +} = {}): void { + for (const provider of providersToRegister) { + if (hasProvider({ providerId: provider.id })) { + continue; + } + registerProvider({ provider }); + } +} diff --git a/apps/web/src/lib/stickers/providers/shapes.ts b/apps/web/src/lib/stickers/providers/shapes.ts new file mode 100644 index 00000000..81873dcb --- /dev/null +++ b/apps/web/src/lib/stickers/providers/shapes.ts @@ -0,0 +1,118 @@ +import { buildStickerId, parseStickerId } from "../sticker-id"; +import type { + StickerItem, + StickerProvider, + StickerSearchResult, +} from "../types"; + +const SHAPES_PROVIDER_ID = "shapes"; + +const SHAPES = [ + { key: "circle", name: "Circle" }, + { key: "square", name: "Square" }, + { key: "triangle", name: "Triangle" }, + { key: "star", name: "Star" }, + { key: "hexagon", name: "Hexagon" }, + { key: "diamond", name: "Diamond" }, +] as const; + +function buildShapeUrl({ shapeKey }: { shapeKey: string }): string { + return `/shapes/${shapeKey}.svg`; +} + +function toStickerItem({ + shape, +}: { + shape: (typeof SHAPES)[number]; +}): StickerItem { + return { + id: buildStickerId({ + providerId: SHAPES_PROVIDER_ID, + providerValue: shape.key, + }), + provider: SHAPES_PROVIDER_ID, + name: shape.name, + previewUrl: buildShapeUrl({ shapeKey: shape.key }), + metadata: { shape: shape.key }, + }; +} + +function filterShapesByQuery({ + query, +}: { + query: string; +}): Array<(typeof SHAPES)[number]> { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return [...SHAPES]; + } + + return SHAPES.filter((shape) => + shape.name.toLowerCase().includes(normalizedQuery), + ); +} + +function paginateShapes({ + shapes, + options, +}: { + shapes: Array<(typeof SHAPES)[number]>; + options?: { page?: number; limit?: number }; +}): { items: Array<(typeof SHAPES)[number]>; hasMore: boolean; total: number } { + const page = Math.max(1, options?.page ?? 1); + const limit = Math.max(1, options?.limit ?? SHAPES.length); + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const pagedItems = shapes.slice(startIndex, endIndex); + return { + items: pagedItems, + hasMore: endIndex < shapes.length, + total: shapes.length, + }; +} + +export const shapesProvider: StickerProvider = { + id: SHAPES_PROVIDER_ID, + async search({ + query, + options, + }: { + query: string; + options?: { limit?: number }; + }): Promise<StickerSearchResult> { + const filteredShapes = filterShapesByQuery({ query }); + const paged = paginateShapes({ + shapes: filteredShapes, + options: { page: 1, limit: options?.limit ?? SHAPES.length }, + }); + return { + items: paged.items.map((shape) => toStickerItem({ shape })), + total: paged.total, + hasMore: paged.hasMore, + }; + }, + async browse({ + options, + }: { + options?: { page?: number; limit?: number }; + }): Promise<StickerSearchResult> { + const paged = paginateShapes({ + shapes: [...SHAPES], + options, + }); + return { + items: paged.items.map((shape) => toStickerItem({ shape })), + total: paged.total, + hasMore: paged.hasMore, + }; + }, + resolveUrl({ + stickerId, + }: { + stickerId: string; + options?: { width?: number; height?: number }; + }): string { + const { providerValue } = parseStickerId({ stickerId }); + return buildShapeUrl({ shapeKey: providerValue }); + }, +}; diff --git a/apps/web/src/lib/stickers/registry.ts b/apps/web/src/lib/stickers/registry.ts new file mode 100644 index 00000000..0fbad594 --- /dev/null +++ b/apps/web/src/lib/stickers/registry.ts @@ -0,0 +1,31 @@ +import type { StickerProvider } from "@/types/stickers"; + +const providers = new Map<string, StickerProvider>(); + +export function registerProvider({ + provider, +}: { + provider: StickerProvider; +}): void { + providers.set(provider.id, provider); +} + +export function hasProvider({ providerId }: { providerId: string }): boolean { + return providers.has(providerId); +} + +export function getProvider({ + providerId, +}: { + providerId: string; +}): StickerProvider { + const provider = providers.get(providerId); + if (!provider) { + throw new Error(`Unknown sticker provider: ${providerId}`); + } + return provider; +} + +export function getAllProviders(): StickerProvider[] { + return Array.from(providers.values()); +} diff --git a/apps/web/src/lib/stickers/resolver.ts b/apps/web/src/lib/stickers/resolver.ts new file mode 100644 index 00000000..3252cd60 --- /dev/null +++ b/apps/web/src/lib/stickers/resolver.ts @@ -0,0 +1,22 @@ +import { getProvider } from "./registry"; +import { parseStickerId } from "./sticker-id"; +import { registerDefaultStickerProviders } from "./providers"; +import type { StickerResolveOptions } from "@/types/stickers"; + +export function resolveStickerId({ + stickerId, + options, +}: { + stickerId: string; + options?: StickerResolveOptions; +}): string { + registerDefaultStickerProviders(); + + const parsedStickerId = parseStickerId({ stickerId }); + return getProvider({ + providerId: parsedStickerId.providerId, + }).resolveUrl({ + stickerId, + options, + }); +} diff --git a/apps/web/src/lib/stickers/sticker-id.ts b/apps/web/src/lib/stickers/sticker-id.ts new file mode 100644 index 00000000..8cb9364b --- /dev/null +++ b/apps/web/src/lib/stickers/sticker-id.ts @@ -0,0 +1,34 @@ +export function parseStickerId({ stickerId }: { stickerId: string }): { + providerId: string; + providerValue: string; +} { + const normalizedStickerId = stickerId.trim(); + if (!normalizedStickerId) { + throw new Error("Sticker ID must be a non-empty string"); + } + + const separatorIndex = normalizedStickerId.indexOf(":"); + if ( + separatorIndex <= 0 || + separatorIndex === normalizedStickerId.length - 1 + ) { + throw new Error( + `Invalid sticker ID format: "${stickerId}". Expected "provider:value".`, + ); + } + + const providerId = normalizedStickerId.slice(0, separatorIndex).trim(); + const providerValue = normalizedStickerId.slice(separatorIndex + 1).trim(); + + return { providerId, providerValue }; +} + +export function buildStickerId({ + providerId, + providerValue, +}: { + providerId: string; + providerValue: string; +}): string { + return `${providerId}:${providerValue}`; +} diff --git a/apps/web/src/lib/stickers/types.ts b/apps/web/src/lib/stickers/types.ts new file mode 100644 index 00000000..91cbcaae --- /dev/null +++ b/apps/web/src/lib/stickers/types.ts @@ -0,0 +1,8 @@ +export type { + StickerItem, + StickerProvider, + StickerProviderBrowseOptions, + StickerProviderSearchOptions, + StickerResolveOptions, + StickerSearchResult, +} from "@/types/stickers"; |
