diff --git a/frontend/app/creators/components/CreatorCard.tsx b/frontend/app/creators/components/CreatorCard.tsx new file mode 100644 index 0000000..605e0f3 --- /dev/null +++ b/frontend/app/creators/components/CreatorCard.tsx @@ -0,0 +1,85 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { FileCheck2, Clock } from "lucide-react"; +import { formatDate, truncateAddress } from "@/app/search/components/shared"; +import type { Creator } from "../types"; + +export interface CreatorCardProps { + creator: Creator; +} + +/** Two-letter monogram derived from the display name or the address. */ +function initials(creator: Creator): string { + const source = creator.name?.trim() || creator.address; + const words = source.split(/\s+/).filter(Boolean); + if (words.length >= 2) { + return `${words[0][0]}${words[1][0]}`.toUpperCase(); + } + return source.slice(0, 2).toUpperCase(); +} + +/** + * Directory card for a single creator: identity, how many verified assets + * they own, their most recent mint and the categories they work in. + */ +export default function CreatorCard({ creator }: CreatorCardProps) { + const displayName = creator.name?.trim() || truncateAddress(creator.address); + const assetLabel = creator.assetCount === 1 ? "asset" : "assets"; + + return ( +
  • + +
    + +
    +

    + {displayName} +

    +

    + {truncateAddress(creator.address)} +

    +
    +
    + +
    +
    +
    +
    +
    +
    + + {creator.categories.length > 0 && ( + + )} + +
  • + ); +} diff --git a/frontend/app/creators/hooks/useInfiniteScroll.ts b/frontend/app/creators/hooks/useInfiniteScroll.ts new file mode 100644 index 0000000..b236be2 --- /dev/null +++ b/frontend/app/creators/hooks/useInfiniteScroll.ts @@ -0,0 +1,71 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface UseInfiniteScrollOptions { + /** Called once each time the sentinel scrolls into view. */ + onLoadMore: () => void; + /** + * Whether the sentinel should be observed at all. Set to false while a + * page is in flight, once the last page has loaded, or on error, so the + * observer cannot fire redundant loads. + */ + enabled: boolean; + /** + * Distance from the viewport at which loading starts, so the next page is + * usually in place by the time the user reaches the end of the list. + */ + rootMargin?: string; +} + +/** + * Observes a sentinel element and invokes `onLoadMore` whenever it enters + * the viewport. + * + * The returned value is a ref *callback*: it re-attaches the observer when + * the sentinel is mounted, unmounted or replaced, which matters because the + * sentinel is only rendered while there are more pages to load. + * + * Environments without `IntersectionObserver` (older browsers, SSR) simply + * never trigger a load; callers should keep a manual "Load more" control + * available as a fallback. + */ +export function useInfiniteScroll({ + onLoadMore, + enabled, + rootMargin = "200px", +}: UseInfiniteScrollOptions) { + const [sentinel, setSentinel] = useState(null); + + // Keep the latest callback in a ref so changing it does not tear down and + // rebuild the observer on every render. + const onLoadMoreRef = useRef(onLoadMore); + useEffect(() => { + onLoadMoreRef.current = onLoadMore; + }, [onLoadMore]); + + useEffect(() => { + if (!sentinel || !enabled) return; + if (typeof IntersectionObserver === "undefined") return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + onLoadMoreRef.current(); + } + }, + { rootMargin } + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [sentinel, enabled, rootMargin]); + + const sentinelRef = useCallback((node: HTMLElement | null) => { + setSentinel(node); + }, []); + + return { sentinelRef }; +} + +export default useInfiniteScroll; diff --git a/frontend/app/creators/page.tsx b/frontend/app/creators/page.tsx new file mode 100644 index 0000000..8cff1b1 --- /dev/null +++ b/frontend/app/creators/page.tsx @@ -0,0 +1,297 @@ +"use client"; + +import React, { useCallback, useEffect, useState } from "react"; +import { AlertCircle, Loader2, Search, Users, X } from "lucide-react"; +import Header from "@/components/Header"; +import { cn } from "@/utils/cn"; +import CreatorCard from "./components/CreatorCard"; +import { useInfiniteScroll } from "./hooks/useInfiniteScroll"; +import { fetchCreators, mergeCreators } from "./services/creatorService"; +import type { Creator } from "./types"; + +/** Debounce delay between keystrokes and the actual directory request (ms). */ +const SEARCH_DEBOUNCE_MS = 300; + +/** Placeholder cards rendered while the first page is in flight. */ +const SKELETON_COUNT = 6; + +/** True when a rejected promise stemmed from an intentional abort. */ +function isAbortError(err: unknown): boolean { + return err instanceof DOMException && err.name === "AbortError"; +} + +function CreatorCardSkeleton() { + return ( +
  • + ); +} + +/** + * Creator Directory. + * + * Lists the creators behind the certificates indexed on the StellarProof + * network. Creators are loaded a page at a time and appended as the user + * reaches the bottom of the list (Intersection Observer), so the page never + * renders more cards than the visitor has actually scrolled to. A manual + * "Load more" button mirrors the same action for keyboard users and for + * browsers without Intersection Observer support. + */ +export default function CreatorsPage() { + const [query, setQuery] = useState(""); + const [appliedQuery, setAppliedQuery] = useState(""); + const [page, setPage] = useState(0); + const [creators, setCreators] = useState([]); + const [hasMore, setHasMore] = useState(false); + // Start in loading state so the first paint shows the skeleton. + const [loadingInitial, setLoadingInitial] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + /** Bumped by the retry button to re-run the loader for the current page. */ + const [retryToken, setRetryToken] = useState(0); + + /* ---------------------------------------------------------------- */ + /* Debounce the search input */ + /* ---------------------------------------------------------------- */ + useEffect(() => { + const trimmed = query.trim(); + if (trimmed === appliedQuery) return; + + const timer = window.setTimeout(() => { + // Applying a new query restarts pagination from the first page. + setAppliedQuery(trimmed); + setPage(0); + setLoadingInitial(true); + setError(null); + }, SEARCH_DEBOUNCE_MS); + + return () => window.clearTimeout(timer); + }, [query, appliedQuery]); + + /* ---------------------------------------------------------------- */ + /* Load the current page of the directory */ + /* ---------------------------------------------------------------- */ + // The loading flags are raised by whatever triggers a load (mount, a new + // query, "load more", retry) rather than inside this effect, so the effect + // body never dispatches a synchronous setState. + useEffect(() => { + let cancelled = false; + const controller = new AbortController(); + + fetchCreators({ + page, + search: appliedQuery || undefined, + signal: controller.signal, + }) + .then((result) => { + if (cancelled) return; + setCreators((previous) => + page === 0 ? result.creators : mergeCreators(previous, result.creators) + ); + setHasMore(result.hasMore); + }) + .catch((err: unknown) => { + if (cancelled || isAbortError(err)) return; + setError( + err instanceof Error + ? err.message + : "Failed to load the creator directory. Please try again." + ); + // Stop the observer from retrying the same failing page in a loop. + setHasMore(false); + }) + .finally(() => { + if (cancelled) return; + setLoadingInitial(false); + setLoadingMore(false); + }); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [appliedQuery, page, retryToken]); + + /* ---------------------------------------------------------------- */ + /* Infinite scroll */ + /* ---------------------------------------------------------------- */ + const busy = loadingInitial || loadingMore; + + const handleLoadMore = useCallback(() => { + setPage((current) => current + 1); + setLoadingMore(true); + setError(null); + }, []); + + const { sentinelRef } = useInfiniteScroll({ + onLoadMore: handleLoadMore, + enabled: hasMore && !busy && error === null, + }); + + const handleRetry = useCallback(() => { + setRetryToken((token) => token + 1); + setError(null); + if (page === 0) setLoadingInitial(true); + else setLoadingMore(true); + }, [page]); + + // Clearing only resets the input; the debounce effect applies the empty + // query and reloads the first page, and does nothing when the empty query + // is already the applied one. + const handleClearSearch = useCallback(() => setQuery(""), []); + + const showEmptyState = !busy && error === null && creators.length === 0; + + return ( +
    +
    +
    +
    +

    + Creator Directory +

    +

    + Browse the creators anchoring their work on the Stellar network. +

    +
    + + {/* Search */} +
    + +
    +
    +
    + + {/* Result summary, announced to assistive tech */} +

    + {loadingInitial + ? "Loading creators…" + : `${creators.length} ${ + creators.length === 1 ? "creator" : "creators" + } loaded`} +

    + + {/* Error */} + {error && ( +
    + + + +
    + )} + + {/* Directory */} +
      + {creators.map((creator) => ( + + ))} + + {loadingInitial && + Array.from({ length: SKELETON_COUNT }, (_, index) => ( + + ))} +
    + + {showEmptyState && ( +
    +
    + )} + + {/* Sentinel: entering the viewport requests the next page. */} + {hasMore && !error && ( +
    +
    + ); +} diff --git a/frontend/app/creators/services/creatorService.ts b/frontend/app/creators/services/creatorService.ts new file mode 100644 index 0000000..dd14e91 --- /dev/null +++ b/frontend/app/creators/services/creatorService.ts @@ -0,0 +1,157 @@ +import { + fetchAllCertificates, + searchCertificates, +} from "@/app/search/services/searchService"; +import type { SearchResult } from "@/app/search/types"; +import type { Creator, CreatorPage } from "../types"; + +/** + * Creator directory service. + * + * The backend has no dedicated creators endpoint yet, so the directory is + * derived from the public global certificate index that already powers the + * search page: certificates are requested one page at a time and grouped by + * the creator recorded in their manifest. + * + * Because the grouping happens per page, a creator can legitimately appear + * again in a later page. {@link mergeCreators} folds those repeats into the + * already-loaded entry, so callers that append pages (infinite scroll) end + * up with one row per creator regardless of how the certificates were + * distributed across pages. + */ + +/** Certificates requested per directory page. */ +export const CREATORS_PAGE_SIZE = 12; + +export interface FetchCreatorsOptions { + /** Zero-based page index. */ + page?: number; + /** Certificates requested per page. */ + pageSize?: number; + /** Optional free-text query matched by the certificate index. */ + search?: string; + /** AbortSignal so stale/unmounted requests can be cancelled. */ + signal?: AbortSignal; +} + +/* -------------------------------------------------------------------------- */ +/* Aggregation */ +/* -------------------------------------------------------------------------- */ + +/** Newest of two ISO timestamps, tolerating unparseable input. */ +function laterOf(a: string, b: string): string { + const timeA = new Date(a).getTime(); + const timeB = new Date(b).getTime(); + if (Number.isNaN(timeA)) return b; + if (Number.isNaN(timeB)) return a; + return timeA >= timeB ? a : b; +} + +function addCategory(categories: string[], category?: string): string[] { + if (!category || categories.includes(category)) return categories; + return [...categories, category]; +} + +/** + * Groups certificates by their creator address, newest activity first. + * Certificates without a creator are skipped: they cannot be attributed. + */ +export function groupCertificatesByCreator( + certificates: SearchResult[] +): Creator[] { + const byAddress = new Map(); + + for (const certificate of certificates) { + const address = certificate.creator?.trim(); + if (!address) continue; + + const existing = byAddress.get(address); + if (existing) { + existing.assetCount += 1; + existing.latestMintedAt = laterOf( + existing.latestMintedAt, + certificate.mintedAt + ); + existing.categories = addCategory(existing.categories, certificate.type); + continue; + } + + byAddress.set(address, { + address, + assetCount: 1, + latestMintedAt: certificate.mintedAt, + categories: addCategory([], certificate.type), + }); + } + + return Array.from(byAddress.values()).sort( + (a, b) => + new Date(b.latestMintedAt).getTime() - + new Date(a.latestMintedAt).getTime() + ); +} + +/** + * Appends a freshly loaded page onto the creators already on screen, + * folding repeats into the existing entry instead of duplicating a card. + * The order of the already-loaded creators is preserved so the list never + * reshuffles under the user while they scroll. + */ +export function mergeCreators( + existing: Creator[], + incoming: Creator[] +): Creator[] { + const merged = existing.map((creator) => ({ ...creator })); + const indexByAddress = new Map( + merged.map((creator, index) => [creator.address, index]) + ); + + for (const creator of incoming) { + const index = indexByAddress.get(creator.address); + if (index === undefined) { + indexByAddress.set(creator.address, merged.length); + merged.push({ ...creator }); + continue; + } + + const target = merged[index]; + target.assetCount += creator.assetCount; + target.latestMintedAt = laterOf( + target.latestMintedAt, + creator.latestMintedAt + ); + target.categories = creator.categories.reduce(addCategory, [ + ...target.categories, + ]); + } + + return merged; +} + +/* -------------------------------------------------------------------------- */ +/* Public API */ +/* -------------------------------------------------------------------------- */ + +/** + * Loads one page of the creator directory. `hasMore` is true when the + * certificate index returned a full page, meaning another request can be + * made for the next offset. + */ +export async function fetchCreators({ + page = 0, + pageSize = CREATORS_PAGE_SIZE, + search, + signal, +}: FetchCreatorsOptions = {}): Promise { + const query = search?.trim(); + const options = { limit: pageSize, skip: page * pageSize, signal }; + + const certificates = query + ? await searchCertificates(query, options) + : await fetchAllCertificates(options); + + return { + creators: groupCertificatesByCreator(certificates), + hasMore: certificates.length === pageSize, + }; +} diff --git a/frontend/app/creators/types.ts b/frontend/app/creators/types.ts new file mode 100644 index 0000000..c8afe71 --- /dev/null +++ b/frontend/app/creators/types.ts @@ -0,0 +1,31 @@ +/** + * Domain types for the Creator Directory. + * + * A `Creator` is the aggregate view of one wallet that has minted at least + * one certificate through the provenance Soroban contract: who they are, + * how many verified assets they own and when they were last active. + */ + +export interface Creator { + /** Stellar public key (or backend user id) identifying the creator. */ + address: string; + + /** Human-readable display name, when the index knows one. */ + name?: string; + + /** Number of verified assets attributed to this creator. */ + assetCount: number; + + /** ISO 8601 timestamp of this creator's most recent mint. */ + latestMintedAt: string; + + /** Coarse asset categories this creator has minted, e.g. "Image". */ + categories: string[]; +} + +/** One page of the creator directory. */ +export interface CreatorPage { + creators: Creator[]; + /** True when another page can be requested for the same query. */ + hasMore: boolean; +}