Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions frontend/app/creators/components/CreatorCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<li className="h-full">
<Link
href={`/search?creator=${encodeURIComponent(creator.address)}`}
aria-label={`View verified assets by ${displayName}`}
className="flex h-full flex-col gap-4 rounded-2xl border border-gray-200 dark:border-white/10 bg-white dark:bg-darkblue p-5 shadow-sm transition-all hover:border-primary hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary"
>
{initials(creator)}
</span>
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold text-gray-900 dark:text-white">
{displayName}
</h3>
<p className="truncate font-mono text-xs text-gray-500 dark:text-gray-400">
{truncateAddress(creator.address)}
</p>
</div>
</div>

<dl className="flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-gray-600 dark:text-gray-300">
<div className="flex items-center gap-1.5">
<FileCheck2 className="h-4 w-4 text-primary" aria-hidden="true" />
<dt className="sr-only">Verified assets</dt>
<dd>
{creator.assetCount} verified {assetLabel}
</dd>
</div>
<div className="flex items-center gap-1.5">
<Clock className="h-4 w-4 text-gray-400" aria-hidden="true" />
<dt className="sr-only">Last mint</dt>
<dd>{formatDate(creator.latestMintedAt)}</dd>
</div>
</dl>

{creator.categories.length > 0 && (
<ul className="flex flex-wrap gap-1.5">
{creator.categories.map((category) => (
<li
key={category}
className="rounded-full border border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5 px-2.5 py-0.5 text-[11px] font-medium text-gray-600 dark:text-gray-300"
>
{category}
</li>
))}
</ul>
)}
</Link>
</li>
);
}
71 changes: 71 additions & 0 deletions frontend/app/creators/hooks/useInfiniteScroll.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>(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;
Loading
Loading