From 6c15fdbe5e4f0799063d92695a5ee23f59acd075 Mon Sep 17 00:00:00 2001 From: wlenig <30681316+wlenig@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:55:15 -0400 Subject: [PATCH 1/6] WEB-132 Extract document loading logic, fix over-fetching, add preview routes --- src/app/editor/[id]/[slug]/EditorPage.tsx | 36 +++++---------- src/app/editor/[id]/[slug]/PreviewPage.tsx | 44 +++++++++++++++++++ .../editor/[id]/[slug]/[versionId]/page.tsx | 21 +++------ .../[id]/[slug]/[versionId]/preview/page.tsx | 21 +++++++++ src/app/editor/[id]/[slug]/page.tsx | 19 +++----- src/app/editor/[id]/[slug]/params.ts | 22 ++++++++++ src/app/editor/[id]/[slug]/preview/page.tsx | 20 +++++++++ src/app/editor/[id]/page.tsx | 8 ++-- src/lib/documents/queries.ts | 31 ++++++++----- 9 files changed, 154 insertions(+), 68 deletions(-) create mode 100644 src/app/editor/[id]/[slug]/PreviewPage.tsx create mode 100644 src/app/editor/[id]/[slug]/[versionId]/preview/page.tsx create mode 100644 src/app/editor/[id]/[slug]/params.ts create mode 100644 src/app/editor/[id]/[slug]/preview/page.tsx diff --git a/src/app/editor/[id]/[slug]/EditorPage.tsx b/src/app/editor/[id]/[slug]/EditorPage.tsx index af87e050..3dc3829e 100644 --- a/src/app/editor/[id]/[slug]/EditorPage.tsx +++ b/src/app/editor/[id]/[slug]/EditorPage.tsx @@ -1,9 +1,8 @@ import "@puckeditor/core/puck.css"; -import type { Data } from "@puckeditor/core"; import { redirect } from "next/navigation"; import { notFound } from "next/navigation"; import { Client } from "./client"; -import { getDocumentById } from "../../../../lib/documents/queries"; +import { getDocumentById, getVersionContent } from "../../../../lib/documents/queries"; import { getEditorSlug, getEditorUrl } from "../../../../lib/editor-url"; import { createEmptyPuckData } from "../../../../lib/puck/utils"; @@ -27,7 +26,14 @@ export default async function EditorPage({ redirect(getEditorUrl(documentId, document.name, versionId)); } - const resolved = resolveVersion(versionId, document.versions); + const targetVersionId = + versionId !== undefined && !isNaN(versionId) + ? document.versions.find((v) => v.id === versionId)?.id + : document.versions[0]?.id; + + const data = targetVersionId + ? (await getVersionContent(targetVersionId)) ?? createEmptyPuckData() + : createEmptyPuckData(); const versions = document.versions.map((v) => ({ id: v.id, @@ -37,32 +43,14 @@ export default async function EditorPage({ return ( ); } - -function resolveVersion( - versionId: number | undefined, - versions: { id: number; documentId: number; content: unknown }[], -): { data: Data; versionId?: number } { - if (versionId !== undefined && !isNaN(versionId)) { - const match = versions.find((v) => v.id === versionId); - if (match) { - return { data: match.content as Data, versionId: match.id }; - } - } - - if (versions.length > 0) { - return { data: versions[0].content as Data, versionId: versions[0].id }; - } - - return { data: createEmptyPuckData() }; -} diff --git a/src/app/editor/[id]/[slug]/PreviewPage.tsx b/src/app/editor/[id]/[slug]/PreviewPage.tsx new file mode 100644 index 00000000..635d3bff --- /dev/null +++ b/src/app/editor/[id]/[slug]/PreviewPage.tsx @@ -0,0 +1,44 @@ +import { redirect } from "next/navigation"; +import { notFound } from "next/navigation"; +import { Client } from "../../../[...puckPath]/client"; +import { + getDocumentPreviewMeta, + getVersionContent, +} from "../../../../lib/documents/queries"; +import { getEditorSlug, getEditorUrl } from "../../../../lib/editor-url"; + +export default async function PreviewPage({ + documentId, + slug, + versionId, +}: { + documentId: number; + slug: string; + versionId?: number; +}) { + const document = await getDocumentPreviewMeta(documentId); + + if (!document) { + notFound(); + } + + const expectedSlug = getEditorSlug(document.name); + if (slug !== expectedSlug) { + redirect(`${getEditorUrl(documentId, document.name, versionId)}/preview`); + } + + const targetVersionId = + versionId ?? document.publishedVersionId ?? document.versions[0]?.id; + + if (!targetVersionId) { + notFound(); + } + + const data = await getVersionContent(targetVersionId); + + if (!data) { + notFound(); + } + + return ; +} diff --git a/src/app/editor/[id]/[slug]/[versionId]/page.tsx b/src/app/editor/[id]/[slug]/[versionId]/page.tsx index d3f162e1..47a8658d 100644 --- a/src/app/editor/[id]/[slug]/[versionId]/page.tsx +++ b/src/app/editor/[id]/[slug]/[versionId]/page.tsx @@ -1,29 +1,20 @@ -import type { Metadata } from "next"; import { notFound } from "next/navigation"; import EditorPage from "../EditorPage"; -import { getDocumentName } from "../../../../../lib/documents/queries"; +import { parseDocumentId, parseVersionId, generateDocumentMetadata } from "../params"; interface PageProps { params: Promise<{ id: string; slug: string; versionId: string }>; } -export async function generateMetadata({ params }: PageProps): Promise { - const { id } = await params; - const name = await getDocumentName(parseInt(id, 10)); - return { - title: name ? `Edit: ${name}` : "Document not found", - }; +export async function generateMetadata({ params }: PageProps) { + return generateDocumentMetadata((await params).id, "Edit"); } export default async function Page({ params }: PageProps) { const { id, slug, versionId: versionIdParam } = await params; - const documentId = parseInt(id, 10); - const versionId = parseInt(versionIdParam, 10); - - if (isNaN(documentId) || isNaN(versionId)) { - notFound(); - } - + const documentId = parseDocumentId(id); + const versionId = parseVersionId(versionIdParam); + if (!documentId || !versionId) notFound(); return ; } diff --git a/src/app/editor/[id]/[slug]/[versionId]/preview/page.tsx b/src/app/editor/[id]/[slug]/[versionId]/preview/page.tsx new file mode 100644 index 00000000..69d4a1ff --- /dev/null +++ b/src/app/editor/[id]/[slug]/[versionId]/preview/page.tsx @@ -0,0 +1,21 @@ +import { notFound } from "next/navigation"; +import PreviewPage from "../../PreviewPage"; +import { parseDocumentId, parseVersionId, generateDocumentMetadata } from "../../params"; + +interface PageProps { + params: Promise<{ id: string; slug: string; versionId: string }>; +} + +export async function generateMetadata({ params }: PageProps) { + return generateDocumentMetadata((await params).id, "Preview"); +} + +export default async function Page({ params }: PageProps) { + const { id, slug, versionId: versionIdParam } = await params; + const documentId = parseDocumentId(id); + const versionId = parseVersionId(versionIdParam); + if (!documentId || !versionId) notFound(); + return ; +} + +export const dynamic = "force-dynamic"; diff --git a/src/app/editor/[id]/[slug]/page.tsx b/src/app/editor/[id]/[slug]/page.tsx index 108d35bb..19fe4f37 100644 --- a/src/app/editor/[id]/[slug]/page.tsx +++ b/src/app/editor/[id]/[slug]/page.tsx @@ -1,28 +1,19 @@ -import type { Metadata } from "next"; import { notFound } from "next/navigation"; import EditorPage from "./EditorPage"; -import { getDocumentName } from "../../../../lib/documents/queries"; +import { parseDocumentId, generateDocumentMetadata } from "./params"; interface PageProps { params: Promise<{ id: string; slug: string }>; } -export async function generateMetadata({ params }: PageProps): Promise { - const { id } = await params; - const name = await getDocumentName(parseInt(id, 10)); - return { - title: name ? `Edit: ${name}` : "Document not found", - }; +export async function generateMetadata({ params }: PageProps) { + return generateDocumentMetadata((await params).id, "Edit"); } export default async function Page({ params }: PageProps) { const { id, slug } = await params; - const documentId = parseInt(id, 10); - - if (isNaN(documentId)) { - notFound(); - } - + const documentId = parseDocumentId(id); + if (!documentId) notFound(); return ; } diff --git a/src/app/editor/[id]/[slug]/params.ts b/src/app/editor/[id]/[slug]/params.ts new file mode 100644 index 00000000..1a59e0f2 --- /dev/null +++ b/src/app/editor/[id]/[slug]/params.ts @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import { getDocumentName } from "../../../../lib/documents/queries"; + +export function parseDocumentId(id: string): number | null { + const parsed = parseInt(id, 10); + return isNaN(parsed) ? null : parsed; +} + +export function parseVersionId(versionId: string): number | null { + const parsed = parseInt(versionId, 10); + return isNaN(parsed) ? null : parsed; +} + +export async function generateDocumentMetadata( + id: string, + prefix: string, +): Promise { + const name = await getDocumentName(parseInt(id, 10)); + return { + title: name ? `${prefix}: ${name}` : "Document not found", + }; +} diff --git a/src/app/editor/[id]/[slug]/preview/page.tsx b/src/app/editor/[id]/[slug]/preview/page.tsx new file mode 100644 index 00000000..506bc592 --- /dev/null +++ b/src/app/editor/[id]/[slug]/preview/page.tsx @@ -0,0 +1,20 @@ +import { notFound } from "next/navigation"; +import PreviewPage from "../PreviewPage"; +import { parseDocumentId, generateDocumentMetadata } from "../params"; + +interface PageProps { + params: Promise<{ id: string; slug: string }>; +} + +export async function generateMetadata({ params }: PageProps) { + return generateDocumentMetadata((await params).id, "Preview"); +} + +export default async function Page({ params }: PageProps) { + const { id, slug } = await params; + const documentId = parseDocumentId(id); + if (!documentId) notFound(); + return ; +} + +export const dynamic = "force-dynamic"; diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 881aa78d..5cb57d61 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -1,6 +1,6 @@ import { redirect } from "next/navigation"; import { notFound } from "next/navigation"; -import { getDocumentById } from "../../../lib/documents/queries"; +import { getDocumentName } from "../../../lib/documents/queries"; import { getEditorUrl } from "../../../lib/editor-url"; interface PageProps { @@ -15,13 +15,13 @@ export default async function Page({ params }: PageProps) { notFound(); } - const document = await getDocumentById(documentId); + const name = await getDocumentName(documentId); - if (!document) { + if (name === undefined) { notFound(); } - redirect(getEditorUrl(documentId, document.name)); + redirect(getEditorUrl(documentId, name)); } export const dynamic = "force-dynamic"; diff --git a/src/lib/documents/queries.ts b/src/lib/documents/queries.ts index 33a0b1a1..7c3c976c 100644 --- a/src/lib/documents/queries.ts +++ b/src/lib/documents/queries.ts @@ -5,9 +5,9 @@ export const getDocumentById = async (id: number) => { return await prisma.document.findUnique({ where: { id }, include: { - publishedVersion: true, versions: { orderBy: { createdAt: "desc" }, + select: { id: true, documentId: true, createdAt: true }, }, }, }); @@ -51,13 +51,6 @@ export const getAllRoutes = async (): Promise | null> => { }, {} as Record); }; -export const getVersions = async (documentId: number) => { - return await prisma.version.findMany({ - where: { documentId }, - orderBy: { createdAt: "desc" }, - }); -}; - export const getDocumentName = async (id: number) => { const doc = await prisma.document.findUnique({ where: { id }, @@ -66,11 +59,27 @@ export const getDocumentName = async (id: number) => { return doc?.name; }; -export const getVersionById = async (versionId: number) => { - return await prisma.version.findUnique({ +export const getDocumentPreviewMeta = async (id: number) => { + return await prisma.document.findUnique({ + where: { id }, + select: { + name: true, + publishedVersionId: true, + versions: { + orderBy: { createdAt: "desc" }, + take: 1, + select: { id: true }, + }, + }, + }); +}; + +export const getVersionContent = async (versionId: number) => { + const version = await prisma.version.findUnique({ where: { id: versionId }, - include: { document: true }, + select: { content: true }, }); + return version?.content as Data | null; }; export async function getDocumentSummaries() { From e29c5a164ca36496b4558cab1c616812f5b9c60d Mon Sep 17 00:00:00 2001 From: wlenig <30681316+wlenig@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:14:01 -0400 Subject: [PATCH 2/6] WEB-132 Add preview button to version list panel --- .../editor/[id]/[slug]/VersionListPanel.tsx | 35 +++++++++++++------ .../[id]/[slug]/VersionPluginContainer.tsx | 8 +++++ 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/app/editor/[id]/[slug]/VersionListPanel.tsx b/src/app/editor/[id]/[slug]/VersionListPanel.tsx index b68bd4ce..a2bb64db 100644 --- a/src/app/editor/[id]/[slug]/VersionListPanel.tsx +++ b/src/app/editor/[id]/[slug]/VersionListPanel.tsx @@ -1,3 +1,4 @@ +import { Eye } from "lucide-react"; import type { Version } from "../../../../lib/types"; export interface VersionListPanelProps { @@ -7,6 +8,7 @@ export interface VersionListPanelProps { publishedVersionId?: number | null; onLoadVersion: (versionId: number) => void; onPublishVersion: (versionId: number) => void; + onPreviewVersion: (versionId: number) => void; isPublishing?: boolean; isPublishDisabled?: boolean; } @@ -25,6 +27,7 @@ export function VersionListPanel({ publishedVersionId, onLoadVersion, onPublishVersion, + onPreviewVersion, isPublishing, isPublishDisabled, }: VersionListPanelProps) { @@ -71,22 +74,34 @@ export function VersionListPanel({ - {isPublished ? ( - - Published - - ) : ( +
+ {isPublished ? ( + + Published + + ) : ( + + )} - )} +
); diff --git a/src/app/editor/[id]/[slug]/VersionPluginContainer.tsx b/src/app/editor/[id]/[slug]/VersionPluginContainer.tsx index 15d5061c..9c4f4aae 100644 --- a/src/app/editor/[id]/[slug]/VersionPluginContainer.tsx +++ b/src/app/editor/[id]/[slug]/VersionPluginContainer.tsx @@ -34,6 +34,13 @@ export function VersionPluginContainer() { router.replace(getEditorUrl(documentId, documentName, versionIdToLoad)); }; + const handlePreviewVersion = (versionIdToPreview: number) => { + window.open( + `${getEditorUrl(documentId, documentName, versionIdToPreview)}/preview`, + "_blank", + ); + }; + return (
From 048cc7182bceb5ace8394c95481d447974616ce6 Mon Sep 17 00:00:00 2001 From: wlenig <30681316+wlenig@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:52:44 -0400 Subject: [PATCH 3/6] WEB-132 Make preview button an , extract VersionEntry to its own component, simlify preview URL construction --- .../editor/[id]/[slug]/VersionListPanel.tsx | 147 +++++++++++------- .../[id]/[slug]/VersionPluginContainer.tsx | 9 +- 2 files changed, 90 insertions(+), 66 deletions(-) diff --git a/src/app/editor/[id]/[slug]/VersionListPanel.tsx b/src/app/editor/[id]/[slug]/VersionListPanel.tsx index a2bb64db..9bf97bdb 100644 --- a/src/app/editor/[id]/[slug]/VersionListPanel.tsx +++ b/src/app/editor/[id]/[slug]/VersionListPanel.tsx @@ -1,4 +1,5 @@ import { Eye } from "lucide-react"; +import { cn } from "../../../../lib/utils"; import type { Version } from "../../../../lib/types"; export interface VersionListPanelProps { @@ -8,7 +9,7 @@ export interface VersionListPanelProps { publishedVersionId?: number | null; onLoadVersion: (versionId: number) => void; onPublishVersion: (versionId: number) => void; - onPreviewVersion: (versionId: number) => void; + previewBaseUrl: string; isPublishing?: boolean; isPublishDisabled?: boolean; } @@ -20,6 +21,79 @@ function formatVersionLabel(version: Version) { return { label, time }; } +function VersionEntry({ + version, + isCurrent, + isPublished, + onLoad, + onPublish, + previewBaseUrl, + isPublishing, + isPublishDisabled, +}: { + version: Version; + isCurrent: boolean; + isPublished: boolean; + onLoad: () => void; + onPublish: () => void; + previewBaseUrl: string; + isPublishing?: boolean; + isPublishDisabled?: boolean; +}) { + const { label, time } = formatVersionLabel(version); + + return ( + + ); +} + export function VersionListPanel({ versions, isLoading, @@ -27,7 +101,7 @@ export function VersionListPanel({ publishedVersionId, onLoadVersion, onPublishVersion, - onPreviewVersion, + previewBaseUrl, isPublishing, isPublishDisabled, }: VersionListPanelProps) { @@ -50,62 +124,19 @@ export function VersionListPanel({ ) : (
- {versions.map((version) => { - const isPublished = version.id === publishedVersionId; - const isCurrent = version.id === currentVersionId; - const { label, time } = formatVersionLabel(version); - - return ( -
onLoadVersion(version.id)} - className={`px-3 py-2 rounded text-xs cursor-pointer transition ${ - isCurrent - ? "bg-gray-100 font-semibold" - : "bg-white hover:bg-gray-50" - }`} - > -
-
- {label} - -
- {time} -
-
- -
- {isPublished ? ( - - Published - - ) : ( - - )} - -
-
-
- ); - })} + {versions.map((version) => ( + onLoadVersion(version.id)} + onPublish={() => onPublishVersion(version.id)} + previewBaseUrl={previewBaseUrl} + isPublishing={isPublishing} + isPublishDisabled={isPublishDisabled} + /> + ))}
)}
diff --git a/src/app/editor/[id]/[slug]/VersionPluginContainer.tsx b/src/app/editor/[id]/[slug]/VersionPluginContainer.tsx index 9c4f4aae..b541b2b8 100644 --- a/src/app/editor/[id]/[slug]/VersionPluginContainer.tsx +++ b/src/app/editor/[id]/[slug]/VersionPluginContainer.tsx @@ -34,13 +34,6 @@ export function VersionPluginContainer() { router.replace(getEditorUrl(documentId, documentName, versionIdToLoad)); }; - const handlePreviewVersion = (versionIdToPreview: number) => { - window.open( - `${getEditorUrl(documentId, documentName, versionIdToPreview)}/preview`, - "_blank", - ); - }; - return (
From 2bfe9036dfe88f6e1933912b7543a00e4f2c15bf Mon Sep 17 00:00:00 2001 From: wlenig <30681316+wlenig@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:39:28 -0400 Subject: [PATCH 4/6] WEB-132 Fix document-scoped version resolution in preview and editor --- src/app/editor/[id]/[slug]/EditorPage.tsx | 10 +-- src/app/editor/[id]/[slug]/PreviewPage.tsx | 15 +++-- .../[id]/[slug]/version-selection.test.ts | 64 +++++++++++++++++++ .../editor/[id]/[slug]/version-selection.ts | 38 +++++++++++ src/lib/documents/queries.ts | 15 ----- 5 files changed, 116 insertions(+), 26 deletions(-) create mode 100644 src/app/editor/[id]/[slug]/version-selection.test.ts create mode 100644 src/app/editor/[id]/[slug]/version-selection.ts diff --git a/src/app/editor/[id]/[slug]/EditorPage.tsx b/src/app/editor/[id]/[slug]/EditorPage.tsx index 3dc3829e..45042af8 100644 --- a/src/app/editor/[id]/[slug]/EditorPage.tsx +++ b/src/app/editor/[id]/[slug]/EditorPage.tsx @@ -5,6 +5,7 @@ import { Client } from "./client"; import { getDocumentById, getVersionContent } from "../../../../lib/documents/queries"; import { getEditorSlug, getEditorUrl } from "../../../../lib/editor-url"; import { createEmptyPuckData } from "../../../../lib/puck/utils"; +import { resolveEditorVersionId } from "./version-selection"; export default async function EditorPage({ documentId, @@ -26,10 +27,11 @@ export default async function EditorPage({ redirect(getEditorUrl(documentId, document.name, versionId)); } - const targetVersionId = - versionId !== undefined && !isNaN(versionId) - ? document.versions.find((v) => v.id === versionId)?.id - : document.versions[0]?.id; + const targetVersionId = resolveEditorVersionId(document.versions, versionId); + + if (versionId !== undefined && targetVersionId === undefined) { + notFound(); + } const data = targetVersionId ? (await getVersionContent(targetVersionId)) ?? createEmptyPuckData() diff --git a/src/app/editor/[id]/[slug]/PreviewPage.tsx b/src/app/editor/[id]/[slug]/PreviewPage.tsx index 635d3bff..7c613745 100644 --- a/src/app/editor/[id]/[slug]/PreviewPage.tsx +++ b/src/app/editor/[id]/[slug]/PreviewPage.tsx @@ -1,11 +1,9 @@ import { redirect } from "next/navigation"; import { notFound } from "next/navigation"; import { Client } from "../../../[...puckPath]/client"; -import { - getDocumentPreviewMeta, - getVersionContent, -} from "../../../../lib/documents/queries"; +import { getDocumentById, getVersionContent } from "../../../../lib/documents/queries"; import { getEditorSlug, getEditorUrl } from "../../../../lib/editor-url"; +import { resolvePreviewVersionId } from "./version-selection"; export default async function PreviewPage({ documentId, @@ -16,7 +14,7 @@ export default async function PreviewPage({ slug: string; versionId?: number; }) { - const document = await getDocumentPreviewMeta(documentId); + const document = await getDocumentById(documentId); if (!document) { notFound(); @@ -27,8 +25,11 @@ export default async function PreviewPage({ redirect(`${getEditorUrl(documentId, document.name, versionId)}/preview`); } - const targetVersionId = - versionId ?? document.publishedVersionId ?? document.versions[0]?.id; + const targetVersionId = resolvePreviewVersionId({ + versions: document.versions, + publishedVersionId: document.publishedVersionId, + requestedVersionId: versionId, + }); if (!targetVersionId) { notFound(); diff --git a/src/app/editor/[id]/[slug]/version-selection.test.ts b/src/app/editor/[id]/[slug]/version-selection.test.ts new file mode 100644 index 00000000..72d20aeb --- /dev/null +++ b/src/app/editor/[id]/[slug]/version-selection.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { resolveEditorVersionId, resolvePreviewVersionId } from "./version-selection"; + +describe("resolveEditorVersionId", () => { + const versions = [{ id: 30 }, { id: 20 }, { id: 10 }]; + + it("uses the latest version when no version is requested", () => { + expect(resolveEditorVersionId(versions)).toBe(30); + }); + + it("uses the requested version when it belongs to the document", () => { + expect(resolveEditorVersionId(versions, 20)).toBe(20); + }); + + it("rejects an unknown version id", () => { + expect(resolveEditorVersionId(versions, 999)).toBeUndefined(); + }); + + it("returns undefined when the document has no versions", () => { + expect(resolveEditorVersionId([], 999)).toBeUndefined(); + }); +}); + +describe("resolvePreviewVersionId", () => { + const versions = [{ id: 30 }, { id: 20 }, { id: 10 }]; + + it("prefers the requested version when it belongs to the document", () => { + expect( + resolvePreviewVersionId({ + versions, + publishedVersionId: 20, + requestedVersionId: 10, + }), + ).toBe(10); + }); + + it("rejects a requested version that does not belong to the document", () => { + expect( + resolvePreviewVersionId({ + versions, + publishedVersionId: 20, + requestedVersionId: 999, + }), + ).toBeUndefined(); + }); + + it("falls back to the published version when no version is requested", () => { + expect( + resolvePreviewVersionId({ + versions, + publishedVersionId: 20, + }), + ).toBe(20); + }); + + it("falls back to the latest version when there is no published version", () => { + expect( + resolvePreviewVersionId({ + versions, + publishedVersionId: null, + }), + ).toBe(30); + }); +}); diff --git a/src/app/editor/[id]/[slug]/version-selection.ts b/src/app/editor/[id]/[slug]/version-selection.ts new file mode 100644 index 00000000..aabe691d --- /dev/null +++ b/src/app/editor/[id]/[slug]/version-selection.ts @@ -0,0 +1,38 @@ +interface VersionOption { + id: number; +} + +export function resolveEditorVersionId( + versions: VersionOption[], + requestedVersionId?: number, +) { + if (requestedVersionId === undefined) { + return versions[0]?.id; + } + + // Editor URLs with an explicit version must stay scoped to the current document; + // an unknown id should 404 upstream rather than opening a different version. + return versions.some((version) => version.id === requestedVersionId) + ? requestedVersionId + : undefined; +} + +export function resolvePreviewVersionId({ + versions, + publishedVersionId, + requestedVersionId, +}: { + versions: VersionOption[]; + publishedVersionId: number | null; + requestedVersionId?: number; +}) { + if (requestedVersionId !== undefined) { + // Preview URLs must stay scoped to the current document; an unknown id should 404 upstream. + return versions.some((version) => version.id === requestedVersionId) + ? requestedVersionId + : undefined; + } + + // Without an explicit version, preview the published version and fall back to the latest draft. + return publishedVersionId ?? versions[0]?.id; +} diff --git a/src/lib/documents/queries.ts b/src/lib/documents/queries.ts index 7c3c976c..21c802d8 100644 --- a/src/lib/documents/queries.ts +++ b/src/lib/documents/queries.ts @@ -59,21 +59,6 @@ export const getDocumentName = async (id: number) => { return doc?.name; }; -export const getDocumentPreviewMeta = async (id: number) => { - return await prisma.document.findUnique({ - where: { id }, - select: { - name: true, - publishedVersionId: true, - versions: { - orderBy: { createdAt: "desc" }, - take: 1, - select: { id: true }, - }, - }, - }); -}; - export const getVersionContent = async (versionId: number) => { const version = await prisma.version.findUnique({ where: { id: versionId }, From 7ca196ba8bc04ce6730b93f09a103e38441fa437 Mon Sep 17 00:00:00 2001 From: wlenig <30681316+wlenig@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:05:00 -0400 Subject: [PATCH 5/6] WEB-132 Extract createDocumentRoute helper and loadDocument to deduplicate editor route boilerplate --- src/app/editor/[id]/[slug]/EditorPage.tsx | 16 +--- src/app/editor/[id]/[slug]/PreviewPage.tsx | 28 ++----- .../editor/[id]/[slug]/[versionId]/page.tsx | 22 +---- .../[id]/[slug]/[versionId]/preview/page.tsx | 22 +---- src/app/editor/[id]/[slug]/page.tsx | 21 +---- src/app/editor/[id]/[slug]/params.ts | 22 ----- src/app/editor/[id]/[slug]/params.tsx | 80 +++++++++++++++++++ src/app/editor/[id]/[slug]/preview/page.tsx | 21 +---- src/app/editor/[id]/page.tsx | 5 +- 9 files changed, 110 insertions(+), 127 deletions(-) delete mode 100644 src/app/editor/[id]/[slug]/params.ts create mode 100644 src/app/editor/[id]/[slug]/params.tsx diff --git a/src/app/editor/[id]/[slug]/EditorPage.tsx b/src/app/editor/[id]/[slug]/EditorPage.tsx index 45042af8..f75ad5f3 100644 --- a/src/app/editor/[id]/[slug]/EditorPage.tsx +++ b/src/app/editor/[id]/[slug]/EditorPage.tsx @@ -1,11 +1,10 @@ import "@puckeditor/core/puck.css"; -import { redirect } from "next/navigation"; import { notFound } from "next/navigation"; import { Client } from "./client"; -import { getDocumentById, getVersionContent } from "../../../../lib/documents/queries"; -import { getEditorSlug, getEditorUrl } from "../../../../lib/editor-url"; +import { getVersionContent } from "../../../../lib/documents/queries"; import { createEmptyPuckData } from "../../../../lib/puck/utils"; import { resolveEditorVersionId } from "./version-selection"; +import { loadDocument } from "./params"; export default async function EditorPage({ documentId, @@ -16,16 +15,7 @@ export default async function EditorPage({ slug: string; versionId?: number; }) { - const document = await getDocumentById(documentId); - - if (!document) { - notFound(); - } - - const expectedSlug = getEditorSlug(document.name); - if (slug !== expectedSlug) { - redirect(getEditorUrl(documentId, document.name, versionId)); - } + const document = await loadDocument(documentId, slug, { versionId }); const targetVersionId = resolveEditorVersionId(document.versions, versionId); diff --git a/src/app/editor/[id]/[slug]/PreviewPage.tsx b/src/app/editor/[id]/[slug]/PreviewPage.tsx index 7c613745..b4c3fff3 100644 --- a/src/app/editor/[id]/[slug]/PreviewPage.tsx +++ b/src/app/editor/[id]/[slug]/PreviewPage.tsx @@ -1,9 +1,8 @@ -import { redirect } from "next/navigation"; import { notFound } from "next/navigation"; import { Client } from "../../../[...puckPath]/client"; -import { getDocumentById, getVersionContent } from "../../../../lib/documents/queries"; -import { getEditorSlug, getEditorUrl } from "../../../../lib/editor-url"; +import { getVersionContent } from "../../../../lib/documents/queries"; import { resolvePreviewVersionId } from "./version-selection"; +import { loadDocument } from "./params"; export default async function PreviewPage({ documentId, @@ -14,16 +13,10 @@ export default async function PreviewPage({ slug: string; versionId?: number; }) { - const document = await getDocumentById(documentId); - - if (!document) { - notFound(); - } - - const expectedSlug = getEditorSlug(document.name); - if (slug !== expectedSlug) { - redirect(`${getEditorUrl(documentId, document.name, versionId)}/preview`); - } + const document = await loadDocument(documentId, slug, { + versionId, + redirectSuffix: "/preview", + }); const targetVersionId = resolvePreviewVersionId({ versions: document.versions, @@ -31,15 +24,10 @@ export default async function PreviewPage({ requestedVersionId: versionId, }); - if (!targetVersionId) { - notFound(); - } + if (!targetVersionId) notFound(); const data = await getVersionContent(targetVersionId); - - if (!data) { - notFound(); - } + if (!data) notFound(); return ; } diff --git a/src/app/editor/[id]/[slug]/[versionId]/page.tsx b/src/app/editor/[id]/[slug]/[versionId]/page.tsx index 47a8658d..9f443637 100644 --- a/src/app/editor/[id]/[slug]/[versionId]/page.tsx +++ b/src/app/editor/[id]/[slug]/[versionId]/page.tsx @@ -1,21 +1,7 @@ -import { notFound } from "next/navigation"; import EditorPage from "../EditorPage"; -import { parseDocumentId, parseVersionId, generateDocumentMetadata } from "../params"; - -interface PageProps { - params: Promise<{ id: string; slug: string; versionId: string }>; -} - -export async function generateMetadata({ params }: PageProps) { - return generateDocumentMetadata((await params).id, "Edit"); -} - -export default async function Page({ params }: PageProps) { - const { id, slug, versionId: versionIdParam } = await params; - const documentId = parseDocumentId(id); - const versionId = parseVersionId(versionIdParam); - if (!documentId || !versionId) notFound(); - return ; -} +import { createDocumentRoute } from "../params"; +const { generateMetadata, Page } = createDocumentRoute(EditorPage, "Edit"); +export { generateMetadata }; +export default Page; export const dynamic = "force-dynamic"; diff --git a/src/app/editor/[id]/[slug]/[versionId]/preview/page.tsx b/src/app/editor/[id]/[slug]/[versionId]/preview/page.tsx index 69d4a1ff..128600ec 100644 --- a/src/app/editor/[id]/[slug]/[versionId]/preview/page.tsx +++ b/src/app/editor/[id]/[slug]/[versionId]/preview/page.tsx @@ -1,21 +1,7 @@ -import { notFound } from "next/navigation"; import PreviewPage from "../../PreviewPage"; -import { parseDocumentId, parseVersionId, generateDocumentMetadata } from "../../params"; - -interface PageProps { - params: Promise<{ id: string; slug: string; versionId: string }>; -} - -export async function generateMetadata({ params }: PageProps) { - return generateDocumentMetadata((await params).id, "Preview"); -} - -export default async function Page({ params }: PageProps) { - const { id, slug, versionId: versionIdParam } = await params; - const documentId = parseDocumentId(id); - const versionId = parseVersionId(versionIdParam); - if (!documentId || !versionId) notFound(); - return ; -} +import { createDocumentRoute } from "../../params"; +const { generateMetadata, Page } = createDocumentRoute(PreviewPage, "Preview"); +export { generateMetadata }; +export default Page; export const dynamic = "force-dynamic"; diff --git a/src/app/editor/[id]/[slug]/page.tsx b/src/app/editor/[id]/[slug]/page.tsx index 19fe4f37..3f26ecc7 100644 --- a/src/app/editor/[id]/[slug]/page.tsx +++ b/src/app/editor/[id]/[slug]/page.tsx @@ -1,20 +1,7 @@ -import { notFound } from "next/navigation"; import EditorPage from "./EditorPage"; -import { parseDocumentId, generateDocumentMetadata } from "./params"; - -interface PageProps { - params: Promise<{ id: string; slug: string }>; -} - -export async function generateMetadata({ params }: PageProps) { - return generateDocumentMetadata((await params).id, "Edit"); -} - -export default async function Page({ params }: PageProps) { - const { id, slug } = await params; - const documentId = parseDocumentId(id); - if (!documentId) notFound(); - return ; -} +import { createDocumentRoute } from "./params"; +const { generateMetadata, Page } = createDocumentRoute(EditorPage, "Edit"); +export { generateMetadata }; +export default Page; export const dynamic = "force-dynamic"; diff --git a/src/app/editor/[id]/[slug]/params.ts b/src/app/editor/[id]/[slug]/params.ts deleted file mode 100644 index 1a59e0f2..00000000 --- a/src/app/editor/[id]/[slug]/params.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Metadata } from "next"; -import { getDocumentName } from "../../../../lib/documents/queries"; - -export function parseDocumentId(id: string): number | null { - const parsed = parseInt(id, 10); - return isNaN(parsed) ? null : parsed; -} - -export function parseVersionId(versionId: string): number | null { - const parsed = parseInt(versionId, 10); - return isNaN(parsed) ? null : parsed; -} - -export async function generateDocumentMetadata( - id: string, - prefix: string, -): Promise { - const name = await getDocumentName(parseInt(id, 10)); - return { - title: name ? `${prefix}: ${name}` : "Document not found", - }; -} diff --git a/src/app/editor/[id]/[slug]/params.tsx b/src/app/editor/[id]/[slug]/params.tsx new file mode 100644 index 00000000..463687fd --- /dev/null +++ b/src/app/editor/[id]/[slug]/params.tsx @@ -0,0 +1,80 @@ +import type { Metadata } from "next"; +import { notFound, redirect } from "next/navigation"; +import { getDocumentById, getDocumentName } from "../../../../lib/documents/queries"; +import { getEditorSlug, getEditorUrl } from "../../../../lib/editor-url"; + +export function parseDocumentId(id: string): number | null { + const parsed = parseInt(id, 10); + return isNaN(parsed) ? null : parsed; +} + +export function parseVersionId(versionId: string): number | null { + const parsed = parseInt(versionId, 10); + return isNaN(parsed) ? null : parsed; +} + +/** + * Fetches a document by ID and validates the URL slug, redirecting if it + * doesn't match. Calls `notFound()` when the document doesn't exist. + */ +export async function loadDocument( + documentId: number, + slug: string, + options?: { versionId?: number; redirectSuffix?: string }, +) { + const document = await getDocumentById(documentId); + if (!document) notFound(); + + const expectedSlug = getEditorSlug(document.name); + if (slug !== expectedSlug) { + const base = getEditorUrl(documentId, document.name, options?.versionId); + redirect(`${base}${options?.redirectSuffix ?? ""}`); + } + + return document; +} + +/** + * Creates the standard `generateMetadata` and default-export `Page` for a + * document route, eliminating the repeated param-parsing boilerplate. + */ +export function createDocumentRoute( + Component: React.ComponentType<{ + documentId: number; + slug: string; + versionId?: number; + }>, + metadataPrefix: string, +) { + async function generateMetadata({ + params, + }: { + params: Promise<{ id: string }>; + }): Promise { + const documentId = parseDocumentId((await params).id); + if (!documentId) return {}; + const name = await getDocumentName(documentId); + return { + title: name ? `${metadataPrefix}: ${name}` : "Document not found", + }; + } + + async function Page({ + params, + }: { + params: Promise<{ id: string; slug: string; versionId?: string }>; + }) { + const { id, slug, versionId: versionIdParam } = await params; + + const documentId = parseDocumentId(id); + if (!documentId) notFound(); + + const versionId = versionIdParam + ? parseVersionId(versionIdParam) || notFound() + : undefined; + + return ; + } + + return { generateMetadata, Page }; +} diff --git a/src/app/editor/[id]/[slug]/preview/page.tsx b/src/app/editor/[id]/[slug]/preview/page.tsx index 506bc592..66f9aa6f 100644 --- a/src/app/editor/[id]/[slug]/preview/page.tsx +++ b/src/app/editor/[id]/[slug]/preview/page.tsx @@ -1,20 +1,7 @@ -import { notFound } from "next/navigation"; import PreviewPage from "../PreviewPage"; -import { parseDocumentId, generateDocumentMetadata } from "../params"; - -interface PageProps { - params: Promise<{ id: string; slug: string }>; -} - -export async function generateMetadata({ params }: PageProps) { - return generateDocumentMetadata((await params).id, "Preview"); -} - -export default async function Page({ params }: PageProps) { - const { id, slug } = await params; - const documentId = parseDocumentId(id); - if (!documentId) notFound(); - return ; -} +import { createDocumentRoute } from "../params"; +const { generateMetadata, Page } = createDocumentRoute(PreviewPage, "Preview"); +export { generateMetadata }; +export default Page; export const dynamic = "force-dynamic"; diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 5cb57d61..dd2c95c8 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; import { notFound } from "next/navigation"; import { getDocumentName } from "../../../lib/documents/queries"; import { getEditorUrl } from "../../../lib/editor-url"; +import { parseDocumentId } from "./[slug]/params"; interface PageProps { params: Promise<{ id: string }>; @@ -9,9 +10,9 @@ interface PageProps { export default async function Page({ params }: PageProps) { const { id } = await params; - const documentId = parseInt(id, 10); + const documentId = parseDocumentId(id); - if (isNaN(documentId)) { + if (!documentId) { notFound(); } From af1e128214f0898768abd5480ec6948789d4a824 Mon Sep 17 00:00:00 2001 From: wlenig <30681316+wlenig@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:26:08 -0400 Subject: [PATCH 6/6] WEB-132 Strip dead code --- src/app/[...puckPath]/page.tsx | 2 -- src/app/editor/ResourceCard.tsx | 1 - src/components/puck/columns.tsx | 2 +- src/lib/documents/queries.ts | 21 --------------- src/lib/puck/responsive.test.ts | 18 +------------ src/lib/puck/responsive.ts | 47 --------------------------------- src/proxy.ts | 6 ----- 7 files changed, 2 insertions(+), 95 deletions(-) delete mode 100644 src/proxy.ts diff --git a/src/app/[...puckPath]/page.tsx b/src/app/[...puckPath]/page.tsx index c332bd6a..c7dce62e 100644 --- a/src/app/[...puckPath]/page.tsx +++ b/src/app/[...puckPath]/page.tsx @@ -11,8 +11,6 @@ export async function generateMetadata({ const { puckPath = [] } = await params; const path = `/${puckPath.join("/")}`; - console.log("Generating metadata for path:", path); - return { title: (await getDocumentByPath(path))?.root.props?.title, }; diff --git a/src/app/editor/ResourceCard.tsx b/src/app/editor/ResourceCard.tsx index a47001eb..7e0a42b7 100644 --- a/src/app/editor/ResourceCard.tsx +++ b/src/app/editor/ResourceCard.tsx @@ -1,7 +1,6 @@ import type { ReactNode } from "react"; import Link from "next/link"; import { Card, CardContent, CardFooter } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; export function formatRelativeTime(date: Date): string { diff --git a/src/components/puck/columns.tsx b/src/components/puck/columns.tsx index e594d3b0..53a15994 100644 --- a/src/components/puck/columns.tsx +++ b/src/components/puck/columns.tsx @@ -1,5 +1,5 @@ import type { ComponentConfig, Slot, SlotComponent } from "@puckeditor/core"; -import { defineProps, responsive, field } from "@/lib/puck/define-props"; +import { defineProps, responsive } from "@/lib/puck/define-props"; import { columnCount, gap, type ColumnCount, type Spacing } from "@/lib/puck/tokens"; import type { ResponsiveValue } from "@/lib/puck/responsive"; import { getGridClassName, getMaxCols } from "@/lib/puck/layout"; diff --git a/src/lib/documents/queries.ts b/src/lib/documents/queries.ts index 21c802d8..74688111 100644 --- a/src/lib/documents/queries.ts +++ b/src/lib/documents/queries.ts @@ -30,27 +30,6 @@ export const getDocumentByPath = async (path: string): Promise => { return route.document.publishedVersion.content as Data; }; -export const getAllRoutes = async (): Promise | null> => { - const routes = await prisma.route.findMany({ - include: { - document: { - include: { publishedVersion: true }, - }, - }, - }); - - if (routes.length === 0) { - return null; - } - - return routes.reduce((acc, route) => { - if (route.document.publishedVersion) { - acc[route.path] = route.document.publishedVersion.content as Data; - } - return acc; - }, {} as Record); -}; - export const getDocumentName = async (id: number) => { const doc = await prisma.document.findUnique({ where: { id }, diff --git a/src/lib/puck/responsive.test.ts b/src/lib/puck/responsive.test.ts index 16516d55..c9525601 100644 --- a/src/lib/puck/responsive.test.ts +++ b/src/lib/puck/responsive.test.ts @@ -1,23 +1,7 @@ import { describe, expect, it } from "vitest"; -import { hasOverride, map, resolveAt, setAt } from "./responsive"; +import { setAt } from "./responsive"; describe("responsive helpers", () => { - it("resolves breakpoints by cascading from the nearest smaller value", () => { - expect(resolveAt({ base: "sm" }, "lg")).toBe("sm"); - expect(resolveAt({ base: "sm", lg: "xl" }, "md")).toBe("sm"); - expect(resolveAt({ base: "sm", md: "lg" }, "lg")).toBe("lg"); - expect(resolveAt({ base: "sm", md: "lg", lg: "xl" }, "lg")).toBe("xl"); - }); - - it("treats non-base and falsy values as defined overrides", () => { - expect(hasOverride({ base: "md" })).toBe(false); - expect(hasOverride({ base: false, md: false })).toBe(true); - expect(map({ base: 0, lg: 3 }, (value, bp) => `${bp}:${value}`)).toEqual({ - base: "base:0", - lg: "lg:3", - }); - }); - it("adds and removes overrides without mutating the original value", () => { const value = { base: "sm", md: "lg" }; const withLg = setAt(value, "lg", "xl"); diff --git a/src/lib/puck/responsive.ts b/src/lib/puck/responsive.ts index 5f626c07..2d620504 100644 --- a/src/lib/puck/responsive.ts +++ b/src/lib/puck/responsive.ts @@ -8,53 +8,6 @@ export type ResponsiveValue = { base: T; } & Partial>; -// Returns the effective value at a breakpoint, falling back to the nearest smaller breakpoint. -export function resolveAt( - value: ResponsiveValue, - breakpoint: ResponsiveBreakpoint, -): T { - const start = responsiveBreakpoints.indexOf(breakpoint); - - for (let index = start; index >= 0; index -= 1) { - const breakpointValue = value[responsiveBreakpoints[index]]; - - if (breakpointValue !== undefined) { - return breakpointValue; - } - } - - return value.base; -} - -export function hasOverride(value: ResponsiveValue): boolean { - return responsiveBreakpoints.some( - (bp) => bp !== "base" && value[bp] !== undefined, - ); -} - -export function map( - value: ResponsiveValue, - fn: (value: T, breakpoint: ResponsiveBreakpoint) => U, -): ResponsiveValue { - const mapped: ResponsiveValue = { - base: fn(value.base, "base"), - }; - - for (const bp of responsiveBreakpoints) { - if (bp === "base") { - continue; - } - - const breakpointValue = value[bp]; - - if (breakpointValue !== undefined) { - mapped[bp] = fn(breakpointValue, bp); - } - } - - return mapped; -} - export function setAt( value: ResponsiveValue, breakpoint: ResponsiveBreakpoint, diff --git a/src/proxy.ts b/src/proxy.ts deleted file mode 100644 index 7f2ba288..00000000 --- a/src/proxy.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { NextResponse } from "next/server"; -import type { NextRequest } from "next/server"; - -export async function proxy(req: NextRequest) { - return NextResponse.next(); -}