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
7 changes: 7 additions & 0 deletions apps/web/content/docs/publishing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ 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.

To edit a tool, open **Dashboard → Listings** and choose **Edit listing**. The
slug remains fixed, while the verified URL/domain, title, description,
subcategory, external pricing label, and site-thumbnail carousel can change.
Every saved edit returns to usefulness review. The last approved version stays
public until a curator approves the complete edit; requested changes and
rejections remain visible to the creator for revision and resubmission.

## Sell externally (optional)

Direct checkout through Modulora isn't live during alpha. You can link a
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/components/tool-listing-editor.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,21 @@ export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};

export const Editing: Story = {
args: {
mode: "edit",
initial: {
name: "shieldcn-beautiful-readme-badges",
title: "Shieldcn — Beautiful README Badges",
description: "Beautiful GitHub README badges and charts styled as shadcn/ui, plus a visual README builder.",
category: "utilities",
siteUrl: "https://shieldcn.dev/",
showcaseImageUrls: [previewImage],
pricing: "free",
preview: { canonicalUrl: "https://shieldcn.dev/", title: "Shieldcn", description: "Beautiful README badges.", imageUrl: previewImage },
editStatus: "changes-requested",
reviewReason: "Update the first screenshot so it matches the current landing page.",
},
},
};
32 changes: 18 additions & 14 deletions apps/web/src/components/tool-listing-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { CATEGORIES } from "@/lib/taxonomy";
import type { ToolListingInput } from "@/lib/tool-listings";
import type { EditableToolListing, ToolListingInput } from "@/lib/tool-listings";

