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:
1 parent
77b0ec8ad8
commit
1445cb7e1e
27 files changed
+524
-86
No files matched your search
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+5
-2
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user