From e0f259e21502c09179f712cf4b8df81a38a7b3fc Mon Sep 17 00:00:00 2001 From: Justin Levine <20596508+jal-co@users.noreply.github.com> Date: Wed, 15 Jul 2026 18:25:02 -0400 Subject: [PATCH] feat(catalog): add tool showcase carousels Signed-off-by: Justin Levine <20596508+jal-co@users.noreply.github.com> --- apps/web/content/docs/curation.mdx | 8 +- apps/web/content/docs/publishing.mdx | 16 + .../tool-image-carousel.stories.tsx | 23 + .../src/components/tool-image-carousel.tsx | 60 + .../tool-listing-detail.stories.tsx | 2 +- .../src/components/tool-listing-detail.tsx | 11 +- .../src/components/tool-listing-editor.tsx | 76 +- apps/web/src/data/catalog.ts | 2 + apps/web/src/lib/catalog-db.ts | 6 + apps/web/src/lib/tool-listings.ts | 25 +- apps/web/src/lib/tool-review-standard.ts | 4 +- apps/web/src/lib/tool-showcase.ts | 16 + apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/api/upload-tool-image.ts | 31 + apps/web/src/routes/components.index.tsx | 49 +- apps/web/src/routes/dashboard.components.tsx | 6 +- .../src/routes/dashboard.review-tool.$id.tsx | 5 +- apps/web/test/tool-showcase.test.ts | 28 + packages/db/migrations/0034_many_longshot.sql | 2 + .../db/migrations/meta/0034_snapshot.json | 4324 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 2 + 22 files changed, 4680 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/components/tool-image-carousel.stories.tsx create mode 100644 apps/web/src/components/tool-image-carousel.tsx create mode 100644 apps/web/src/lib/tool-showcase.ts create mode 100644 apps/web/src/routes/api/upload-tool-image.ts create mode 100644 apps/web/test/tool-showcase.test.ts create mode 100644 packages/db/migrations/0034_many_longshot.sql create mode 100644 packages/db/migrations/meta/0034_snapshot.json diff --git a/apps/web/content/docs/curation.mdx b/apps/web/content/docs/curation.mdx index 917ffd0..ac7a4e7 100644 --- a/apps/web/content/docs/curation.mdx +++ b/apps/web/content/docs/curation.mdx @@ -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 diff --git a/apps/web/content/docs/publishing.mdx b/apps/web/content/docs/publishing.mdx index 6a1dbc4..6908e43 100644 --- a/apps/web/content/docs/publishing.mdx +++ b/apps/web/content/docs/publishing.mdx @@ -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 diff --git a/apps/web/src/components/tool-image-carousel.stories.tsx b/apps/web/src/components/tool-image-carousel.stories.tsx new file mode 100644 index 0000000..3316f1f --- /dev/null +++ b/apps/web/src/components/tool-image-carousel.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const SingleImage: Story = { args: { images: [image("Overview", "#171717")] } }; +export const Empty: Story = { args: { images: [] } }; diff --git a/apps/web/src/components/tool-image-carousel.tsx b/apps/web/src/components/tool-image-carousel.tsx new file mode 100644 index 0000000..9d82de3 --- /dev/null +++ b/apps/web/src/components/tool-image-carousel.tsx @@ -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 ( +
+ + {count > 1 ? ( + <> +
+ + +
+ + Screenshot {index + 1} of {count} + + ) : null} +
+ ); +} diff --git a/apps/web/src/components/tool-listing-detail.stories.tsx b/apps/web/src/components/tool-listing-detail.stories.tsx index f742068..2690e54 100644 --- a/apps/web/src/components/tool-listing-detail.stories.tsx +++ b/apps/web/src/components/tool-listing-detail.stories.tsx @@ -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() }], }, }, diff --git a/apps/web/src/components/tool-listing-detail.tsx b/apps/web/src/components/tool-listing-detail.tsx index 8bbf846..416aaf5 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 { 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"; @@ -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 (
@@ -17,7 +19,7 @@ export function ToolListingDetail({ item }: { item: CatalogItem }) {
-
Tool{item.category}
+
Tool{site.pricing === "freemium" ? "Freemium" : site.pricing === "paid" ? "Paid" : "Free"}{item.category}

{item.title}

{item.description}

Listed by @{item.namespace}

@@ -26,6 +28,12 @@ export function ToolListingDetail({ item }: { item: CatalogItem }) {
+ +
+ +
+

Live site preview

+
+
diff --git a/apps/web/src/components/tool-listing-editor.tsx b/apps/web/src/components/tool-listing-editor.tsx index 66e22f6..078a068 100644 --- a/apps/web/src/components/tool-listing-editor.tsx +++ b/apps/web/src/components/tool-listing-editor.tsx @@ -1,7 +1,9 @@ import { useRef, useState, type FormEvent } from "react"; -import { HiArrowTopRightOnSquare as External, HiGlobeAlt as Globe, HiArrowPath as Loader } from "react-icons/hi2"; +import { HiArrowLeft as ArrowLeft, HiArrowRight as ArrowRight, HiArrowTopRightOnSquare as External, HiGlobeAlt as Globe, HiArrowPath as Loader, HiPhoto as Photo, HiTrash as Trash } 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 { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { DnsRecordCard, OneClickSetup } from "@/components/domain-verify"; @@ -43,6 +45,8 @@ export function ToolListingEditor({ onInspect, onSubmit, onSubmitted, onCreateDo const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [category, setCategory] = useState(CATEGORIES[0].id); + const [pricing, setPricing] = useState("free"); + const [showcaseImageUrls, setShowcaseImageUrls] = useState([]); const [preview, setPreview] = useState(null); const [inspecting, setInspecting] = useState(false); const [submitting, setSubmitting] = useState(false); @@ -51,6 +55,7 @@ export function ToolListingEditor({ onInspect, onSubmit, onSubmitted, onCreateDo const [domainRecord, setDomainRecord] = useState<{ domain: string; token: string; verified: boolean } | null>(null); const [domainConnect, setDomainConnect] = useState<{ supported: boolean; provider?: string; applyUrl?: string } | null>(null); const [domainBusy, setDomainBusy] = useState(false); + const [uploading, setUploading] = useState(false); const verificationAttempt = useRef(0); async function inspect() { @@ -108,12 +113,47 @@ export function ToolListingEditor({ onInspect, onSubmit, onSubmitted, onCreateDo 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 }); + if (showcaseImageUrls.length === 0) { setSubmitting(false); setError("Upload at least one showcase image."); return; } + const result = await onSubmit({ siteUrl: normalizedUrl, name, title, description, category, pricing, showcaseImageUrls }); setSubmitting(false); if (!result.ok) { setError(result.error ?? "Could not submit the listing."); return; } await onSubmitted(); } + async function uploadImages(files: FileList | null) { + if (!files?.length) return; + const selected = Array.from(files); + if (showcaseImageUrls.length + selected.length > 6) { setError("Upload up to 6 showcase images."); return; } + setUploading(true); setError(""); + const uploaded: string[] = []; + try { + for (const file of selected) { + if (file.size > 2 * 1024 * 1024) throw new Error(`${file.name} is larger than 2 MB.`); + const form = new FormData(); form.set("file", file); + const response = await fetch("/api/upload-tool-image", { method: "POST", body: form }); + const result = await response.json() as { url?: string; error?: string }; + if (!response.ok || !result.url) throw new Error(result.error ?? `Could not upload ${file.name}.`); + uploaded.push(result.url); + } + setShowcaseImageUrls((current) => [...current, ...uploaded]); + } catch (uploadError) { + if (uploaded.length) setShowcaseImageUrls((current) => [...current, ...uploaded]); + setError(uploadError instanceof Error ? uploadError.message : "Could not upload the showcase images."); + } finally { + setUploading(false); + } + } + + function moveImage(index: number, delta: number) { + setShowcaseImageUrls((current) => { + const destination = index + delta; + if (destination < 0 || destination >= current.length) return current; + const next = [...current]; + [next[index], next[destination]] = [next[destination]!, next[index]!]; + return next; + }); + } + return (
@@ -132,21 +172,37 @@ export function ToolListingEditor({ onInspect, onSubmit, onSubmitted, onCreateDo
setName(event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))} maxLength={64} required />

Used in the Modulora detail URL.