diff options
Diffstat (limited to 'apps/web/src/lib/stickers/providers')
| -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 |
5 files changed, 552 insertions, 0 deletions
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 }); + }, +}; |
