From f507fc2966eeb4ec4c6f119f9860694ad82fecfa Mon Sep 17 00:00:00 2001 From: Justin Levine <20596508+jal-co@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:02:03 -0400 Subject: [PATCH] fix(catalog): treat tools as first-class listings Signed-off-by: Justin Levine <20596508+jal-co@users.noreply.github.com> --- apps/web/src/components/app-shell.tsx | 2 +- .../components/dashboard-sidebar.stories.tsx | 2 +- apps/web/src/components/dashboard-sidebar.tsx | 2 +- .../src/components/external-site-preview.tsx | 8 +++- .../src/components/tool-listing-detail.tsx | 10 +++-- .../src/components/tool-listing-editor.tsx | 20 +++++++++- .../components/tool-listing-image.stories.tsx | 20 ++++++++++ .../web/src/components/tool-listing-image.tsx | 37 +++++++++++++++++++ apps/web/src/lib/catalog-db.ts | 4 +- apps/web/src/lib/marketplace.ts | 4 +- apps/web/src/lib/studio.ts | 17 ++++----- apps/web/src/routes/components.index.tsx | 20 ++++++---- apps/web/src/routes/dashboard.components.tsx | 13 ++++--- apps/web/src/routes/dashboard.index.tsx | 2 +- 14 files changed, 125 insertions(+), 36 deletions(-) create mode 100644 apps/web/src/components/tool-listing-image.stories.tsx create mode 100644 apps/web/src/components/tool-listing-image.tsx diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index 3507c06..fd5514e 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -25,7 +25,7 @@ import type { CurrentUser } from "@/lib/session"; import { isRenderableImageUrl } from "@/lib/image-url"; const NAV_LINKS = [ - { label: "Components", to: "/components" as const }, + { label: "Listings", to: "/components" as const }, { label: "Docs", to: "/docs/$" as const, params: { _splat: "" } }, ]; diff --git a/apps/web/src/components/dashboard-sidebar.stories.tsx b/apps/web/src/components/dashboard-sidebar.stories.tsx index f009675..bb9724f 100644 --- a/apps/web/src/components/dashboard-sidebar.stories.tsx +++ b/apps/web/src/components/dashboard-sidebar.stories.tsx @@ -7,7 +7,7 @@ import { DashboardSidebar } from "./dashboard-sidebar"; const summary: StudioSummary = { user: { name: "Maker", username: "maker", image: null }, namespace: "maker", - counts: { components: 9, libraries: 0, verifiedInstalls: 42 }, + counts: { listings: 9, libraries: 0, verifiedInstalls: 42 }, roles: { curator: true, owner: true }, journey: { published: true, approved: true, payouts: true, priced: true }, }; diff --git a/apps/web/src/components/dashboard-sidebar.tsx b/apps/web/src/components/dashboard-sidebar.tsx index 234b051..5a69ecc 100644 --- a/apps/web/src/components/dashboard-sidebar.tsx +++ b/apps/web/src/components/dashboard-sidebar.tsx @@ -82,7 +82,7 @@ function SidebarContent({ summary, mobile = false }: { summary: StudioSummary; m Overview - Listings{summary.counts.components} + Listings{summary.counts.listings} ListsPlus {DIRECT_MARKETPLACE_ENABLED ? Purchases : null} Collections diff --git a/apps/web/src/components/external-site-preview.tsx b/apps/web/src/components/external-site-preview.tsx index fae80e3..a0b0b2d 100644 --- a/apps/web/src/components/external-site-preview.tsx +++ b/apps/web/src/components/external-site-preview.tsx @@ -1,9 +1,15 @@ import { useEffect, useState } from "react"; import { HiArrowTopRightOnSquare as External, HiGlobeAlt as Globe, HiPhoto as ImageIcon } from "react-icons/hi2"; +import { ToolListingImage } from "@/components/tool-listing-image"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +function hostnameOf(url: string): string { + try { return new URL(url).hostname; } + catch { return "External tool"; } +} + export function ExternalSitePreview({ url, title, @@ -46,7 +52,7 @@ export function ExternalSitePreview({
) ) : imageUrl ? ( - {imageAlt} + ) : (
diff --git a/apps/web/src/components/tool-listing-detail.tsx b/apps/web/src/components/tool-listing-detail.tsx index ed195bb..8bbf846 100644 --- a/apps/web/src/components/tool-listing-detail.tsx +++ b/apps/web/src/components/tool-listing-detail.tsx @@ -1,6 +1,7 @@ import { Link } from "@tanstack/react-router"; -import { HiArrowTopRightOnSquare as External, HiCheckBadge as Check, HiGlobeAlt as Globe } from "react-icons/hi2"; +import { HiArrowLeft as ArrowLeft, HiArrowTopRightOnSquare as External, HiCheckBadge as Check, HiGlobeAlt as Globe } from "react-icons/hi2"; import { ExternalSitePreview } from "@/components/external-site-preview"; +import { ToolListingImage } from "@/components/tool-listing-image"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import type { CatalogItem } from "@/data/catalog"; @@ -11,9 +12,12 @@ export function ToolListingDetail({ item }: { item: CatalogItem }) { const domainEvidence = item.evidence.find((record) => record.type === "domain-verified" && record.status === "passed"); return (
+
-
Tool / site{item.category}
+
Tool{item.category}

{item.title}

{item.description}

Listed by @{item.namespace}

@@ -32,7 +36,7 @@ export function ToolListingDetail({ item }: { item: CatalogItem }) {
-

Site metadata and fallback

{site.ogImageUrl ? {`Open : null}
Fetched title
{site.ogTitle || "Not provided"}
Fetched description
{site.ogDescription || "Not provided"}
+

Site metadata and fallback

{site.ogImageUrl ? : null}
Fetched title
{site.ogTitle || "Not provided"}
Fetched description
{site.ogDescription || "Not provided"}

Scoped evidence

{domainEvidence ? `The creator proved DNS control of ${site.domain}.` : "No public domain-control evidence is available."}

Domain control and curator approval do not certify security, privacy, legality, accessibility, or ongoing usefulness.

diff --git a/apps/web/src/components/tool-listing-editor.tsx b/apps/web/src/components/tool-listing-editor.tsx index 794d497..66e22f6 100644 --- a/apps/web/src/components/tool-listing-editor.tsx +++ b/apps/web/src/components/tool-listing-editor.tsx @@ -18,6 +18,16 @@ export interface ToolListingPreview { imageUrl: string | null; } +function normalizeHttpsUrl(value: string): string | null { + try { + const trimmed = value.trim(); + const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`); + return url.protocol === "https:" ? url.toString() : null; + } catch { + return null; + } +} + export interface ToolListingEditorProps { onInspect: (siteUrl: string) => Promise<{ ok: boolean; error?: string; metadata?: ToolListingPreview; verificationDomain?: string }>; onSubmit: (input: ToolListingInput) => Promise<{ ok: boolean; error?: string }>; @@ -45,7 +55,10 @@ export function ToolListingEditor({ onInspect, onSubmit, onSubmitted, onCreateDo async function inspect() { setInspecting(true); setError(""); setPreview(null); - const result = await onInspect(siteUrl); + const normalizedUrl = normalizeHttpsUrl(siteUrl); + if (!normalizedUrl) { setInspecting(false); setError("Enter a valid HTTPS site URL."); return; } + setSiteUrl(normalizedUrl); + const result = await onInspect(normalizedUrl); setInspecting(false); if (!result.ok) { if (result.verificationDomain) { @@ -92,7 +105,10 @@ export function ToolListingEditor({ onInspect, onSubmit, onSubmitted, onCreateDo async function submit(event: FormEvent) { event.preventDefault(); setSubmitting(true); setError(""); - const result = await onSubmit({ siteUrl, name, title, description, category }); + const normalizedUrl = normalizeHttpsUrl(siteUrl); + if (!normalizedUrl) { setSubmitting(false); setError("Enter a valid HTTPS site URL."); return; } + setSiteUrl(normalizedUrl); + const result = await onSubmit({ siteUrl: normalizedUrl, name, title, description, category }); setSubmitting(false); if (!result.ok) { setError(result.error ?? "Could not submit the listing."); return; } await onSubmitted(); diff --git a/apps/web/src/components/tool-listing-image.stories.tsx b/apps/web/src/components/tool-listing-image.stories.tsx new file mode 100644 index 0000000..5f205d9 --- /dev/null +++ b/apps/web/src/components/tool-listing-image.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from "@storybook/tanstack-react"; + +import { ToolListingImage } from "./tool-listing-image"; + +const meta = { + title: "Tools/ToolListingImage", + component: ToolListingImage, + args: { domain: "shieldcn.dev", className: "h-72 w-[28rem] rounded-xl border border-border" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Missing: Story = { args: { src: null } }; +export const Broken: Story = { args: { src: "/missing-tool-preview.png" } }; +export const Loaded: Story = { + args: { + src: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1200' height='675'%3E%3Crect width='1200' height='675' fill='%23171717'/%3E%3Ctext x='600' y='338' text-anchor='middle' dominant-baseline='middle' fill='%23fafafa' font-family='Arial' font-size='54'%3EShieldcn preview%3C/text%3E%3C/svg%3E", + }, +}; diff --git a/apps/web/src/components/tool-listing-image.tsx b/apps/web/src/components/tool-listing-image.tsx new file mode 100644 index 0000000..dcf41a0 --- /dev/null +++ b/apps/web/src/components/tool-listing-image.tsx @@ -0,0 +1,37 @@ +import { useEffect, useState } from "react"; +import { HiGlobeAlt as Globe } from "react-icons/hi2"; + +import { cn } from "@/lib/utils"; + +export function ToolListingImage({ + src, + domain, + alt = "", + className, + imageClassName, +}: { + src?: string | null; + domain?: string | null; + alt?: string; + className?: string; + imageClassName?: string; +}) { + const [mounted, setMounted] = useState(false); + const [failedUrl, setFailedUrl] = useState(null); + + useEffect(() => setMounted(true), []); + const showImage = mounted && Boolean(src) && failedUrl !== src; + + return ( +
+ {showImage ? ( + {alt} setFailedUrl(src!)} className={cn("size-full object-cover", imageClassName)} /> + ) : ( +
+ + {domain || "External tool"} +
+ )} +
+ ); +} diff --git a/apps/web/src/lib/catalog-db.ts b/apps/web/src/lib/catalog-db.ts index 98bde86..694fc3a 100644 --- a/apps/web/src/lib/catalog-db.ts +++ b/apps/web/src/lib/catalog-db.ts @@ -169,7 +169,7 @@ export const fetchCatalog = createServerFn({ method: "GET" }).handler( }, ); -/** Components with an active paid promotion (clearly labeled on browse). */ +/** Listings with an active paid boost (clearly labeled on browse). */ export const fetchFeatured = createServerFn({ method: "GET" }).handler( async (): Promise => { const database = db(); @@ -707,6 +707,7 @@ export interface MyComponent { reviewHistory: CreatorReviewRecord[]; listingKind: "component" | "tool"; siteUrl: string | null; + siteDomain: string | null; previewImageUrl: string | null; } @@ -777,6 +778,7 @@ export const fetchMyComponents = createServerFn({ method: "GET" }).handler( })), listingKind: row.component.listingKind, siteUrl: row.component.siteUrl, + siteDomain: row.component.siteDomain, previewImageUrl: row.component.previewImageUrl, })); }, diff --git a/apps/web/src/lib/marketplace.ts b/apps/web/src/lib/marketplace.ts index 603edd5..5e2680b 100644 --- a/apps/web/src/lib/marketplace.ts +++ b/apps/web/src/lib/marketplace.ts @@ -54,8 +54,8 @@ export const startPromotion = createServerFn({ method: "POST" }) .from(schema.components) .where(and(eq(schema.components.namespaceId, ns.id), eq(schema.components.name, data.name))) .limit(1); - if (!component) return { ok: false, error: "Component not found." }; - if (component.reviewStatus !== "approved") return { ok: false, error: "Only listed components can be promoted." }; + if (!component) return { ok: false, error: "Listing not found." }; + if (component.reviewStatus !== "approved") return { ok: false, error: "Only approved listings can be boosted." }; const [promo] = await db .insert(schema.promotions) diff --git a/apps/web/src/lib/studio.ts b/apps/web/src/lib/studio.ts index a776a1b..e313013 100644 --- a/apps/web/src/lib/studio.ts +++ b/apps/web/src/lib/studio.ts @@ -20,7 +20,7 @@ export interface StudioSummary { }; namespace: string | null; counts: { - components: number; + listings: number; libraries: number; verifiedInstalls: number; }; @@ -48,7 +48,7 @@ export const fetchStudioSummary = createServerFn({ method: "GET" }).handler( const summary: StudioSummary = { user: { name: user.name, username: user.username, image: user.image }, namespace: user.username, - counts: { components: 0, libraries: 0, verifiedInstalls: 0 }, + counts: { listings: 0, libraries: 0, verifiedInstalls: 0 }, roles: { curator: user.isCurator ?? false, owner: isOwnerUser(user.id) }, journey: { published: false, approved: false, payouts: user.payoutsEnabled ?? false, priced: false }, }; @@ -64,17 +64,14 @@ export const fetchStudioSummary = createServerFn({ method: "GET" }).handler( .where(eq(schema.namespaces.name, user.username)) .limit(1); if (ns) { + // Count every listing kind. Components and tools/sites are the first + // kinds, and future kinds remain included without dashboard changes. const [row] = await db .select({ total: count() }) .from(schema.components) - .where( - and( - eq(schema.components.namespaceId, ns.id), - eq(schema.components.framework, "react"), - ), - ); - summary.counts.components = row?.total ?? 0; - summary.journey.published = summary.counts.components > 0; + .where(eq(schema.components.namespaceId, ns.id)); + summary.counts.listings = row?.total ?? 0; + summary.journey.published = summary.counts.listings > 0; const [approved] = await db .select({ total: count() }) diff --git a/apps/web/src/routes/components.index.tsx b/apps/web/src/routes/components.index.tsx index 2ea154c..1fff97d 100644 --- a/apps/web/src/routes/components.index.tsx +++ b/apps/web/src/routes/components.index.tsx @@ -19,12 +19,14 @@ import { import { HiCheckBadge } from "react-icons/hi2"; import { PriceSeal, PromotedBadge } from "@/components/money"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { HiCalendarDays as CalendarDays, HiClock as Clock3, HiCube as Component, HiFunnel as Filter, HiGift as Gift, HiSquares2X2 as Grid2X2, HiListBullet as List, HiPlus as Plus, HiMagnifyingGlass as Search, HiSparkles as Sparkles, HiTableCells as Table2, HiTag as Tag, HiUsers as Users, HiXMark as X } from "react-icons/hi2"; +import { HiCalendarDays as CalendarDays, HiClock as Clock3, HiCube as Component, HiFunnel as Filter, HiGift as Gift, HiGlobeAlt as Globe, HiSquares2X2 as Grid2X2, HiListBullet as List, HiPlus as Plus, HiMagnifyingGlass as Search, HiSparkles as Sparkles, HiTableCells as Table2, HiTag as Tag, HiUsers as Users, HiXMark as X } from "react-icons/hi2"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { LiveCardPreview } from "@/components/live-card-preview"; +import { ToolListingImage } from "@/components/tool-listing-image"; import { Input } from "@/components/ui/input"; import { EmptyState } from "@/components/ui/empty-state"; import { fetchCatalog, fetchFeatured } from "@/lib/catalog-db"; @@ -170,7 +172,7 @@ function Catalog() { void setSearch({ q: event.target.value })} - placeholder="Search components, tools, and sites" + placeholder="Search listings" className="h-9 bg-secondary/50 pl-9" />
@@ -252,7 +254,7 @@ function Catalog() { className="flex items-center justify-between" >
- Components + Listings / {search.view === "authors" ? "Top authors" : search.view} {search.category ? <>/{search.category} : null} @@ -266,9 +268,13 @@ function Catalog() { void setSearch({ layout: "grid" })}> void setSearch({ layout: "list" })}>
- + + + + Component + Tool or site + +
@@ -331,7 +337,7 @@ function GalleryItem({ item, list }: { item: CatalogItem; list: boolean }) { function ToolCardPreview({ item, className }: { item: CatalogItem; className?: string }) { return (
- {item.site?.ogImageUrl ? :
{item.site?.domain ?? "External tool"}
} + Live site · {item.site?.domain}
); diff --git a/apps/web/src/routes/dashboard.components.tsx b/apps/web/src/routes/dashboard.components.tsx index f2b16da..150751e 100644 --- a/apps/web/src/routes/dashboard.components.tsx +++ b/apps/web/src/routes/dashboard.components.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { createFileRoute, Link, redirect, useRouter } from "@tanstack/react-router"; -import { HiSquaresPlus as Blocks, HiArrowTopRightOnSquare as ExternalLink, HiArrowPath as Loader2, HiClipboardDocumentCheck as Review, HiGlobeAlt as Globe, HiMagnifyingGlass as Search, HiPencil as Pencil, HiPlus as Plus, HiSparkles as Sparkles, HiTag as Tag, HiTrash as Trash2 } from "react-icons/hi2"; +import { HiSquaresPlus as Blocks, HiArrowTopRightOnSquare as ExternalLink, HiArrowPath as Loader2, HiClipboardDocumentCheck as Review, HiMagnifyingGlass as Search, HiPencil as Pencil, HiPlus as Plus, HiSparkles as Sparkles, HiTag as Tag, HiTrash as Trash2 } from "react-icons/hi2"; import { Button } from "@/components/ui/button"; @@ -17,6 +17,7 @@ import { deleteMyComponent, fetchMyComponents, type MyComponent } from "@/lib/ca import { confirmCheckout, setComponentPrice, startPromotion } from "@/lib/marketplace"; import { EarningsBreakdown, LicensePicker, PriceSeal } from "@/components/money"; import { LiveCardPreview } from "@/components/live-card-preview"; +import { ToolListingImage } from "@/components/tool-listing-image"; import { Input } from "@/components/ui/input"; import { EmptyState } from "@/components/ui/empty-state"; import { DashboardPageHeader } from "@/components/dashboard-page-header"; @@ -72,7 +73,7 @@ function MyComponents() { {promoted ? (
- Promotion active — your component is now featured on browse. + Boost active — your listing is now featured on browse.
) : null} @@ -233,7 +234,7 @@ function ComponentCard({ component, username, payoutsEnabled }: { component: MyC
{component.listingKind === "tool" ? ( -
{component.previewImageUrl ? : }
+ ) : }
@@ -259,10 +260,10 @@ function ComponentCard({ component, username, payoutsEnabled }: { component: MyC Open {component.listingKind !== "tool" ? Edit component : null} - {component.reviewStatus === "approved" && component.listingKind !== "tool" ? ( + {component.reviewStatus === "approved" ? ( <> - {DIRECT_MARKETPLACE_ENABLED ? : null} - Promote listing + {DIRECT_MARKETPLACE_ENABLED && component.listingKind !== "tool" ? : null} + Boost listing ) : null} { setOpen(next); if (!next) setConfirm(""); }}> diff --git a/apps/web/src/routes/dashboard.index.tsx b/apps/web/src/routes/dashboard.index.tsx index fdb30eb..eb78f2d 100644 --- a/apps/web/src/routes/dashboard.index.tsx +++ b/apps/web/src/routes/dashboard.index.tsx @@ -20,7 +20,7 @@ type ContentType = { }; const CONTENT_TYPES: ContentType[] = [ - { label: "Listings", icon: Blocks, key: "components" }, + { label: "Listings", icon: Blocks, key: "listings" }, { label: "Collections", icon: Library, key: "libraries" }, { label: "Verified installs", icon: ShieldCheck, key: "verifiedInstalls", receipt: true }, ];