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/app/editor/[id]/[slug]/EditorPage.tsx b/src/app/editor/[id]/[slug]/EditorPage.tsx index af87e050..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 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 { 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,18 +15,17 @@ export default async function EditorPage({ slug: string; versionId?: number; }) { - const document = await getDocumentById(documentId); + const document = await loadDocument(documentId, slug, { versionId }); - if (!document) { - notFound(); - } + const targetVersionId = resolveEditorVersionId(document.versions, versionId); - const expectedSlug = getEditorSlug(document.name); - if (slug !== expectedSlug) { - redirect(getEditorUrl(documentId, document.name, versionId)); + if (versionId !== undefined && targetVersionId === undefined) { + notFound(); } - const resolved = resolveVersion(versionId, document.versions); + const data = targetVersionId + ? (await getVersionContent(targetVersionId)) ?? createEmptyPuckData() + : createEmptyPuckData(); const versions = document.versions.map((v) => ({ id: v.id, @@ -37,32 +35,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..b4c3fff3 --- /dev/null +++ b/src/app/editor/[id]/[slug]/PreviewPage.tsx @@ -0,0 +1,33 @@ +import { notFound } from "next/navigation"; +import { Client } from "../../../[...puckPath]/client"; +import { getVersionContent } from "../../../../lib/documents/queries"; +import { resolvePreviewVersionId } from "./version-selection"; +import { loadDocument } from "./params"; + +export default async function PreviewPage({ + documentId, + slug, + versionId, +}: { + documentId: number; + slug: string; + versionId?: number; +}) { + const document = await loadDocument(documentId, slug, { + versionId, + redirectSuffix: "/preview", + }); + + const targetVersionId = resolvePreviewVersionId({ + versions: document.versions, + publishedVersionId: document.publishedVersionId, + requestedVersionId: versionId, + }); + + if (!targetVersionId) notFound(); + + const data = await getVersionContent(targetVersionId); + if (!data) notFound(); + + return ; +} diff --git a/src/app/editor/[id]/[slug]/VersionListPanel.tsx b/src/app/editor/[id]/[slug]/VersionListPanel.tsx index b68bd4ce..9bf97bdb 100644 --- a/src/app/editor/[id]/[slug]/VersionListPanel.tsx +++ b/src/app/editor/[id]/[slug]/VersionListPanel.tsx @@ -1,3 +1,5 @@ +import { Eye } from "lucide-react"; +import { cn } from "../../../../lib/utils"; import type { Version } from "../../../../lib/types"; export interface VersionListPanelProps { @@ -7,6 +9,7 @@ export interface VersionListPanelProps { publishedVersionId?: number | null; onLoadVersion: (versionId: number) => void; onPublishVersion: (versionId: number) => void; + previewBaseUrl: string; isPublishing?: boolean; isPublishDisabled?: boolean; } @@ -18,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 ( +
+
+
+ {label} + +
+ {time} +
+
+ +
+ {isPublished ? ( + + Published + + ) : ( + + )} + e.stopPropagation()} + className="p-1 text-gray-400 rounded hover:text-gray-600 hover:bg-gray-100 transition" + title="Preview" + > + + +
+
+
+ ); +} + export function VersionListPanel({ versions, isLoading, @@ -25,6 +101,7 @@ export function VersionListPanel({ publishedVersionId, onLoadVersion, onPublishVersion, + previewBaseUrl, isPublishing, isPublishDisabled, }: VersionListPanelProps) { @@ -47,50 +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 15d5061c..b541b2b8 100644 --- a/src/app/editor/[id]/[slug]/VersionPluginContainer.tsx +++ b/src/app/editor/[id]/[slug]/VersionPluginContainer.tsx @@ -43,6 +43,7 @@ export function VersionPluginContainer() { publishedVersionId={publishedVersionId} onLoadVersion={handleLoadVersion} onPublishVersion={handlePublishVersion} + previewBaseUrl={getEditorUrl(documentId, documentName)} isPublishing={isPublishing} isPublishDisabled={isArchived} /> diff --git a/src/app/editor/[id]/[slug]/[versionId]/page.tsx b/src/app/editor/[id]/[slug]/[versionId]/page.tsx index d3f162e1..9f443637 100644 --- a/src/app/editor/[id]/[slug]/[versionId]/page.tsx +++ b/src/app/editor/[id]/[slug]/[versionId]/page.tsx @@ -1,30 +1,7 @@ -import type { Metadata } from "next"; -import { notFound } from "next/navigation"; import EditorPage from "../EditorPage"; -import { getDocumentName } from "../../../../../lib/documents/queries"; - -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 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(); - } - - 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 new file mode 100644 index 00000000..128600ec --- /dev/null +++ b/src/app/editor/[id]/[slug]/[versionId]/preview/page.tsx @@ -0,0 +1,7 @@ +import PreviewPage from "../../PreviewPage"; +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 108d35bb..3f26ecc7 100644 --- a/src/app/editor/[id]/[slug]/page.tsx +++ b/src/app/editor/[id]/[slug]/page.tsx @@ -1,29 +1,7 @@ -import type { Metadata } from "next"; -import { notFound } from "next/navigation"; import EditorPage from "./EditorPage"; -import { getDocumentName } from "../../../../lib/documents/queries"; - -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 default async function Page({ params }: PageProps) { - const { id, slug } = await params; - const documentId = parseInt(id, 10); - - if (isNaN(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.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 new file mode 100644 index 00000000..66f9aa6f --- /dev/null +++ b/src/app/editor/[id]/[slug]/preview/page.tsx @@ -0,0 +1,7 @@ +import PreviewPage from "../PreviewPage"; +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]/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/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 881aa78d..dd2c95c8 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -1,7 +1,8 @@ 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"; +import { parseDocumentId } from "./[slug]/params"; interface PageProps { params: Promise<{ id: string }>; @@ -9,19 +10,19 @@ 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(); } - 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/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 33a0b1a1..74688111 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 }, }, }, }); @@ -30,34 +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 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 +38,12 @@ export const getDocumentName = async (id: number) => { return doc?.name; }; -export const getVersionById = async (versionId: number) => { - return await prisma.version.findUnique({ +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() { 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(); -}