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
2 changes: 1 addition & 1 deletion apps/web/src/components/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: "" } },
];

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/dashboard-sidebar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
};
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/dashboard-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ function SidebarContent({ summary, mobile = false }: { summary: StudioSummary; m

<SidebarSection label="Library">
<Link to="/dashboard" activeOptions={{ exact: true }} className={itemClass}><BarChart3 className="size-4 shrink-0 opacity-70" /><span className="flex-1 truncate">Overview</span></Link>
<Link to="/dashboard/components" className={itemClass}><Blocks className="size-4 shrink-0 opacity-70" /><span className="flex-1 truncate">Listings</span><span className="text-xs tabular-nums text-muted-foreground">{summary.counts.components}</span></Link>
<Link to="/dashboard/components" className={itemClass}><Blocks className="size-4 shrink-0 opacity-70" /><span className="flex-1 truncate">Listings</span><span className="text-xs tabular-nums text-muted-foreground">{summary.counts.listings}</span></Link>
<Link to="/dashboard/lists" className={itemClass}><Bookmark className="size-4 shrink-0 opacity-70" /><span className="flex-1 truncate">Lists</span><SidebarBadge>Plus</SidebarBadge></Link>
{DIRECT_MARKETPLACE_ENABLED ? <Link to="/dashboard/purchases" className={itemClass}><ShoppingBag className="size-4 shrink-0 opacity-70" /><span className="flex-1 truncate">Purchases</span></Link> : null}
<Link to="/dashboard/collections" className={itemClass}><Library className="size-4 shrink-0 opacity-70" /><span className="flex-1 truncate">Collections</span></Link>
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/components/external-site-preview.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -46,7 +52,7 @@ export function ExternalSitePreview({
<div className="size-full bg-secondary/20" aria-hidden />
)
) : imageUrl ? (
<img src={imageUrl} alt={imageAlt} referrerPolicy="no-referrer" className="size-full object-cover" />
<ToolListingImage src={imageUrl} domain={hostnameOf(url)} alt={imageAlt} className="size-full" />
) : (
<div className="flex size-full flex-col items-center justify-center gap-2 p-8 text-center text-muted-foreground">
<ImageIcon className="size-6" />
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/components/tool-listing-detail.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -11,9 +12,12 @@ export function ToolListingDetail({ item }: { item: CatalogItem }) {
const domainEvidence = item.evidence.find((record) => record.type === "domain-verified" && record.status === "passed");
return (
<div className="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6">
<Button asChild variant="ghost" size="sm" className="mb-4 -ml-2 gap-1.5 text-muted-foreground hover:text-foreground">
<Link to="/components"><ArrowLeft className="size-4" /> Back to listings</Link>
</Button>
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="mb-2 flex flex-wrap items-center gap-2"><Badge variant="outline">Tool / site</Badge><Badge variant="secondary">{item.category}</Badge></div>
<div className="mb-2 flex flex-wrap items-center gap-2"><Badge variant="outline">Tool</Badge><Badge variant="secondary">{item.category}</Badge></div>
<h1 className="text-3xl font-bold tracking-tight">{item.title}</h1>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-muted-foreground">{item.description}</p>
<p className="mt-3 text-xs text-muted-foreground">Listed by <Link to="/$username" params={{ username: item.namespace }} className="underline underline-offset-2">@{item.namespace}</Link></p>
Expand All @@ -32,7 +36,7 @@ export function ToolListingDetail({ item }: { item: CatalogItem }) {
</div>

<div className="mt-6 grid gap-4 md:grid-cols-2">
<div className="rounded-xl border border-border/60 bg-card/40 p-5"><h2 className="flex items-center gap-2 text-sm font-semibold"><Globe className="size-4" /> Site metadata and fallback</h2>{site.ogImageUrl ? <img src={site.ogImageUrl} alt={`Open Graph preview for ${item.title}`} className="mt-4 aspect-[16/9] w-full rounded-lg border border-border/60 object-cover" /> : null}<dl className="mt-4 grid gap-3 text-sm"><div><dt className="text-xs text-muted-foreground">Fetched title</dt><dd>{site.ogTitle || "Not provided"}</dd></div><div><dt className="text-xs text-muted-foreground">Fetched description</dt><dd className="leading-relaxed">{site.ogDescription || "Not provided"}</dd></div></dl></div>
<div className="rounded-xl border border-border/60 bg-card/40 p-5"><h2 className="flex items-center gap-2 text-sm font-semibold"><Globe className="size-4" /> Site metadata and fallback</h2>{site.ogImageUrl ? <ToolListingImage src={site.ogImageUrl} domain={site.domain} alt={`Open Graph preview for ${item.title}`} className="mt-4 aspect-[16/9] w-full rounded-lg border border-border/60" /> : null}<dl className="mt-4 grid gap-3 text-sm"><div><dt className="text-xs text-muted-foreground">Fetched title</dt><dd>{site.ogTitle || "Not provided"}</dd></div><div><dt className="text-xs text-muted-foreground">Fetched description</dt><dd className="leading-relaxed">{site.ogDescription || "Not provided"}</dd></div></dl></div>
<div className="rounded-xl border border-border/60 bg-card/40 p-5"><h2 className="flex items-center gap-2 text-sm font-semibold"><Check className="size-4 text-receipt" /> Scoped evidence</h2><p className="mt-3 text-sm leading-relaxed">{domainEvidence ? `The creator proved DNS control of ${site.domain}.` : "No public domain-control evidence is available."}</p><p className="mt-2 text-xs leading-relaxed text-muted-foreground">Domain control and curator approval do not certify security, privacy, legality, accessibility, or ongoing usefulness.</p></div>
</div>
</div>
Expand Down
20 changes: 18 additions & 2 deletions apps/web/src/components/tool-listing-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/components/tool-listing-image.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof ToolListingImage>;

export default meta;
type Story = StoryObj<typeof meta>;

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",
},
};
37 changes: 37 additions & 0 deletions apps/web/src/components/tool-listing-image.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);

useEffect(() => setMounted(true), []);
const showImage = mounted && Boolean(src) && failedUrl !== src;

return (
<div className={cn("relative overflow-hidden bg-secondary/20", className)}>
{showImage ? (
<img src={src!} alt={alt} onError={() => setFailedUrl(src!)} className={cn("size-full object-cover", imageClassName)} />
) : (
<div className="flex size-full flex-col items-center justify-center gap-2 text-muted-foreground">
<Globe className="size-6" />
<span className="max-w-[80%] truncate text-sm">{domain || "External tool"}</span>
</div>
)}
</div>
);
}
4 changes: 3 additions & 1 deletion apps/web/src/lib/catalog-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CatalogItem[]> => {
const database = db();
Expand Down Expand Up @@ -707,6 +707,7 @@ export interface MyComponent {
reviewHistory: CreatorReviewRecord[];
listingKind: "component" | "tool";
siteUrl: string | null;
siteDomain: string | null;
previewImageUrl: string | null;
}

Expand Down Expand Up @@ -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,
}));
},
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/lib/marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 7 additions & 10 deletions apps/web/src/lib/studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export interface StudioSummary {
};
namespace: string | null;
counts: {
components: number;
listings: number;
libraries: number;
verifiedInstalls: number;
};
Expand Down Expand Up @@ -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 },
};
Expand All @@ -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() })
Expand Down
20 changes: 13 additions & 7 deletions apps/web/src/routes/components.index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -170,7 +172,7 @@ function Catalog() {
<Input
value={search.q}
onChange={(event) => void setSearch({ q: event.target.value })}
placeholder="Search components, tools, and sites"
placeholder="Search listings"
className="h-9 bg-secondary/50 pl-9"
/>
</div>
Expand Down Expand Up @@ -252,7 +254,7 @@ function Catalog() {
className="flex items-center justify-between"
>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Components</span>
<span className="text-muted-foreground">Listings</span>
<span className="text-border">/</span>
<span className="capitalize">{search.view === "authors" ? "Top authors" : search.view}</span>
{search.category ? <><span className="text-border">/</span><span>{search.category}</span></> : null}
Expand All @@ -266,9 +268,13 @@ function Catalog() {
<LayoutButton label="Grid" active={search.layout === "grid"} onClick={() => void setSearch({ layout: "grid" })}><Grid2X2 /></LayoutButton>
<LayoutButton label="List" active={search.layout === "list"} onClick={() => void setSearch({ layout: "list" })}><List /></LayoutButton>
</div>
<Button asChild size="sm" className="gap-1.5">
<Link to="/dashboard/new"><Plus className="size-4" /> Add yours</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild><Button size="sm" className="gap-1.5"><Plus className="size-4" /> Add yours</Button></DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild><Link to="/dashboard/new" className="min-h-11 gap-2"><Component className="size-4" />Component</Link></DropdownMenuItem>
<DropdownMenuItem asChild><Link to="/dashboard/tools/new" className="min-h-11 gap-2"><Globe className="size-4" />Tool or site</Link></DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</motion.div>

Expand Down Expand Up @@ -331,7 +337,7 @@ function GalleryItem({ item, list }: { item: CatalogItem; list: boolean }) {
function ToolCardPreview({ item, className }: { item: CatalogItem; className?: string }) {
return (
<div className={`relative aspect-[16/10] overflow-hidden rounded-lg border border-border/60 bg-secondary/30 ${className ?? ""}`}>
{item.site?.ogImageUrl ? <img src={item.site.ogImageUrl} alt="" className="size-full object-cover transition-transform duration-300 group-hover:scale-[1.02]" /> : <div className="flex size-full items-center justify-center text-sm text-muted-foreground">{item.site?.domain ?? "External tool"}</div>}
<ToolListingImage src={item.site?.ogImageUrl} domain={item.site?.domain} className="size-full" imageClassName="transition-transform duration-300 group-hover:scale-[1.02] motion-reduce:transform-none motion-reduce:transition-none" />
<span className="absolute bottom-2 left-2 rounded-md bg-background/85 px-2 py-1 text-[10px] font-medium backdrop-blur-sm">Live site · {item.site?.domain}</span>
</div>
);
Expand Down
Loading
Loading