export interface ToolListingPreview {
canonicalUrl: string;
Expand All @@ -31,6 +31,8 @@ function normalizeHttpsUrl(value: string): string | null {
}

export interface ToolListingEditorProps {
mode?: "create" | "edit";
initial?: EditableToolListing;
onInspect: (siteUrl: string) => Promise<{ ok: boolean; error?: string; metadata?: ToolListingPreview; verificationDomain?: string }>;
onSubmit: (input: ToolListingInput) => Promise<{ ok: boolean; error?: string }>;
onSubmitted: () => Promise<void> | void;
Expand All @@ -39,15 +41,16 @@ export interface ToolListingEditorProps {
onDiscoverDomainConnect: (domain: string) => Promise<{ supported: boolean; provider?: string; applyUrl?: string }>;
}

export function ToolListingEditor({ onInspect, onSubmit, onSubmitted, onCreateDomain, onVerifyDomain, onDiscoverDomainConnect }: ToolListingEditorProps) {
const [siteUrl, setSiteUrl] = useState("");
const [name, setName] = useState("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [category, setCategory] = useState<string>(CATEGORIES[0].id);
const [pricing, setPricing] = useState<ToolListingInput["pricing"]>("free");
const [showcaseImageUrls, setShowcaseImageUrls] = useState<string[]>([]);
const [preview, setPreview] = useState<ToolListingPreview | null>(null);
export function ToolListingEditor({ mode = "create", initial, onInspect, onSubmit, onSubmitted, onCreateDomain, onVerifyDomain, onDiscoverDomainConnect }: ToolListingEditorProps) {
const editing = mode === "edit";
const [siteUrl, setSiteUrl] = useState(initial?.siteUrl ?? "");
const [name, setName] = useState(initial?.name ?? "");
const [title, setTitle] = useState(initial?.title ?? "");
const [description, setDescription] = useState(initial?.description ?? "");
const [category, setCategory] = useState<string>(initial?.category ?? CATEGORIES[0].id);
const [pricing, setPricing] = useState<ToolListingInput["pricing"]>(initial?.pricing ?? "free");
const [showcaseImageUrls, setShowcaseImageUrls] = useState<string[]>(initial?.showcaseImageUrls ?? []);
const [preview, setPreview] = useState<ToolListingPreview | null>(initial?.preview ?? null);
const [inspecting, setInspecting] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
Expand Down Expand Up @@ -158,9 +161,10 @@ export function ToolListingEditor({ onInspect, onSubmit, onSubmitted, onCreateDo
<form onSubmit={submit} className="grid w-full gap-6 xl:grid-cols-[minmax(0,0.8fr)_minmax(28rem,1.2fr)]">
<div className="flex flex-col gap-5 rounded-xl border border-border/60 bg-card/40 p-6">
<div>
<h2 className="text-sm font-semibold">Tool or site details</h2>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">Only owner-authorized sites on a domain verified in Settings may be submitted.</p>
<h2 className="text-sm font-semibold">{editing ? "Edit tool or site" : "Tool or site details"}</h2>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">Only owner-authorized sites on a domain verified in Settings may be submitted. {editing ? "Every saved edit returns to curator review while the approved version stays public." : null}</p>
</div>
{initial?.editStatus ? <div className="rounded-lg border border-border/60 bg-secondary/25 px-3 py-2 text-xs leading-relaxed"><span className="font-medium">Edit {initial.editStatus === "pending" ? "in review" : initial.editStatus.replace("-", " ")}.</span>{initial.reviewReason ? ` ${initial.reviewReason}` : " You can revise and resubmit this draft."}</div> : null}
<div className="flex flex-col gap-2">
<Label htmlFor="tool-url">Verified HTTPS URL</Label>
<div className="flex gap-2">
Expand All @@ -169,7 +173,7 @@ export function ToolListingEditor({ onInspect, onSubmit, onSubmitted, onCreateDo
</div>
</div>
<div className="flex flex-col gap-2"><Label htmlFor="tool-title">Title</Label><Input id="tool-title" value={title} onChange={(event) => setTitle(event.target.value)} maxLength={120} required /></div>
<div className="flex flex-col gap-2"><Label htmlFor="tool-slug">Listing slug</Label><Input id="tool-slug" value={name} onChange={(event) => setName(event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))} maxLength={64} required /><p className="text-xs text-muted-foreground">Used in the Modulora detail URL.</p></div>
<div className="flex flex-col gap-2"><Label htmlFor="tool-slug">Listing slug</Label><Input id="tool-slug" value={name} disabled={editing} onChange={(event) => setName(event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))} maxLength={64} required /><p className="text-xs text-muted-foreground">{editing ? "The public listing URL stays fixed." : "Used in the Modulora detail URL."}</p></div>
<div className="flex flex-col gap-2"><Label htmlFor="tool-description">Description</Label><textarea id="tool-description" value={description} onChange={(event) => setDescription(event.target.value)} rows={5} minLength={24} maxLength={500} required className="resize-y rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" /></div>
<div className="flex flex-col gap-2"><Label htmlFor="tool-category">Category</Label><Select value={category} onValueChange={setCategory}><SelectTrigger id="tool-category"><SelectValue /></SelectTrigger><SelectContent>{CATEGORIES.map((item) => <SelectItem key={item.id} value={item.id}>{item.label}</SelectItem>)}</SelectContent></Select></div>
<div className="flex flex-col gap-2"><Label htmlFor="tool-pricing">Pricing</Label><Select value={pricing} onValueChange={(value) => setPricing(value as ToolListingInput["pricing"])}><SelectTrigger id="tool-pricing"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="free">Free</SelectItem><SelectItem value="freemium">Freemium</SelectItem><SelectItem value="paid">Paid</SelectItem></SelectContent></Select><p className="text-xs text-muted-foreground">Describes pricing on your site. Checkout remains external.</p></div>
Expand All @@ -195,7 +199,7 @@ export function ToolListingEditor({ onInspect, onSubmit, onSubmitted, onCreateDo
{showcaseImageUrls.length < 6 ? <label htmlFor="tool-images" className="flex min-h-11 cursor-pointer items-center justify-center gap-2 rounded-lg border border-dashed border-border px-3 py-2 text-sm text-muted-foreground transition-colors hover:border-foreground/25 hover:text-foreground"><Photo className="size-4" />{uploading ? "Uploading…" : showcaseImageUrls.length ? "Add images" : "Upload site thumbnails"}<input id="tool-images" type="file" accept="image/png,image/jpeg,image/webp" multiple disabled={uploading} className="sr-only" onChange={(event) => { void uploadImages(event.currentTarget.files); event.currentTarget.value = ""; }} /></label> : null}
</div>
{error ? <p className="text-sm text-destructive" role="alert">{error}</p> : null}
<Button type="submit" disabled={submitting || inspecting || uploading || !preview || showcaseImageUrls.length === 0}>{submitting ? <Loader className="size-4 animate-spin" /> : null} Submit for usefulness review</Button>
<Button type="submit" disabled={submitting || inspecting || uploading || !preview || showcaseImageUrls.length === 0}>{submitting ? <Loader className="size-4 animate-spin" /> : null} {editing ? "Submit changes for review" : "Submit for usefulness review"}</Button>
<p className="text-xs leading-relaxed text-muted-foreground">Submission enters the curator queue. Approval evaluates usefulness and catalog relevance; it is not a security or legal certification.</p>
</div>

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/data/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export interface CatalogItem {
title: string;
description: string;
category: string;
/** First public listing decision, or submission time when not yet reviewed. */
/** Stable initial listing creation date. */
listedAt?: string;
listingKind?: "component" | "tool";
site?: {
Expand Down
17 changes: 14 additions & 3 deletions apps/web/src/lib/catalog-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ function toCatalogItem(
title: component.title,
description: component.description,
category: categoryLabel(component.category),
listedAt: (component.reviewedAt ?? component.submittedAt ?? component.createdAt).toISOString(),
listedAt: component.createdAt.toISOString(),
listingKind: component.listingKind as CatalogItem["listingKind"],
site: component.listingKind === "tool" && component.siteUrl && component.siteDomain
? {
Expand Down Expand Up @@ -714,6 +714,8 @@ export interface MyComponent {
previewImageUrl: string | null;
showcaseImageUrls: string[];
toolPricing: "free" | "freemium" | "paid" | null;
editStatus: "pending" | "changes-requested" | "rejected" | null;
editReviewReason: string | null;
}

export const fetchMyComponents = createServerFn({ method: "GET" }).handler(
Expand Down Expand Up @@ -758,8 +760,14 @@ export const fetchMyComponents = createServerFn({ method: "GET" }).handler(
.where(inArray(schema.reviewRecords.componentId, componentIds))
.orderBy(desc(schema.reviewRecords.createdAt))
: [];
const drafts = componentIds.length > 0
? await database.select({ componentId: schema.toolListingDrafts.componentId, status: schema.toolListingDrafts.status, reviewReason: schema.toolListingDrafts.reviewReason }).from(schema.toolListingDrafts).where(inArray(schema.toolListingDrafts.componentId, componentIds))
: [];
const draftByComponent = new Map(drafts.map((draft) => [draft.componentId, draft]));

return rows.map((row) => ({
return rows.map((row) => {
const draft = draftByComponent.get(row.component.id);
return {
name: row.component.name,
title: row.component.title,
category: categoryLabel(row.component.category),
Expand Down Expand Up @@ -787,7 +795,10 @@ export const fetchMyComponents = createServerFn({ method: "GET" }).handler(
previewImageUrl: row.component.previewImageUrl,
showcaseImageUrls: row.component.showcaseImageUrls,
toolPricing: row.component.toolPricing,
}));
editStatus: draft?.status ?? null,
editReviewReason: draft?.reviewReason ?? null,
};
});
},
);

Expand Down
34 changes: 31 additions & 3 deletions apps/web/src/lib/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export async function fireReviewWebhook(input: {
category: string;
paid: boolean;
listingKind: "component" | "tool";
submissionKind?: "new" | "edit";
origin: string;
}): Promise<void> {
const webhookUrl = process.env.REVIEW_WEBHOOK_URL;
Expand All @@ -48,7 +49,7 @@ export async function fireReviewWebhook(input: {
body: JSON.stringify({
embeds: [
{
title: `New ${input.listingKind === "tool" ? "tool/site" : "component"} awaiting review`,
title: `${input.submissionKind === "edit" ? "Edited" : "New"} ${input.listingKind === "tool" ? "tool/site" : "component"} awaiting review`,
url: reviewUrl,
description: `**${input.title}** by @${input.username}`,
color: 0x6366f1,
Expand Down Expand Up @@ -108,10 +109,25 @@ export const fetchReviewQueue = createServerFn({ method: "GET" }).handler(
.orderBy(desc(schema.components.submittedAt))
.limit(100);

const editRows = await db
.select({
id: schema.components.id,
name: schema.components.name,
namespace: schema.namespaces.name,
payload: schema.toolListingDrafts.payload,
submittedAt: schema.toolListingDrafts.submittedAt,
})
.from(schema.toolListingDrafts)
.innerJoin(schema.components, eq(schema.components.id, schema.toolListingDrafts.componentId))
.innerJoin(schema.namespaces, eq(schema.namespaces.id, schema.components.namespaceId))
.where(eq(schema.toolListingDrafts.status, "pending"))
.orderBy(desc(schema.toolListingDrafts.submittedAt))
.limit(100);

return {
ok: true,
isCurator: true,
items: rows.map((row) => ({
items: [...rows.map((row) => ({
id: row.id,
title: row.title,
name: row.name,
Expand All @@ -121,7 +137,19 @@ export const fetchReviewQueue = createServerFn({ method: "GET" }).handler(
listingKind: row.listingKind,
status: row.status,
submittedAt: row.submittedAt.toISOString(),
})),
})), ...editRows.map((row) => ({
id: row.id,
title: row.payload.title,
name: row.name,
namespace: row.namespace,
category: row.payload.category,
paid: false,
listingKind: "tool" as const,
status: "pending" as const,
submittedAt: row.submittedAt.toISOString(),
}))]
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
.slice(0, 100),
};
},
);
Expand Down
Loading
Loading