diff --git a/components/AccountCell/index.tsx b/components/AccountCell/index.tsx index 43f86c15..7b725a16 100644 --- a/components/AccountCell/index.tsx +++ b/components/AccountCell/index.tsx @@ -49,6 +49,9 @@ const Index = ({ active, address }) => { borderColor: "$neutral5", }} src={identity.avatar} + alt={ + identity?.name ? `${identity.name} avatar` : `${address} avatar` + } /> ) : ( ( + +); + +export default ConnectButtonSkeleton; diff --git a/components/DelegatingWidget/Header.tsx b/components/DelegatingWidget/Header.tsx index b808c216..4b272b81 100644 --- a/components/DelegatingWidget/Header.tsx +++ b/components/DelegatingWidget/Header.tsx @@ -42,6 +42,11 @@ const Header = ({ height: 40, }} src={delegateProfile.avatar} + alt={ + delegateProfile?.name + ? `${delegateProfile.name} avatar` + : `${delegateProfile?.id || "delegate"} avatar` + } /> ) : ( Get LPT - + } > { transition: "opacity 150ms ease", }} src={avatarSrc} + alt={`${address} avatar`} onLoad={() => setImageLoaded(true)} onError={() => setHasAvatarError(true)} /> diff --git a/components/Logo/index.tsx b/components/Logo/index.tsx index 22f3803f..9e350ecc 100644 --- a/components/Logo/index.tsx +++ b/components/Logo/index.tsx @@ -179,7 +179,7 @@ const LivepeerLogo = ({ if (!isLink) return markup; return ( - {markup} + {markup} ); }; diff --git a/components/OrchestratorList/Skeleton.tsx b/components/OrchestratorList/Skeleton.tsx new file mode 100644 index 00000000..4b8c1790 --- /dev/null +++ b/components/OrchestratorList/Skeleton.tsx @@ -0,0 +1,81 @@ +import { Box, Flex, Skeleton } from "@livepeer/design-system"; + +const OrchestratorListSkeleton = () => { + return ( + + {/* Input section skeleton */} + + + + + + + + + + + + + {/* Table header skeleton */} + + + + + + + + + + + {/* Table rows skeleton (10 rows) */} + {Array.from({ length: 10 }).map((_, i) => ( + + + + + + + + + + ))} + + {/* Pagination skeleton */} + + + + + + + ); +}; + +export default OrchestratorListSkeleton; diff --git a/components/OrchestratorList/index.tsx b/components/OrchestratorList/index.tsx index e06ae1d7..f00cf185 100644 --- a/components/OrchestratorList/index.tsx +++ b/components/OrchestratorList/index.tsx @@ -256,7 +256,7 @@ const OrchestratorList = ({ } > - Orchestrator + Orchestrator ), accessor: "id", @@ -359,7 +359,7 @@ const OrchestratorList = ({ } > - Forecasted Yield + Forecasted Yield ), @@ -380,6 +380,7 @@ const OrchestratorList = ({ } > - Delegated Stake + Delegated Stake ), accessor: "totalStake", @@ -888,7 +889,7 @@ const OrchestratorList = ({ } > - Trailing 90D Fees + Trailing 90D Fees ), accessor: "ninetyDayVolumeETH", @@ -922,23 +923,21 @@ const OrchestratorList = ({ }} asChild > - - - - - + }, + }} + > + + { - return ( - + transform: "translateX(6px)", + }, + }, + }; + + // For external links, use regular anchor tag to avoid Next.js Link issues + if (newWindow || href.startsWith("http")) { + return ( + + {children} + + ); + } + + // For internal links, use Next.js Link + return ( + {children} diff --git a/components/Profile/index.tsx b/components/Profile/index.tsx index 233226d9..b6dbcc72 100644 --- a/components/Profile/index.tsx +++ b/components/Profile/index.tsx @@ -65,6 +65,9 @@ const Index = ({ account, isMyAccount = false, identity }: Props) => { height: "100%", }} src={identity.avatar} + alt={ + identity?.name ? `${identity.name} avatar` : `${account} avatar` + } /> ) : ( { href={identity.url} target="__blank" rel="noopener noreferrer" + aria-label={`Visit ${identity.url.replace( + /(^\w+:|^)\/\//, + "" + )}`} > - + @@ -177,12 +188,17 @@ const Index = ({ account, isMyAccount = false, identity }: Props) => { href={`https://twitter.com/${identity.twitter}`} target="__blank" rel="noopener noreferrer" + aria-label={`View ${identity.twitter} on Twitter`} > - + + @@ -861,11 +854,17 @@ const ContractAddressesPopover = ({ activeChain }: { activeChain?: Chain }) => { marginBottom: "$1", }} target="_blank" + rel="noopener noreferrer" href={ contractAddresses?.[ key as keyof typeof contractAddresses ]?.link } + aria-label={`View ${ + contractAddresses?.[ + key as keyof typeof contractAddresses + ]?.name ?? "contract" + } on block explorer`} > { passHref href="https://docs.livepeer.org/references/contract-addresses" > - + { height: 15, }} as={ArrowTopRightIcon} + aria-hidden="true" /> diff --git a/lib/api/image-optimization.ts b/lib/api/image-optimization.ts new file mode 100644 index 00000000..2378aa4f --- /dev/null +++ b/lib/api/image-optimization.ts @@ -0,0 +1,92 @@ +import sharp from "sharp"; + +export interface OptimizeImageResult { + buffer: Buffer; + contentType: string; + originalSize: number; + optimizedSize: number; + originalDimensions?: { width: number; height: number }; + optimizedDimensions?: { width: number; height: number }; + format?: string; +} + +export interface OptimizeImageOptions { + width?: number; + height?: number; + quality?: number; + effort?: number; +} + +/** + * Optimizes an image by resizing and converting to WebP format + * @param imageBuffer - The original image buffer + * @param options - Optimization options + * @returns Optimized image buffer and metadata, or original buffer if optimization fails + */ +export async function optimizeImage( + imageBuffer: ArrayBuffer, + options: OptimizeImageOptions = {} +): Promise { + const { width = 96, height = 96, quality = 75, effort = 6 } = options; + + const originalSize = imageBuffer.byteLength; + const originalBuffer = Buffer.from(imageBuffer); + + // Get original image metadata + let originalMetadata; + try { + originalMetadata = await sharp(originalBuffer).metadata(); + } catch { + // Metadata extraction failed, but we can still proceed with optimization + originalMetadata = undefined; + } + + // Optimize image: resize and convert to WebP + try { + const optimizedBuffer = await sharp(originalBuffer) + .resize(width, height, { + fit: "cover", + withoutEnlargement: true, // Don't upscale small images + }) + .webp({ quality, effort }) + .toBuffer(); + + const optimizedMetadata = await sharp(optimizedBuffer).metadata(); + + return { + buffer: optimizedBuffer, + contentType: "image/webp", + originalSize, + optimizedSize: optimizedBuffer.length, + originalDimensions: originalMetadata + ? { + width: originalMetadata.width || 0, + height: originalMetadata.height || 0, + } + : undefined, + optimizedDimensions: optimizedMetadata + ? { + width: optimizedMetadata.width || 0, + height: optimizedMetadata.height || 0, + } + : undefined, + format: optimizedMetadata?.format, + }; + } catch { + // Fallback to original image if optimization fails + // This is expected for some edge cases (unsupported formats, corrupted images, etc. + // Return original image as fallback + return { + buffer: originalBuffer, + contentType: "image/jpeg", // Default fallback + originalSize, + optimizedSize: originalSize, + originalDimensions: originalMetadata + ? { + width: originalMetadata.width || 0, + height: originalMetadata.height || 0, + } + : undefined, + }; + } +} diff --git a/next.config.js b/next.config.js index 3eca479a..d02ef9bb 100644 --- a/next.config.js +++ b/next.config.js @@ -1,5 +1,7 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + productionBrowserSourceMaps: true, + turbopack: { rules: { "*.svg": { diff --git a/package.json b/package.json index 0d87846b..e0555475 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "rehype-raw": "^7.0.0", "remark-gfm": "4.0.1", "sanitize-html": "^2.17.0", + "sharp": "^0.34.5", "swr": "^2.3.7", "viem": "^2.38.5", "wagmi": "^2.19.1", diff --git a/pages/_app.tsx b/pages/_app.tsx index 39e3a75e..19722f1b 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -39,6 +39,10 @@ function App({ Component, pageProps, fallback = null }) { <> + Livepeer Explorer diff --git a/pages/api/ens-data/image/[name].tsx b/pages/api/ens-data/image/[name].tsx index d2be5f8b..3322e9c4 100644 --- a/pages/api/ens-data/image/[name].tsx +++ b/pages/api/ens-data/image/[name].tsx @@ -5,6 +5,7 @@ import { methodNotAllowed, notFound, } from "@lib/api/errors"; +import { optimizeImage } from "@lib/api/image-optimization"; import { l1PublicClient } from "@lib/chains"; import { parseArweaveTxId, parseCid } from "livepeer/utils"; import { NextApiRequest, NextApiResponse } from "next"; @@ -48,9 +49,30 @@ const handler = async ( const arrayBuffer = await response.arrayBuffer(); + // Optimize image using utility + const optimizationResult = await optimizeImage(arrayBuffer, { + width: 96, + height: 96, + quality: 75, + effort: 6, + }); + + // Set appropriate content type (fallback to original if optimization failed) + if (optimizationResult.contentType === "image/jpeg") { + const originalContentType = + response.headers.get("content-type") || "image/jpeg"; + res.setHeader("Content-Type", originalContentType); + } else { + res.setHeader("Content-Type", optimizationResult.contentType); + } + + res.setHeader( + "Content-Length", + optimizationResult.buffer.length.toString() + ); res.setHeader("Cache-Control", getCacheControlHeader("week")); - return res.end(Buffer.from(arrayBuffer)); + return res.end(optimizationResult.buffer); } catch (e) { console.error(e); return notFound(res, "ENS avatar not found"); diff --git a/pages/index.tsx b/pages/index.tsx index 828343e8..4142577f 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -4,6 +4,7 @@ import ErrorComponent from "@components/Error"; import type { Group } from "@components/ExplorerChart"; import ExplorerChart from "@components/ExplorerChart"; import OrchestratorList from "@components/OrchestratorList"; +import OrchestratorListSkeleton from "@components/OrchestratorList/Skeleton"; import RoundStatus from "@components/RoundStatus"; import Spinner from "@components/Spinner"; import TransactionsList, { @@ -362,8 +363,10 @@ const Home = ({ hadError, orchestrators, events, protocol }: PageProps) => { justifyContent: "space-between", marginBottom: "$4", alignItems: "center", + gap: "$4", "@bp1": { flexDirection: "row", + gap: "$5", }, }} > @@ -380,7 +383,7 @@ const Home = ({ hadError, orchestrators, events, protocol }: PageProps) => { Orchestrators - + {(process.env.NEXT_PUBLIC_NETWORK == "MAINNET" || process.env.NEXT_PUBLIC_NETWORK == "ARBITRUM_ONE") && ( @@ -389,7 +392,8 @@ const Home = ({ hadError, orchestrators, events, protocol }: PageProps) => { css={{ color: "$hiContrast", fontSize: "$2", - marginRight: "$2", + minHeight: "44px", + padding: "$2 $3", }} > Performance Leaderboard @@ -397,9 +401,21 @@ const Home = ({ hadError, orchestrators, events, protocol }: PageProps) => { )} - @@ -418,11 +434,7 @@ const Home = ({ hadError, orchestrators, events, protocol }: PageProps) => { protocolData={protocol?.protocol} /> ) : ( - - Loading orchestrators… - + )} )} @@ -434,8 +446,10 @@ const Home = ({ hadError, orchestrators, events, protocol }: PageProps) => { marginBottom: "$4", marginTop: "$7", alignItems: "center", + gap: "$4", "@bp1": { flexDirection: "row", + gap: "$5", }, }} > @@ -452,11 +466,23 @@ const Home = ({ hadError, orchestrators, events, protocol }: PageProps) => { Transactions - + - diff --git a/pages/migrate/broadcaster.tsx b/pages/migrate/broadcaster.tsx index 244b97db..39207f75 100644 --- a/pages/migrate/broadcaster.tsx +++ b/pages/migrate/broadcaster.tsx @@ -740,7 +740,7 @@ const MigrateBroadcaster = () => { {state.image && ( - + )} diff --git a/pages/migrate/delegator/index.tsx b/pages/migrate/delegator/index.tsx index c17bdf04..d6802159 100644 --- a/pages/migrate/delegator/index.tsx +++ b/pages/migrate/delegator/index.tsx @@ -734,7 +734,7 @@ const MigrateUndelegatedStake = () => { {state.image && ( - + )} diff --git a/pages/migrate/orchestrator.tsx b/pages/migrate/orchestrator.tsx index 3805aaab..c3cd9e23 100644 --- a/pages/migrate/orchestrator.tsx +++ b/pages/migrate/orchestrator.tsx @@ -711,7 +711,7 @@ const MigrateOrchestrator = () => { {state.image && ( - + )} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 959a1b16..bb49edff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -237,6 +237,9 @@ importers: sanitize-html: specifier: ^2.17.0 version: 2.17.0 + sharp: + specifier: ^0.34.5 + version: 0.34.5 swr: specifier: ^2.3.7 version: 2.3.7(react@19.2.1) @@ -13016,8 +13019,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@img/colour@1.0.0': - optional: true + '@img/colour@1.0.0': {} '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: @@ -18857,8 +18859,7 @@ snapshots: detect-indent@6.1.0: {} - detect-libc@2.1.2: - optional: true + detect-libc@2.1.2: {} detect-newline@3.1.0: {} @@ -23600,7 +23601,6 @@ snapshots: '@img/sharp-win32-arm64': 0.34.5 '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 - optional: true shebang-command@2.0.0: dependencies: