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
8 changes: 5 additions & 3 deletions apps/web/content/docs/curation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,16 @@ originality certification, accessibility certification, or legal review.

## Tool and site usefulness review

External tool and site listings use a separate `tool-alpha-1` standard.
External tool and site listings use a separate `tool-alpha-2` standard.
The creator must first prove control of the exact domain through **Settings →
Verified domains**. Curators then inspect an isolated live preview, the
creator's description and category, and server-fetched Open Graph metadata.
creator's description, category, external pricing label, creator-uploaded site
thumbnails, and server-fetched Open Graph metadata.

The listing decision is based on usefulness rather than metadata differences.
Curators consider whether the site works, helps visitors accomplish a concrete
task or outcome, belongs in Modulora's catalog, is presented accurately, and
task or outcome, belongs in Modulora's catalog, is presented accurately by its
copy, pricing label, and thumbnails, and
shows no obvious deception or harmful behavior. Metadata is supporting context.

Approval means the tool was useful and relevant enough for the catalog at the
Expand Down
16 changes: 16 additions & 0 deletions apps/web/content/docs/publishing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,22 @@ You'll see the status on your listings: **In review**, **Live**, or
**Changes requested** (with the reason). Review checks the listing is what it
claims to be; it is not a safety guarantee.

## List a tool or site

Choose **New → Tool or site** to submit an owner-authorized external resource.
Before submitting, verify the exact domain in **Settings → Verified domains**,
inspect the HTTPS URL, and provide:

- a title, description, catalog subcategory, and listing slug;
- an external pricing label: **Free**, **Freemium**, or **Paid**; and
- 1–6 creator-owned PNG, JPEG, or WebP site thumbnails, up to 2 MB each.

Reorder thumbnails to choose the catalog cover. The full set appears as a
carousel on catalog and detail surfaces. Pricing is descriptive metadata for
the external site; it does not enable Modulora checkout or buyer entitlements.
Tool listings also appear under the top-level **Tools** catalog category while
retaining their more specific subcategory.

## Sell externally (optional)

Direct checkout through Modulora isn't live during alpha. You can link a
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/components/tool-image-carousel.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from "@storybook/tanstack-react";

import { ToolImageCarousel } from "./tool-image-carousel";

const image = (label: string, color: string) => `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1200' height='900'%3E%3Crect width='1200' height='900' fill='${encodeURIComponent(color)}'/%3E%3Ctext x='600' y='450' text-anchor='middle' dominant-baseline='middle' fill='white' font-family='Arial' font-size='64'%3E${label}%3C/text%3E%3C/svg%3E`;

const meta = {
title: "Tools/ToolImageCarousel",
component: ToolImageCarousel,
args: {
title: "Contrast Workbench",
domain: "example.com",
images: [image("Overview", "#171717"), image("Editor", "#334155"), image("Report", "#0f766e")],
className: "aspect-[4/3] w-[32rem] rounded-xl border border-border",
},
} satisfies Meta<typeof ToolImageCarousel>;

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

export const Default: Story = {};
export const SingleImage: Story = { args: { images: [image("Overview", "#171717")] } };
export const Empty: Story = { args: { images: [] } };
60 changes: 60 additions & 0 deletions apps/web/src/components/tool-image-carousel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useEffect, useState } from "react";
import { HiChevronLeft as ChevronLeft, HiChevronRight as ChevronRight } from "react-icons/hi2";

import { ToolListingImage } from "@/components/tool-listing-image";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

export function ToolImageCarousel({
images,
domain,
title,
className,
imageClassName,
}: {
images: string[];
domain: string;
title: string;
className?: string;
imageClassName?: string;
}) {
const [index, setIndex] = useState(0);
const safeImages = images.filter(Boolean);
const count = safeImages.length;

useEffect(() => setIndex((current) => Math.min(current, Math.max(0, count - 1))), [count]);

function move(delta: number) {
if (count < 2) return;
setIndex((current) => (current + delta + count) % count);
}

return (
<div
role="region"
aria-roledescription="carousel"
aria-label={`${title} screenshots`}
className={cn("group/carousel relative overflow-hidden", className)}
>
<ToolListingImage
src={safeImages[index] ?? null}
domain={domain}
alt={count ? `${title} screenshot ${index + 1} of ${count}` : ""}
className="size-full"
imageClassName={imageClassName}
/>
{count > 1 ? (
<>
<div className="absolute inset-x-2 top-1/2 z-20 flex -translate-y-1/2 justify-between opacity-0 transition-opacity group-hover/carousel:opacity-100 group-focus-within/carousel:opacity-100 motion-reduce:transition-none">
<Button type="button" variant="outline" size="icon" className="pointer-events-auto size-11 rounded-full border-white/20 bg-black/70 text-white shadow-sm backdrop-blur-sm hover:bg-black/85 hover:text-white" aria-label="Previous screenshot" onClick={(event) => { event.preventDefault(); event.stopPropagation(); move(-1); }}><ChevronLeft /></Button>
<Button type="button" variant="outline" size="icon" className="pointer-events-auto size-11 rounded-full border-white/20 bg-black/70 text-white shadow-sm backdrop-blur-sm hover:bg-black/85 hover:text-white" aria-label="Next screenshot" onClick={(event) => { event.preventDefault(); event.stopPropagation(); move(1); }}><ChevronRight /></Button>
</div>
<div className="absolute bottom-3 left-1/2 z-20 flex -translate-x-1/2 gap-1.5 rounded-full bg-background/75 px-2 py-1.5 backdrop-blur-sm" aria-hidden="true">
{safeImages.map((image, dot) => <span key={image} className={cn("size-1.5 rounded-full", dot === index ? "bg-foreground" : "bg-muted-foreground/40")} />)}
</div>
<span className="sr-only" aria-live="polite">Screenshot {index + 1} of {count}</span>
</>
) : null}
</div>
);
}
2 changes: 1 addition & 1 deletion apps/web/src/components/tool-listing-detail.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const meta = {
description: "A focused visual tool for comparing interface colors and checking readable combinations.",
category: "Utilities",
listingKind: "tool",
site: { url: "https://example.com", domain: "example.com", ogTitle: "Contrast Workbench", ogDescription: "Compare interface colors.", ogImageUrl: null },
site: { url: "https://example.com", domain: "example.com", ogTitle: "Contrast Workbench", ogDescription: "Compare interface colors.", ogImageUrl: null, showcaseImageUrls: [], pricing: "freemium" },
evidence: [{ type: "domain-verified", status: "passed", issuer: "modulora-platform", timestamp: new Date().toISOString() }],
},
},
Expand Down
11 changes: 10 additions & 1 deletion 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 { HiArrowLeft as ArrowLeft, HiArrowTopRightOnSquare as External, HiCheckBadge as Check, HiGlobeAlt as Globe } from "react-icons/hi2";
import { ExternalSitePreview } from "@/components/external-site-preview";
import { ToolImageCarousel } from "@/components/tool-image-carousel";
import { ToolListingImage } from "@/components/tool-listing-image";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
Expand All @@ -9,6 +10,7 @@ import type { CatalogItem } from "@/data/catalog";
export function ToolListingDetail({ item }: { item: CatalogItem }) {
const site = item.site;
if (!site) return null;
const images = site.showcaseImageUrls.length ? site.showcaseImageUrls : site.ogImageUrl ? [site.ogImageUrl] : [];
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">
Expand All @@ -17,7 +19,7 @@ export function ToolListingDetail({ item }: { item: CatalogItem }) {
</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</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">{site.pricing === "freemium" ? "Freemium" : site.pricing === "paid" ? "Paid" : "Free"}</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 @@ -26,13 +28,20 @@ export function ToolListingDetail({ item }: { item: CatalogItem }) {
</div>

<div className="overflow-hidden rounded-2xl border border-border/60 bg-card/40">
<ToolImageCarousel images={images} domain={site.domain} title={item.title} className="aspect-[16/9] min-h-[28rem]" />
</div>

<div className="mt-6">
<h2 className="mb-3 text-sm font-semibold">Live site preview</h2>
<div className="overflow-hidden rounded-2xl border border-border/60 bg-card/40">
<ExternalSitePreview
url={site.url}
title={`Live preview of ${item.title}`}
imageUrl={site.ogImageUrl}
imageAlt={`Open Graph preview for ${item.title}`}
className="aspect-[16/9] min-h-[28rem]"
/>
</div>
</div>

<div className="mt-6 grid gap-4 md:grid-cols-2">
Expand Down
Loading
Loading