diff --git a/bun.lock b/bun.lock index e230b53..5eec278 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "ycms", @@ -14,6 +15,7 @@ "@radix-ui/react-switch": "1.2.6", "@tanstack/react-query": "5.85.5", "framer-motion": "12.23.12", + "motion-dom": "12.23.12", "react": "19.1.1", "react-dom": "19.1.1", "react-icons": "5.5.0", diff --git a/packages/app/lib/assets.ts b/packages/app/lib/assets.ts index 3f3797c..60c59d2 100644 --- a/packages/app/lib/assets.ts +++ b/packages/app/lib/assets.ts @@ -1,6 +1,6 @@ -const BASE_URL = import.meta.env.VITE_ASSETS_CDN_URL?.endsWith('/') - ? import.meta.env.VITE_ASSETS_CDN_URL - : `${import.meta.env.VITE_ASSETS_CDN_URL}/` +const ASSETS_CDN_URL = import.meta.env.VITE_ASSETS_CDN_URL || 'https://cdn.jsdelivr.net/gh/yearn/tokenAssets@main' + +const BASE_URL = ASSETS_CDN_URL.endsWith('/') ? ASSETS_CDN_URL : `${ASSETS_CDN_URL}/` export function getChainIconUrl(chainId: number) { return `${BASE_URL}chains/${chainId}/logo.svg` @@ -9,3 +9,11 @@ export function getChainIconUrl(chainId: number) { export function getTokenIconUrl(chainId: number, address: string) { return `${BASE_URL}tokens/${chainId}/${address.toLowerCase()}/logo.svg` } + +export function getTokenLogoUrl( + chainId: number, + address: string, + fileName: 'logo.svg' | 'logo-32.png' | 'logo-128.png', +) { + return `${BASE_URL}tokens/${chainId}/${address.toLowerCase()}/${fileName}` +} diff --git a/packages/app/package.json b/packages/app/package.json index ab1ccdd..c018fcb 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-switch": "1.2.6", "@tanstack/react-query": "5.85.5", "framer-motion": "12.23.12", + "motion-dom": "12.23.12", "react": "19.1.1", "react-dom": "19.1.1", "react-icons": "5.5.0", diff --git a/packages/app/src/routes/Collection.tsx b/packages/app/src/routes/Collection.tsx index dd82ba1..78863cd 100644 --- a/packages/app/src/routes/Collection.tsx +++ b/packages/app/src/routes/Collection.tsx @@ -1,6 +1,7 @@ -import { Suspense } from 'react' +import { Suspense, useEffect, useMemo, useState } from 'react' import { PiGitPullRequest, PiTrash } from 'react-icons/pi' import { useParams } from 'react-router-dom' +import { getTokenLogoUrl } from '../../lib/assets' import { chains } from '../../lib/chains' import { type CollectionKey, getCollection, getCollectionKeys } from '../../schemas/cms' import Button from '../components/eg/elements/Button' @@ -24,6 +25,146 @@ type ProviderProps = CollectionProps & { children: React.ReactNode } +const TOKEN_ASSETS_URL = 'https://token-assets.yearn.fi' + +const TOKEN_LOGO_FILES = [ + { fileName: 'logo.svg', label: 'SVG' }, + { fileName: 'logo-32.png', label: '32px PNG' }, + { fileName: 'logo-128.png', label: '128px PNG' }, +] as const + +const TOKEN_LOGO_BUTTON_CLASS = + 'h-9 px-4 flex items-center justify-center text-sm border cursor-pointer rounded-lg whitespace-nowrap bg-interactive-secondary text-interactive-secondary-text border-interactive-secondary-border hover:bg-interactive-secondary-hover active:bg-interactive-secondary-active' + +type TokenLogoFile = (typeof TOKEN_LOGO_FILES)[number] + +async function downloadTokenLogo(url: string, fileName: string) { + const response = await fetch(url) + + if (!response.ok) { + throw new Error(`Failed to download ${fileName}`) + } + + const blob = await response.blob() + const objectUrl = URL.createObjectURL(blob) + const link = document.createElement('a') + + link.href = objectUrl + link.download = fileName + document.body.appendChild(link) + link.click() + link.remove() + URL.revokeObjectURL(objectUrl) +} + +async function downloadTokenLogos(files: Array) { + for (const file of files) { + await downloadTokenLogo(file.url, file.fileName) + } +} + +function TokenLogoDownloads() { + const { o: token } = useMetaData() + const [availableFiles, setAvailableFiles] = useState([]) + + const logoFiles = useMemo( + () => + TOKEN_LOGO_FILES.map((file) => ({ + ...file, + url: getTokenLogoUrl(token.chainId, token.address, file.fileName), + })), + [token.address, token.chainId], + ) + + const previewLogo = logoFiles.find((file) => file.fileName === 'logo-128.png') + const showPreview = availableFiles.some((file) => file.fileName === 'logo-128.png') && previewLogo + const availableLogos = logoFiles.filter((logoFile) => + availableFiles.some((file) => file.fileName === logoFile.fileName), + ) + const tokenAssetsUploadUrl = useMemo(() => { + const params = new URLSearchParams({ + chain: String(token.chainId), + address: token.address, + }) + + return `${TOKEN_ASSETS_URL}?${params.toString()}` + }, [token.address, token.chainId]) + + useEffect(() => { + const controller = new AbortController() + + async function checkLogoFiles() { + const results = await Promise.all( + logoFiles.map(async (file) => { + try { + const response = await fetch(file.url, { method: 'HEAD', signal: controller.signal }) + return response.ok ? file : null + } catch { + return null + } + }), + ) + + if (!controller.signal.aborted) { + setAvailableFiles(results.filter((file): file is TokenLogoFile & { url: string } => file !== null)) + } + } + + setAvailableFiles([]) + checkLogoFiles() + + return () => controller.abort() + }, [logoFiles]) + + return ( +
+
logo
+
+ {showPreview && ( + {`${token.name} + )} +
+ {availableFiles.length > 0 && ( +
+
download
+
+ + {availableLogos.map((file) => { + return ( + + ) + })} +
+
+ )} + + Add or update logo + +
+
+
+ ) +} + function DraftActions({ collection }: CollectionProps) { const { o: item, isDirty, formState } = useMetaData() const upsertItem = useDraftCartStore((state) => state.upsertItem) @@ -82,6 +223,7 @@ function CollectionDetails({ collection }: CollectionProps) { + {collection === 'tokens' && } }> {signedIn && } {!signedIn && }