Keep listed published rules public and guest-claim rules unlisted, with semantic document markup and share chrome that stays clickable.

Guest publishes remain share-by-link but stay out of the catalog and sitemap, and their pages send noindex until claimed. The published-rule view now uses heading and list markup matching Markdown export, and top banners sit below the header so Share is not covered.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
adilalloandCursor committed 2026-09-17 17:42:01 -06:00
1 parent 77b0ec8ad8
commit 1445cb7e1e
27 files changed
+524 -86

No files matched your search

+3 -2
View File
@@ -77,6 +77,7 @@ import {
} from "./utils/createFlowFooterClassNames";
import {
CREATE_FLOW_MD_CENTERED_MAIN_CLASS,
CREATE_FLOW_FIXED_TOP_BANNER_OFFSET_CLASS,
CREATE_FLOW_PAGE_GUTTER_CLASS,
CREATE_FLOW_SCROLL_REGION_CLASS,
CREATE_FLOW_VIEWPORT_FRAME_CLASS,
@@ -679,7 +680,7 @@ function CreateFlowLayoutContent({
<div className={`${CREATE_FLOW_VIEWPORT_FRAME_CLASS} bg-black`}>
{topBanners.length > 0 ? (
<div
className={`pointer-events-none fixed left-0 right-0 top-0 z-[200] flex flex-col gap-2 ${CREATE_FLOW_PAGE_GUTTER_CLASS} pt-[var(--spacing-measures-spacing-300,12px)]`}
className={`pointer-events-none fixed left-0 right-0 z-[200] flex flex-col gap-2 ${CREATE_FLOW_FIXED_TOP_BANNER_OFFSET_CLASS} ${CREATE_FLOW_PAGE_GUTTER_CLASS} pt-2`}
aria-live="polite"
>
{topBanners.map((b) => (
@@ -791,7 +792,7 @@ function CreateFlowLayoutContent({
}
onExit={(opts) => void handleExit(opts)}
buttonPalette={isCompletedStep ? "inverse" : undefined}
className={`shrink-0 ${
className={`relative z-[210] shrink-0 ${
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
}`.trim()}
/>
+7 -2
View File
@@ -11,7 +11,10 @@ import {
import { useCreateFlow } from "./context/CreateFlowContext";
import { fetchDraftFromServer } from "../../../lib/create/api";
import messages from "../../../messages/en/index";
import { CREATE_FLOW_PAGE_GUTTER_CLASS } from "./components/createFlowLayoutTokens";
import {
CREATE_FLOW_FIXED_TOP_BANNER_OFFSET_CLASS,
CREATE_FLOW_PAGE_GUTTER_CLASS,
} from "./components/createFlowLayoutTokens";
import Alert from "../../components/modals/Alert";
import {
isValidStep,
@@ -166,7 +169,9 @@ export function SignedInDraftHydration({
if (!loadingHydration) return null;
return (
<div className={`pointer-events-none fixed left-0 right-0 top-14 z-[170] flex justify-center ${CREATE_FLOW_PAGE_GUTTER_CLASS} pt-2 md:top-16`}>
<div
className={`pointer-events-none fixed left-0 right-0 z-[170] flex justify-center ${CREATE_FLOW_FIXED_TOP_BANNER_OFFSET_CLASS} ${CREATE_FLOW_PAGE_GUTTER_CLASS} pt-2`}
>
<div className="pointer-events-auto w-full max-w-[960px]">
<Alert
type="banner"
@@ -8,6 +8,12 @@ import { CREATE_FLOW_SCREEN_REGISTRY } from "../utils/createFlowScreenRegistry";
*/
export const CREATE_FLOW_PAGE_GUTTER_CLASS = "px-5 md:px-12 lg:px-16";
/**
* Place fixed top alerts below {@link CreateFlowTopNav} so Share / export
* stay clickable (header is ~56px, ~64px from `md`).
*/
export const CREATE_FLOW_FIXED_TOP_BANNER_OFFSET_CLASS = "top-14 md:top-16";
/**
* Wizard viewport frame. `dvh` tracks mobile browser chrome so the action bar
* stays on-screen; `max-h-dvh` keeps the flex column from overflowing it.
+8 -3
View File
@@ -3,7 +3,10 @@ import { notFound } from "next/navigation";
import messages from "../../../../messages/en/index";
import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules";
import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../lib/create/publishedDocumentToDisplaySections";
import { routeMetadata } from "../../../../lib/siteMetadata";
import {
robotsForPublishedRule,
routeMetadata,
} from "../../../../lib/siteMetadata";
import CommunityRule from "../../../components/type/CommunityRule";
import HeaderLockup from "../../../components/type/HeaderLockup";
@@ -26,9 +29,11 @@ export async function generateMetadata({
typeof rule.summary === "string" && rule.summary.trim().length > 0
? rule.summary
: undefined;
const robots = robotsForPublishedRule(rule.isListed);
return routeMetadata(`/rules/${rule.id}`, {
title: rule.title,
description,
...(robots ? { robots } : {}),
openGraph: {
title: rule.title,
description,
@@ -58,7 +63,7 @@ export default async function PublicRuleDetailPage({ params }: PageProps) {
: undefined;
return (
<div className="min-h-screen bg-[var(--color-teal-teal50,#c9fef9)]">
<article className="min-h-screen bg-[var(--color-teal-teal50,#c9fef9)]">
<div className="mx-auto flex max-w-[1200px] flex-col gap-[var(--measures-spacing-1200,48px)] px-5 py-[var(--spacing-scale-048,48px)] md:px-12 md:py-[var(--spacing-scale-064,64px)]">
<HeaderLockup
title={rule.title}
@@ -69,6 +74,6 @@ export default async function PublicRuleDetailPage({ params }: PageProps) {
/>
<CommunityRule sections={sections} />
</div>
</div>
</article>
);
}
@@ -6,6 +6,7 @@ import Share from "../../../../../components/modals/Share";
import Alert from "../../../../../components/modals/Alert";
import { CreateFlowHeaderLockup } from "../../../../../(app)/create/components/CreateFlowHeaderLockup";
import {
CREATE_FLOW_FIXED_TOP_BANNER_OFFSET_CLASS,
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "../../../../../(app)/create/components/createFlowLayoutTokens";
@@ -41,7 +42,9 @@ export function UseCaseCompletedRuleView({
style={{ background: pageBg }}
>
{actionBanner ? (
<div className="pointer-events-none fixed inset-x-0 top-0 z-20 flex justify-center px-5 pt-3">
<div
className={`pointer-events-none fixed inset-x-0 z-20 flex justify-center px-5 pt-2 ${CREATE_FLOW_FIXED_TOP_BANNER_OFFSET_CLASS}`}
>
<div className="pointer-events-auto w-full max-w-[639px]">
<Alert
type="banner"
@@ -72,7 +75,7 @@ export function UseCaseCompletedRuleView({
duplicateAriaLabel={duplicateAriaLabel}
exitLabel={exitLabel}
buttonPalette="inverse"
className="shrink-0 !bg-transparent"
className="relative z-[21] shrink-0 !bg-transparent"
onShare={onShareOpen}
onDuplicate={onDuplicate}
onExit={onExit}
+10 -17
View File
@@ -1,7 +1,11 @@
import type { Prisma } from "@prisma/client";
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "../../../lib/server/db";
import { getSessionPepper, isDatabaseConfigured } from "../../../lib/server/env";
import { listListedPublishedRules } from "../../../lib/server/publishedRules";
import {
getSessionPepper,
isDatabaseConfigured,
} from "../../../lib/server/env";
import {
hashSessionToken,
hashRuleClaimToken,
@@ -12,6 +16,7 @@ import { rateLimitKey } from "../../../lib/server/rateLimit";
import {
dbUnavailable,
errorJson,
internalError,
rateLimited,
serverMisconfigured,
} from "../../../lib/server/responses";
@@ -48,22 +53,10 @@ export const GET = apiRoute("rules.list", async (request: NextRequest) => {
const { searchParams } = new URL(request.url);
const take = Math.min(Number(searchParams.get("limit") ?? "50") || 50, 100);
/**
* Public catalog: owned rules only (last touched first). Guest / orphan rows
* (`userId` null) stay reachable at `/rules/[id]` and `GET /api/rules/[id]`.
*/
const rules = await prisma.publishedRule.findMany({
where: { userId: { not: null } },
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
take,
select: {
id: true,
title: true,
summary: true,
createdAt: true,
updatedAt: true,
},
});
const rules = await listListedPublishedRules(take);
if (rules === null) {
return internalError("Failed to list rules");
}
return NextResponse.json({ rules });
});
@@ -39,17 +39,20 @@ function CommunityRuleView({
categoryName={ruleSection.categoryName}
showRail={!useCardStyle}
>
{ruleSection.entries.map((entry, entryIndex) => {
const hasBlocks = Boolean(entry.blocks?.length);
return (
<TextBlock
key={entryIndex}
title={entry.title}
body={entry.body}
rows={hasBlocks ? entry.blocks : undefined}
/>
);
})}
<ul className="m-0 flex min-w-0 list-none flex-col gap-6 p-0">
{ruleSection.entries.map((entry, entryIndex) => {
const hasBlocks = Boolean(entry.blocks?.length);
return (
<li key={entryIndex} className="min-w-0">
<TextBlock
title={entry.title}
body={entry.body}
rows={hasBlocks ? entry.blocks : undefined}
/>
</li>
);
})}
</ul>
</Section>
))}
</div>
+8 -5
View File
@@ -21,21 +21,24 @@ function SectionView({
}: SectionProps) {
const inner = (
<div className="flex min-w-0 flex-1 flex-col gap-4" style={{ gap: "16px" }}>
<p className={CATEGORY_CLASS}>{categoryName}</p>
<h2 className={`${CATEGORY_CLASS} m-0`}>{categoryName}</h2>
<div className="flex min-w-0 flex-col gap-6">{children}</div>
</div>
);
if (!showRail) {
return (
<div className={`flex min-w-0 flex-col ${className}`.trim()} data-name="Section">
<section
className={`flex min-w-0 flex-col ${className}`.trim()}
data-name="Section"
>
{inner}
</div>
</section>
);
}
return (
<div
<section
className={`flex min-w-0 items-stretch gap-3 ${className}`.trim()}
style={{ gap: "12px" }}
data-name="Section"
@@ -46,7 +49,7 @@ function SectionView({
aria-hidden
/>
{inner}
</div>
</section>
);
}
@@ -50,7 +50,7 @@ function LabeledRowView({ row }: { row: TextRow }) {
return (
<div className="flex min-w-0 flex-col gap-2">
<p className={ROW_LABEL_CLASS}>{row.label}</p>
<h4 className={`${ROW_LABEL_CLASS} m-0`}>{row.label}</h4>
{imageSrc ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element -- same-origin or absolute upload URL */}
@@ -88,18 +88,24 @@ function TextBlockView({
const hasRows = rows && rows.length > 0;
return (
<div
<article
className={`flex min-w-0 flex-col gap-2 ${className}`.trim()}
data-name="TextBlock"
>
<p className={`${ENTRY_TITLE_CLASS} w-full min-w-0`}>{title}</p>
<h3 className={`${ENTRY_TITLE_CLASS} m-0 w-full min-w-0`}>{title}</h3>
<div className="flex min-w-0 flex-col gap-3">
{body.trim().length > 0 ? <ParagraphGroup text={body} /> : null}
{hasRows
? rows!.map((row, i) => <LabeledRowView key={i} row={row} />)
: null}
{hasRows ? (
<ul className="m-0 flex min-w-0 list-none flex-col gap-3 p-0">
{rows!.map((row, i) => (
<li key={i} className="min-w-0">
<LabeledRowView row={row} />
</li>
))}
</ul>
) : null}
</div>
</div>
</article>
);
}
+4 -2
View File
@@ -1,6 +1,8 @@
import type { MetadataRoute } from "next";
import { buildPublicSitemap } from "../lib/publicSitemap";
import { listListedPublishedRulesForSitemap } from "../lib/server/publishedRules";
export default function sitemap(): MetadataRoute.Sitemap {
return buildPublicSitemap();
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const listedRules = await listListedPublishedRulesForSitemap();
return buildPublicSitemap(listedRules);
}