From fc80226559c7dd7f307603b9843c21c0e2560b07 Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Thu, 5 Nov 2026 15:45:56 +0100 Subject: [PATCH] =?UTF-8?q?feat(blog):=20=E2=9C=A8=20add=20blog=20post=20p?= =?UTF-8?q?review=20page=20with=20live=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../docs/content-engine/access-api.mdx | 2 +- .../editorial-live-preview.webp | Bin 0 -> 75214 bytes .../content-engine/editorial-previews.mdx | 88 +++- apps/web/src/locales/@vitnode/blog/pl.json | 10 +- packages/vitnode/src/content/index.ts | 7 + packages/vitnode/src/content/live-preview.ts | 92 ++++ .../content/server/permission-matrix.test.ts | 1 + packages/vitnode/src/content/server/routes.ts | 43 +- packages/vitnode/src/locales/en.json | 24 +- .../admin/content/editorial/transport.ts | 12 +- .../src/tanstack/admin/content/form/host.tsx | 14 +- .../vitnode/src/tanstack/content/index.ts | 1 + .../src/tanstack/content/live-preview.ts | 74 +++ .../admin/views/access/effective-notice.tsx | 176 ++++--- .../views/content/actions/content-form.tsx | 30 +- .../views/content/actions/editorial-api.ts | 39 +- .../content/actions/editorial-transport.tsx | 8 + .../admin/views/content/form/context.tsx | 9 +- .../views/admin/views/content/form/index.ts | 1 + .../admin/views/content/form/live-preview.tsx | 489 ++++++++++++++++++ plugins/blog/src/content/post.ts | 2 +- plugins/blog/src/locales/en.json | 10 +- plugins/blog/src/pages/post-article.tsx | 343 ++++++++++++ plugins/blog/src/pages/post-page.tsx | 374 ++------------ plugins/blog/src/pages/post-preview-page.tsx | 84 +++ plugins/blog/src/pages/post-preview.ts | 118 +++++ plugins/blog/src/pages/post-query.ts | 2 + plugins/blog/src/routes.ts | 7 + .../admin/article/editor/article-editor.tsx | 405 +++++++++------ .../src/views/admin/article/form-layout.tsx | 99 ++-- 30 files changed, 1943 insertions(+), 621 deletions(-) create mode 100644 apps/web/content/docs/content-engine/editorial-live-preview.webp create mode 100644 packages/vitnode/src/content/live-preview.ts create mode 100644 packages/vitnode/src/tanstack/content/live-preview.ts create mode 100644 packages/vitnode/src/views/admin/views/content/form/live-preview.tsx create mode 100644 plugins/blog/src/pages/post-article.tsx create mode 100644 plugins/blog/src/pages/post-preview-page.tsx create mode 100644 plugins/blog/src/pages/post-preview.ts diff --git a/apps/web/content/docs/content-engine/access-api.mdx b/apps/web/content/docs/content-engine/access-api.mdx index 4a242fd50f..52cd0aebfe 100644 --- a/apps/web/content/docs/content-engine/access-api.mdx +++ b/apps/web/content/docs/content-engine/access-api.mdx @@ -146,7 +146,7 @@ Mounted under the scope family's admin module, `/api/{pluginId}/admin/content/{a | `GET /access/files` | `can_view` | `{ privateStorage, private, public }`: the family's files by visibility | | `POST /access/files/protect` | `can_edit` | Moves up to 100 public files to private storage: `{ moved, remaining, shared }` | -A content type filed under scopes gets `POST /access/effective` in its own module, such as `/api/@vitnode/blog/admin/content/posts/access/effective`. It takes `{ scopeIds }` (up to 50) and answers what readers need for each action across all of them, plus `missing` scope ids. `ContentAccessEffectiveNotice` from `@vitnode/core/views/admin/views/access/effective-notice` renders it in an editor. +A content type filed under scopes gets `POST /access/effective` in its own module, such as `/api/@vitnode/blog/admin/content/posts/access/effective`. It takes `{ scopeIds }` (up to 50) and answers what readers need for each action across all of them, plus `missing` scope ids. `ContentAccessEffectiveNotice` from `@vitnode/core/views/admin/views/access/effective-notice` renders it in an editor as a **Public** or **Restricted** badge; click it for who can view, read, comment and download. `overrides` is a partial `Record`. An action left out inherits. Call these routes with `rawFetcher`: diff --git a/apps/web/content/docs/content-engine/editorial-live-preview.webp b/apps/web/content/docs/content-engine/editorial-live-preview.webp new file mode 100644 index 0000000000000000000000000000000000000000..8cf662f44f90f1cb432601addd9658acc22b82e4 GIT binary patch literal 75214 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℞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 literal 0 HcmV?d00001 diff --git a/apps/web/content/docs/content-engine/editorial-previews.mdx b/apps/web/content/docs/content-engine/editorial-previews.mdx index 637650d5f4..63299fff7d 100644 --- a/apps/web/content/docs/content-engine/editorial-previews.mdx +++ b/apps/web/content/docs/content-engine/editorial-previews.mdx @@ -5,6 +5,7 @@ icon: Eye --- import { ImgDocs } from "@/components/fumadocs/img"; +import livePreview from "./editorial-live-preview.webp"; import previewLink from "./editorial-preview-link.webp"; A preview link lets a reviewer read a draft without signing in. It needs [`editorial`](/docs/content-engine/editorial) and a [public API](/docs/content-engine/public-api), and a page that knows how to load the draft. The examples use `newsroom.story` from the newsroom plugin. @@ -34,7 +35,7 @@ In the AdminCP, open **More actions** and choose **Preview**. Creating a link ne alt="Preview link dialog with a signed link to the Spring menu launches Monday story at /stories/spring-menu-launches-monday, a copy button, an Open button and an Expires in 30 minutes note" /> -The link is HMAC-signed, expires after `expiresInMinutes` and cannot be revoked early, so treat it like a password. It is pinned to the last saved version, so it shows what **Save** committed, not unsaved [live editing](/docs/content-engine/live-editing) drafts. +The link is HMAC-signed, expires after `expiresInMinutes` and cannot be revoked early, so treat it like a password. It is pinned to the last saved version, so it shows what **Save** committed, not unsaved [live editing](/docs/content-engine/live-editing) drafts. Want those too? Use the [live preview](#live-preview). The link points to the first of these that exists: @@ -58,3 +59,88 @@ The route returns the record's public fields, and the same `404` for a forged, e VitNode builds links from `VITNODE_WEB_URL` and `VITNODE_API_URL`. When either is not an absolute URL, the API logs a warning at startup and the **Preview** button answers `503`. +## Live preview + +The live preview puts the real page next to the form, and it updates as you type, before anything is saved. In the blog's article editor, click **Live preview** (the eye icon) in the header, and the page slides in from the right: + + + +- The screen size buttons switch between **Fit to panel**, **Mobile**, **Tablet** and **Desktop**. Fixed sizes shrink to fit the panel, so a desktop page still fits on a laptop. +- **Open in a new tab** pops the preview out, and that tab keeps following your edits. +- On phones, the preview takes the whole screen. Click the eye again to get back to the form. + +### Add it to your editor + +`ContentLivePreview` comes from `@vitnode/core/content/admin-form`. Render it in your [custom form layout](/docs/content-engine/admincp): + +```tsx title="A custom form layout (excerpt)" +import type { ContentFormLayoutProps } from "@vitnode/core/lib/plugin"; + +import { Button } from "@vitnode/core/components/ui/button"; +import { ContentLivePreview } from "@vitnode/core/content/admin-form"; +import { EyeIcon } from "lucide-react"; +import React from "react"; + +export const StoryFormLayout = ({ + contentTypeId, + itemId, +}: ContentFormLayoutProps) => { + const [open, setOpen] = React.useState(false); + + return ( + <> + + {open && itemId !== undefined ? ( + { + setOpen(false); + }} + /> + ) : null} + + ); +}; +``` + +`ContentLivePreview` creates a preview link for `locale` (falling back to the default language when that translation was never saved), opens it in an iframe and sends the unsaved form values to it with `postMessage`. It renews the link before it expires, so a long writing session doesn't end in a 404. + +### Follow the edits in your page + +The page behind the link reads the values with `useContentLivePreview` from `@vitnode/core/tanstack/content` and lays them over the record it loaded: + +```tsx title="The preview page (excerpt)" +import type { MultiLangValue } from "@vitnode/core/lib/helpers/multi-lang"; + +import { getLangValue } from "@vitnode/core/lib/helpers/multi-lang"; +import { useContentLivePreview } from "@vitnode/core/tanstack/content"; + +const StoryTitle = ({ saved }: { saved: string }) => { + const live = useContentLivePreview(); + const values = live?.values as { title?: MultiLangValue } | undefined; + + return

{getLangValue(values?.title, live?.locale ?? "en") || saved}

; +}; +``` + +The values come exactly as the AdminCP form holds them: a localized field is `{ languageCode, value }[]`, and relations, users and files are ids. For users and files, `live.references.people` and `live.references.files` hold the full objects, including an author picked or a cover uploaded a second ago, so look each id up there first, then in the record the page loaded. Relations stay ids, so map those to the objects the page already loaded. The hook returns `null` until the editor sends something, which is all it ever returns when someone opens the link on its own. It only reads messages from the same origin, sent by the window that embeds or opened the page. + +The blog does this in `plugins/blog/src/pages/post-preview-page.tsx`, with `pathTemplate: "/blog/preview/{token}"`. diff --git a/apps/web/src/locales/@vitnode/blog/pl.json b/apps/web/src/locales/@vitnode/blog/pl.json index 229b68d2dd..5434d059ac 100644 --- a/apps/web/src/locales/@vitnode/blog/pl.json +++ b/apps/web/src/locales/@vitnode/blog/pl.json @@ -110,6 +110,11 @@ "url_missing": "Brak adresu URL w tym języku.", "cover_cropped": "Obraz wyróżniający przycięty do kart społecznościowych.", "alt_edit": "Edytuj pod obrazem" + }, + "shared_fields": "Takie samo w każdym języku", + "live_preview": { + "open": "Podgląd na żywo", + "close": "Ukryj podgląd na żywo" } } }, @@ -179,7 +184,10 @@ "by": "Autorzy:", "edited": "Edytowano", "reading_time": "{minutes} min czytania", - "on_this_page": "Na tej stronie" + "on_this_page": "Na tej stronie", + "preview": { + "title": "Podgląd: {title}" + } }, "list": { "title": "Blog", diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index 16d5b38c96..8026e3f7b9 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -450,6 +450,13 @@ export { resolveContentTranslationIndexes, toSnakeCase, } from "./indexes"; +export { + CONTENT_LIVE_PREVIEW_MESSAGE, + type ContentLivePreviewPayload, + type ContentLivePreviewReferences, + isContentLivePreviewReady, + parseContentLivePreviewValues, +} from "./live-preview"; export type { ContentRefreshClientMessage, ContentRefreshErrorCode, diff --git a/packages/vitnode/src/content/live-preview.ts b/packages/vitnode/src/content/live-preview.ts new file mode 100644 index 0000000000..2e802130b3 --- /dev/null +++ b/packages/vitnode/src/content/live-preview.ts @@ -0,0 +1,92 @@ +import { z } from "zod"; + +import type { ContentFileDescriptor } from "./files"; +import type { ContentPublicPerson } from "./public-person"; + +import { zodContentFileDescriptor } from "./files"; +import { zodContentPublicPerson } from "./public-person"; + +/** + * The `type` of every live-preview `postMessage`. The preview page sends + * `ready` to its framing or opening editor when it loads; the editor answers + * with `values` on that and on every form change. + */ +export const CONTENT_LIVE_PREVIEW_MESSAGE = { + ready: "vitnode:content-live-preview:ready", + values: "vitnode:content-live-preview:values", +} as const; + +/** + * The descriptors behind the identifiers in `values`, so a preview can render a + * cover image just uploaded or an author just picked without a lookup of its + * own. Each list holds every descriptor the editor knows, saved or not. + */ +export interface ContentLivePreviewReferences { + files: ContentFileDescriptor[]; + people: ContentPublicPerson[]; +} + +/** One message from the editor: the unsaved form, as the form holds it. */ +export interface ContentLivePreviewPayload { + /** The language the editor is writing in. */ + locale: string; + references: ContentLivePreviewReferences; + /** + * Every field by name, in the form's own shapes: a file or user field holds + * identifiers (look them up in `references`), a localized field holds + * `{ languageCode, value }[]`. + */ + values: Record; +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const zodReferences = z.object({ + files: z.array(zodContentFileDescriptor).catch([]), + people: z.array(zodContentPublicPerson).catch([]), +}); + +/** + * Reads a `values` message (`event.data`) sent by the editor's + * `ContentLivePreview`. Universal and pure. + * + * Accepts `{ type: CONTENT_LIVE_PREVIEW_MESSAGE.values, locale: string, + * values: object, references?: { files, people } }`. Returns `null` for any + * other type, a missing `locale` or a `values` that is not a plain object. + * Invalid `references` lists become empty lists instead of rejecting the + * message. + * + * Only the envelope is checked: each entry in `values` stays `unknown`, so + * validate a field before you render it. It does not authenticate the + * message either - check `event.origin` and `event.source` first, as + * `useContentLivePreview` does. + */ +export const parseContentLivePreviewValues = ( + data: unknown, +): ContentLivePreviewPayload | null => { + if (!isRecord(data) || data.type !== CONTENT_LIVE_PREVIEW_MESSAGE.values) { + return null; + } + if (typeof data.locale !== "string" || !isRecord(data.values)) return null; + + return { + locale: data.locale, + references: zodReferences + .catch({ files: [], people: [] }) + .parse(data.references), + values: data.values, + }; +}; + +/** + * Whether `event.data` is the `ready` message a preview page sends when it + * loads, telling the editor to send the current values. Universal and pure. + * + * Accepts any object whose `type` is `CONTENT_LIVE_PREVIEW_MESSAGE.ready`; + * other properties are ignored. It does not authenticate the message - check + * `event.origin` and that `event.source` is the preview frame or popup the + * editor opened before answering. + */ +export const isContentLivePreviewReady = (data: unknown): boolean => + isRecord(data) && data.type === CONTENT_LIVE_PREVIEW_MESSAGE.ready; diff --git a/packages/vitnode/src/content/server/permission-matrix.test.ts b/packages/vitnode/src/content/server/permission-matrix.test.ts index af632a2d66..9b54a6aecc 100644 --- a/packages/vitnode/src/content/server/permission-matrix.test.ts +++ b/packages/vitnode/src/content/server/permission-matrix.test.ts @@ -224,6 +224,7 @@ describe("the generated permission matrix", () => { "DELETE /{id}": "can_delete", "DELETE /{id}/translations/{locale}": "can_delete", "GET /": "can_view", + "GET /live-preview/people": "can_view", "GET /options/{field}": "can_view", "GET /{id}": "can_view", // Read-only: it reports what the slug mutations already did, so the diff --git a/packages/vitnode/src/content/server/routes.ts b/packages/vitnode/src/content/server/routes.ts index b4610eea1c..dd70941ce3 100644 --- a/packages/vitnode/src/content/server/routes.ts +++ b/packages/vitnode/src/content/server/routes.ts @@ -22,6 +22,7 @@ import type { AnyContentModel, ContentModel } from "./model"; import type { ContentPreviewTarget } from "./preview-target"; import { checkStaffPermission } from "../../api/lib/check-staff-permission"; +import { loadPublicPeople, zodPublicPerson } from "../../api/lib/public-people"; import { buildRoute } from "../../api/lib/route"; import { zodPaginationPageInfo, @@ -1671,6 +1672,46 @@ export const buildContentRoutes = < * a table of 25 rows must not be 25 live bearer tokens for unpublished * records sitting in a browser's memory. */ + const livePreviewPeople = buildRoute({ + pluginId, + adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, + route: { + method: "get", + path: "/live-preview/people", + description: `Public summaries of the people a ${name} live preview names`, + request: { + query: z.object({ + ids: z.string().optional(), + }), + }, + responses: { + 200: jsonResponse( + z.object({ items: z.array(zodPublicPerson) }), + "The people found, in the order asked", + ), + }, + }, + handler: async c => { + const ids = (c.req.valid("query").ids ?? "") + .split(",") + .map(Number) + .filter(id => Number.isSafeInteger(id) && id > 0) + .slice(0, CONTENT_OPTIONS_LIMIT); + const people = await loadPublicPeople(c, ids); + + return c.json( + { + items: ids.flatMap(id => { + const person = people.get(id); + + return person ? [person] : []; + }), + }, + 200, + ); + }, + }); + const previewToken = buildRoute({ pluginId, adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view }, @@ -2101,7 +2142,7 @@ export const buildContentRoutes = < // Mounted only for a content type that declares a file field, so nothing // else gains a binary endpoint it has no use for. ...(hasFileFields ? [upload] : []), - ...(previewEnabled ? [previewToken] : []), + ...(previewEnabled ? [previewToken, livePreviewPeople] : []), ...(definition.delivery.enabled ? [deliveryDetail] : []), ...(definition.editorial.scheduling.enabled ? [scheduleList, scheduleCreate, scheduleCancel] diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index f21a33f97e..64cb695923 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -1992,6 +1992,26 @@ "unavailable": "Preview links cannot be built on this deployment. Check that VITNODE_WEB_URL and VITNODE_API_URL are absolute URLs, then restart the API.", "live": "This record has no saved version yet, so the link shows it live - it will follow any edits made before the reviewer opens it." }, + "live_preview": { + "title": "Live preview", + "loading": "Creating a preview link…", + "frame": "Live preview of the page", + "device": "Screen size", + "devices": { + "responsive": "Fit to panel", + "mobile": "Mobile", + "tablet": "Tablet", + "desktop": "Desktop" + }, + "size": "{width} × {height}", + "reload": "Reload preview", + "open": "Open in a new tab", + "close": "Close preview", + "load_error": { + "title": "Preview didn't load", + "desc": "We couldn't create a preview link for this page. Try again in a moment." + } + }, "delivery": { "title": "Delivery for this {name}", "desc": "Where this record lives on the public site, and where it used to.", @@ -3238,7 +3258,9 @@ }, "notice": { "title": "Who can see this", - "open": "Everyone can read this, comment on it and download its attachments - none of its categories restricts it.", + "label": "Who can see this:", + "public": "Public", + "restricted": "Restricted", "and": ", and also ", "intersection": "A reader has to meet the rule of every category it is in, so two restricted categories together let in fewer people than either one alone.", "missing": "One of the selected categories no longer exists. Nobody can read a record filed there." diff --git a/packages/vitnode/src/tanstack/admin/content/editorial/transport.ts b/packages/vitnode/src/tanstack/admin/content/editorial/transport.ts index b9227ba0b3..53d7895c70 100644 --- a/packages/vitnode/src/tanstack/admin/content/editorial/transport.ts +++ b/packages/vitnode/src/tanstack/admin/content/editorial/transport.ts @@ -10,6 +10,7 @@ import { getContentRevisionInBrowser, listContentRevisionsInBrowser, listContentSchedulesInBrowser, + loadContentLivePreviewPeopleInBrowser, readContentDeliveryInBrowser, restoreContentRevisionInBrowser, scheduleContentInBrowser, @@ -36,8 +37,12 @@ export const contentEditorialTransport = ( scheduleId, ), - createPreview: async (contentTypeId, itemId) => - await createContentPreviewInBrowser(targetFor(contentTypeId), itemId), + createPreview: async (contentTypeId, itemId, locale) => + await createContentPreviewInBrowser( + targetFor(contentTypeId), + itemId, + locale, + ), getRevision: async (contentTypeId, itemId, revisionId) => await getContentRevisionInBrowser( @@ -46,6 +51,9 @@ export const contentEditorialTransport = ( revisionId, ), + livePreviewPeople: async (contentTypeId, ids) => + await loadContentLivePreviewPeopleInBrowser(targetFor(contentTypeId), ids), + listRevisions: async (contentTypeId, itemId, cursor) => await listContentRevisionsInBrowser( targetFor(contentTypeId), diff --git a/packages/vitnode/src/tanstack/admin/content/form/host.tsx b/packages/vitnode/src/tanstack/admin/content/form/host.tsx index d9975f8f19..6b20be3f11 100644 --- a/packages/vitnode/src/tanstack/admin/content/form/host.tsx +++ b/packages/vitnode/src/tanstack/admin/content/form/host.tsx @@ -4,9 +4,11 @@ import React from "react"; import type { ContentFormNavigation } from "@/views/admin/views/content/form/navigation"; +import { ContentEditorialTransportProvider } from "@/views/admin/views/content/actions/editorial-transport"; import { ContentFormNavigationProvider } from "@/views/admin/views/content/form/navigation"; import { ContentFormTransportProvider } from "@/views/admin/views/content/form/transport"; +import { contentEditorialTransport } from "../editorial/transport"; import { contentFormTransport } from "./transport"; export const ContentFormHost = ({ @@ -21,6 +23,10 @@ export const ContentFormHost = ({ () => contentFormTransport(queryClient), [queryClient], ); + const editorial = React.useMemo( + () => contentEditorialTransport(queryClient), + [queryClient], + ); const navigation = React.useMemo( () => ({ @@ -36,9 +42,11 @@ export const ContentFormHost = ({ return ( - - {children} - + + + {children} + + ); }; diff --git a/packages/vitnode/src/tanstack/content/index.ts b/packages/vitnode/src/tanstack/content/index.ts index 64ae6f7360..5044d731d3 100644 --- a/packages/vitnode/src/tanstack/content/index.ts +++ b/packages/vitnode/src/tanstack/content/index.ts @@ -33,6 +33,7 @@ export { ContentListResults, useContentListSearchInput, } from "./list-ui"; +export { useContentLivePreview } from "./live-preview"; export type { ContentLiveRefreshHandlers, ContentLiveRefreshStatus, diff --git a/packages/vitnode/src/tanstack/content/live-preview.ts b/packages/vitnode/src/tanstack/content/live-preview.ts new file mode 100644 index 0000000000..306c07e48e --- /dev/null +++ b/packages/vitnode/src/tanstack/content/live-preview.ts @@ -0,0 +1,74 @@ +import React from "react"; + +import type { ContentLivePreviewPayload } from "@/content/live-preview"; + +import { + CONTENT_LIVE_PREVIEW_MESSAGE, + parseContentLivePreviewValues, +} from "@/content/live-preview"; + +const editorWindows = (): Window[] => { + const windows: Window[] = []; + if (window.parent !== window) windows.push(window.parent); + const opener: unknown = window.opener; + if (opener !== null && typeof opener === "object") { + windows.push(opener as Window); + } + + return windows; +}; + +/** + * The unsaved values an AdminCP editor is typing, for the page behind a + * Content Engine preview link. Lay them over the record the page loaded. + * + * Browser-only: `null` during SSR, on a page that is neither framed by nor + * opened from an editor, and until the first message arrives. The editor's + * `ContentLivePreview` sends a message on every change, and again when this + * page reloads. Messages are accepted only from the framing or opening window, + * and only when it shares this page's origin. + * + * @example + * import type { MultiLangValue } from "@vitnode/core/lib/helpers/multi-lang"; + * + * import { getLangValue } from "@vitnode/core/lib/helpers/multi-lang"; + * import { useContentLivePreview } from "@vitnode/core/tanstack/content"; + * + * const StoryTitle = ({ saved }: { saved: string }) => { + * const live = useContentLivePreview(); + * const title = live?.values.title as MultiLangValue | undefined; + * + * return

{getLangValue(title, live?.locale ?? "en") || saved}

; + * }; + */ +export const useContentLivePreview = (): ContentLivePreviewPayload | null => { + const [payload, setPayload] = + React.useState(null); + + React.useEffect(() => { + const sources = editorWindows(); + if (sources.length === 0) return; + + const onMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (!sources.some(source => source === event.source)) return; + + const next = parseContentLivePreviewValues(event.data); + if (next) setPayload(next); + }; + + window.addEventListener("message", onMessage); + for (const source of sources) { + source.postMessage( + { type: CONTENT_LIVE_PREVIEW_MESSAGE.ready }, + window.location.origin, + ); + } + + return () => { + window.removeEventListener("message", onMessage); + }; + }, []); + + return payload; +}; diff --git a/packages/vitnode/src/views/admin/views/access/effective-notice.tsx b/packages/vitnode/src/views/admin/views/access/effective-notice.tsx index 6f97a01d65..a3e8273144 100644 --- a/packages/vitnode/src/views/admin/views/access/effective-notice.tsx +++ b/packages/vitnode/src/views/admin/views/access/effective-notice.tsx @@ -1,11 +1,25 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { CircleCheckIcon, LockIcon, TriangleAlertIcon } from "lucide-react"; +import { cn } from "cn"; +import { + ChevronDownIcon, + CircleCheckIcon, + GlobeIcon, + LockIcon, + TriangleAlertIcon, +} from "lucide-react"; +import React from "react"; import { useLocale, useTranslations } from "use-intl"; import type { ContentAccessAudience } from "@/content/access/policy"; import type { ContentAccessEffectiveResponse } from "@/content/access/schemas"; import { roleOptionName } from "@/components/form/fields/roles"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { Skeleton } from "@/components/ui/skeleton"; import { CONTENT_ACCESS_ACTIONS } from "@/content/access/const"; import { sameContentAccessAudience } from "@/content/access/policy"; @@ -45,6 +59,7 @@ const fetchEffective = async ({ const Notice = (props: ContentAccessEffectiveNoticeProps) => { const t = useTranslations("core.access.admin"); const locale = useLocale(); + const labelId = React.useId(); const ids = [...new Set(props.scopeIds.map(String))].sort(); const query = useQuery({ placeholderData: keepPreviousData, @@ -62,15 +77,11 @@ const Notice = (props: ContentAccessEffectiveNoticeProps) => { if (query.isPending) { return ( -
+
{t("effective.loading")} - - +
); } @@ -95,67 +106,98 @@ const Notice = (props: ContentAccessEffectiveNoticeProps) => { } } }; - const restricted = CONTENT_ACCESS_ACTIONS.some( - action => actions[action].requirements.length > 0, - ); + const rows = CONTENT_ACCESS_ACTIONS.map(action => { + // One line per distinct audience: a reader has to be in every + // one of them, whichever category set it. + const audiences = actions[action].requirements + .map(requirement => requirement.audience) + .filter( + (audience, index, all) => + all.findIndex(other => sameContentAccessAudience(other, audience)) === + index, + ); - return ( -
-

- {restricted ? ( -

- {restricted ? ( - <> -
- {CONTENT_ACCESS_ACTIONS.map(action => { - // One line per distinct audience: a reader has to be in every - // one of them, whichever category set it. - const audiences = actions[action].requirements - .map(requirement => requirement.audience) - .filter( - (audience, index, all) => - all.findIndex(other => - sameContentAccessAudience(other, audience), - ) === index, - ); + return { action, audiences }; + }); + const restricted = rows.some(row => row.audiences.length > 0); - return ( -
-
- {t(`actions.${action}.label`)} -
-
- {audiences.length === 0 - ? t("effective.open") - : audiences.map(audienceText).join(t("notice.and"))} -
+ return ( +
+
+ + {t("notice.label")} + + + + } + > + {restricted ? ( + + +
+ {rows.map(({ action, audiences }) => ( +
+ {audiences.length === 0 ? ( +
- ); - })} -
-

- {t("notice.intersection")} -

- - ) : ( -

- {t("notice.open")} -

- )} + ))} +
+ {restricted ? ( +

+ {t("notice.intersection")} +

+ ) : null} + + +
{missing.length > 0 ? ( -

+

) : null} - + ); }; @@ -171,7 +213,9 @@ const Notice = (props: ContentAccessEffectiveNoticeProps) => { * Who may read, comment on and download from a record filed under several * scopes (an article in its categories), as the server computes it for the * scopes the editor holds right now: a reader has to meet every scope's rule. - * Shown in an editor before saving; the server decides again on every read. + * Renders a Public / Restricted badge that opens the per-action breakdown, and + * a warning when a selected scope no longer exists. Shown in an editor before + * saving; the server decides again on every read. */ export const ContentAccessEffectiveNotice = ( props: ContentAccessEffectiveNoticeProps, diff --git a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx index 7e4bb6f50c..92c4db52f7 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/content-form.tsx @@ -4,7 +4,10 @@ import { useLocale, useTranslations } from "use-intl"; import type { ItemAutoFormComponentProps } from "@/components/form/auto-form"; import type { ContentFormSpec } from "@/content/admin/spec"; -import type { ContentFileFieldValue } from "@/content/files"; +import type { + ContentFileDescriptor, + ContentFileFieldValue, +} from "@/content/files"; import type { ContentId } from "@/content/ids"; import type { ContentVisibilityAction } from "@/content/visibility"; import type { ContentFormLayout } from "@/lib/plugin"; @@ -279,6 +282,16 @@ const ContentFormFields = ({ const files = data?.files as | Record | undefined; + const [uploadedFiles, setUploadedFiles] = React.useState< + ContentFileDescriptor[] + >([]); + const userFieldNames = React.useMemo( + () => + spec.fields + .filter(fieldSpec => fieldSpec.kind === "user") + .map(fieldSpec => fieldSpec.name), + [spec], + ); const localizedFields = React.useMemo( () => contentLocalizedFieldNames(spec), @@ -587,9 +600,16 @@ const ContentFormFields = ({ files={files} loadOptions={loadOptions} spec={fieldSpec} - uploadFile={async ({ field, file }) => - await uploadContentFile({ field, file, spec }) - } + uploadFile={async ({ field, file }) => { + const uploaded = await uploadContentFile({ + field, + file, + spec, + }); + setUploadedFiles(current => [...current, uploaded]); + + return uploaded; + }} {...props} /> )} @@ -644,6 +664,8 @@ const ContentFormFields = ({ status: row.status, updatedAt: row.updatedAt, })), + uploadedFiles, + userFieldNames, visibility: { canHide, enabled: visibility, diff --git a/packages/vitnode/src/views/admin/views/content/actions/editorial-api.ts b/packages/vitnode/src/views/admin/views/content/actions/editorial-api.ts index 0e2d19f7bd..408454e445 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/editorial-api.ts +++ b/packages/vitnode/src/views/admin/views/content/actions/editorial-api.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { ContentId } from "@/content/ids"; +import type { ContentPublicPerson } from "@/content/public-person"; import type { ContentRevisionDetail, ContentRevisionMeta, @@ -11,6 +12,7 @@ import type { } from "@/content/schedules"; import { contentAnyIdSchema } from "@/content/ids"; +import { zodContentPublicPerson } from "@/content/public-person"; import type { ContentMutationResult } from "../content-mutation"; import type { ContentApiTarget } from "../content-request"; @@ -44,6 +46,10 @@ const zodPreview = z.object({ version: z.number(), }); +const zodLivePreviewPeople = z.object({ + items: z.array(zodContentPublicPerson), +}); + const zodScheduleList = z.object({ edges: z.array(z.object({ id: z.number() }).loose()), hasCronAdapter: z.boolean(), @@ -184,9 +190,17 @@ export const restoreContentRevisionInBrowser = async ( export const createContentPreviewInBrowser = async ( target: ContentApiTarget, id: ContentId, + locale?: string, ): Promise => { const result = await sendContentApiRequest( - { method: "post", path: `/${id}/preview`, target }, + { + method: "post", + path: + locale === undefined + ? `/${id}/preview` + : `/${id}/translations/${encodeURIComponent(locale)}/preview`, + target, + }, zodPreview, ); @@ -201,6 +215,29 @@ export const createContentPreviewInBrowser = async ( return { preview: { expiresAt, revisionId, url, version } }; }; +export const loadContentLivePreviewPeopleInBrowser = async ( + target: ContentApiTarget, + ids: readonly number[], +): Promise => { + if (ids.length === 0) return []; + + const result = await sendContentApiRequest( + { + method: "get", + path: "/live-preview/people", + query: { ids: ids.join(",") }, + target, + }, + zodLivePreviewPeople, + ); + + if (!contentWriteSucceeded(result, 200) || !result.data) { + throw new Error(result.error ?? ""); + } + + return result.data.items; +}; + export const listContentSchedulesInBrowser = async ( target: ContentApiTarget, id: ContentId, diff --git a/packages/vitnode/src/views/admin/views/content/actions/editorial-transport.tsx b/packages/vitnode/src/views/admin/views/content/actions/editorial-transport.tsx index fa8f0f63f2..6f1fe393b5 100644 --- a/packages/vitnode/src/views/admin/views/content/actions/editorial-transport.tsx +++ b/packages/vitnode/src/views/admin/views/content/actions/editorial-transport.tsx @@ -1,6 +1,7 @@ import React from "react"; import type { ContentId } from "@/content/ids"; +import type { ContentPublicPerson } from "@/content/public-person"; import type { ContentRevisionDetail } from "@/content/revisions"; import type { ContentScheduleAction } from "@/content/schedules"; @@ -31,6 +32,7 @@ export interface ContentEditorialTransport { createPreview: ( contentTypeId: string, itemId: ContentId, + locale?: string, ) => Promise; /** One revision's snapshot, read when a row is expanded and not before. */ getRevision: ( @@ -50,6 +52,12 @@ export interface ContentEditorialTransport { itemId: ContentId, ) => Promise; + /** Public summaries of these users, for a live preview of unsaved authors. */ + livePreviewPeople: ( + contentTypeId: string, + ids: readonly number[], + ) => Promise; + readDelivery: ( contentTypeId: string, itemId: ContentId, diff --git a/packages/vitnode/src/views/admin/views/content/form/context.tsx b/packages/vitnode/src/views/admin/views/content/form/context.tsx index 95c17c29b2..d2cbd2008d 100644 --- a/packages/vitnode/src/views/admin/views/content/form/context.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/context.tsx @@ -1,7 +1,10 @@ import React from "react"; import type { PageTitleBack } from "@/components/ui/page-title"; -import type { ContentFileFieldValue } from "@/content/files"; +import type { + ContentFileDescriptor, + ContentFileFieldValue, +} from "@/content/files"; import type { ContentId } from "@/content/ids"; import type { ContentVisibilityAction } from "@/content/visibility"; @@ -59,6 +62,10 @@ export interface ContentFormContextValue { skeleton?: boolean; title?: string; translations?: readonly ContentFormTranslationMeta[]; + /** Files uploaded since the form opened, saved or not, oldest first. */ + uploadedFiles?: readonly ContentFileDescriptor[]; + /** The `field.user()` fields, by name. */ + userFieldNames?: string[]; visibility?: { canHide: boolean; enabled: boolean; diff --git a/packages/vitnode/src/views/admin/views/content/form/index.ts b/packages/vitnode/src/views/admin/views/content/form/index.ts index d1bd416266..01728153b5 100644 --- a/packages/vitnode/src/views/admin/views/content/form/index.ts +++ b/packages/vitnode/src/views/admin/views/content/form/index.ts @@ -32,6 +32,7 @@ export { ContentFormSection, ContentFormSidebar, } from "./layout-primitives"; +export { ContentLivePreview } from "./live-preview"; export { ContentFormActions, ContentFormField, diff --git a/packages/vitnode/src/views/admin/views/content/form/live-preview.tsx b/packages/vitnode/src/views/admin/views/content/form/live-preview.tsx new file mode 100644 index 0000000000..f8ef6814b6 --- /dev/null +++ b/packages/vitnode/src/views/admin/views/content/form/live-preview.tsx @@ -0,0 +1,489 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { cn } from "cn"; +import { + ExternalLinkIcon, + LaptopIcon, + MonitorOffIcon, + RotateCwIcon, + ScanIcon, + SmartphoneIcon, + TabletIcon, + XIcon, +} from "lucide-react"; +import React from "react"; +import { useTranslations } from "use-intl"; + +import type { ContentFileDescriptor } from "@/content/files"; +import type { ContentId } from "@/content/ids"; +import type { ContentLivePreviewReferences } from "@/content/live-preview"; + +import { Button } from "@/components/ui/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { TooltipWithContent } from "@/components/ui/tooltip"; +import { + CONTENT_LIVE_PREVIEW_MESSAGE, + isContentLivePreviewReady, +} from "@/content/live-preview"; + +import { useContentEditorialTransport } from "../actions/editorial-transport"; +import { useContentFormOptional } from "./context"; +import { useContentFormValues } from "./values"; + +const LIVE_PREVIEW_DEVICES = { + responsive: { height: 0, icon: ScanIcon, width: 0 }, + mobile: { height: 812, icon: SmartphoneIcon, width: 375 }, + tablet: { height: 1024, icon: TabletIcon, width: 768 }, + desktop: { height: 900, icon: LaptopIcon, width: 1440 }, +} as const; + +type LivePreviewDevice = keyof typeof LIVE_PREVIEW_DEVICES; + +const LIVE_PREVIEW_DEVICE_IDS = [ + "responsive", + "mobile", + "tablet", + "desktop", +] as const satisfies readonly LivePreviewDevice[]; + +const isLivePreviewDevice = (value: unknown): value is LivePreviewDevice => + LIVE_PREVIEW_DEVICE_IDS.some(id => id === value); + +const POST_DELAY_MS = 120; +const RENEW_BEFORE_EXPIRY_MS = 60_000; + +const useStageSize = () => { + const ref = React.useRef(null); + const [size, setSize] = React.useState({ height: 0, width: 0 }); + + React.useEffect(() => { + const element = ref.current; + if (!element) return; + + const observer = new ResizeObserver(([entry]) => { + if (!entry) return; + setSize({ + height: entry.contentRect.height, + width: entry.contentRect.width, + }); + }); + observer.observe(element); + + return () => { + observer.disconnect(); + }; + }, []); + + return { ref, size }; +}; + +const usePreviewLink = ({ + contentTypeId, + itemId, + locale, +}: { + contentTypeId: string; + itemId: ContentId; + locale: string; +}) => { + const transport = useContentEditorialTransport(); + + return useQuery({ + gcTime: 0, + queryFn: async () => { + const localized = await transport.createPreview( + contentTypeId, + itemId, + locale, + ); + if (localized.preview || localized.status !== 404) return localized; + + return await transport.createPreview(contentTypeId, itemId); + }, + queryKey: ["content-live-preview", contentTypeId, itemId, locale], + refetchInterval: query => { + const expiresAt = query.state.data?.preview?.expiresAt; + if (!expiresAt) return false; + + return Math.max( + new Date(expiresAt).getTime() - Date.now() - RENEW_BEFORE_EXPIRY_MS, + RENEW_BEFORE_EXPIRY_MS, + ); + }, + refetchOnWindowFocus: false, + retry: false, + staleTime: Infinity, + }); +}; + +const userIdsOf = ( + values: Record, + fieldNames: readonly string[], +): number[] => + [ + ...new Set( + fieldNames + .flatMap(name => values[name]) + .filter((id): id is number => typeof id === "number"), + ), + ].toSorted((a, b) => a - b); + +const useLiveReferences = ({ + contentTypeId, + values, +}: { + contentTypeId: string; + values: Record; +}): ContentLivePreviewReferences => { + const form = useContentFormOptional(); + const transport = useContentEditorialTransport(); + const userIds = userIdsOf(values, form?.userFieldNames ?? []); + + const people = useQuery({ + enabled: userIds.length > 0, + placeholderData: keepPreviousData, + queryFn: async () => + await transport.livePreviewPeople(contentTypeId, userIds), + queryKey: ["content-live-preview-people", contentTypeId, userIds], + refetchOnWindowFocus: false, + staleTime: Infinity, + }); + + const savedFiles = form?.files; + const uploadedFiles = form?.uploadedFiles; + const files = React.useMemo(() => { + const byId = new Map(); + for (const value of Object.values(savedFiles ?? {})) { + for (const file of [value ?? []].flat()) byId.set(file.id, file); + } + for (const file of uploadedFiles ?? []) byId.set(file.id, file); + + return [...byId.values()]; + }, [savedFiles, uploadedFiles]); + + const peopleData = people.data; + + return React.useMemo( + () => ({ files, people: peopleData ?? [] }), + [files, peopleData], + ); +}; + +const useLiveValues = ({ + contentTypeId, + frame, + locale, + origin, + popup, +}: { + contentTypeId: string; + frame: React.RefObject; + locale: string; + origin: null | string; + popup: React.RefObject; +}) => { + const values = useContentFormValues(); + const references = useLiveReferences({ contentTypeId, values }); + const latestRef = React.useRef({ locale, references, values }); + React.useEffect(() => { + latestRef.current = { locale, references, values }; + }); + + const send = React.useCallback( + (target: null | undefined | Window) => { + if (!target || origin === null || target.closed) return; + target.postMessage( + { type: CONTENT_LIVE_PREVIEW_MESSAGE.values, ...latestRef.current }, + origin, + ); + }, + [origin], + ); + + React.useEffect(() => { + const timer = window.setTimeout(() => { + send(frame.current?.contentWindow); + send(popup.current); + }, POST_DELAY_MS); + + return () => { + window.clearTimeout(timer); + }; + }, [frame, locale, popup, references, send, values]); + + React.useEffect(() => { + const onMessage = (event: MessageEvent) => { + if (event.origin !== origin || !isContentLivePreviewReady(event.data)) { + return; + } + if ( + event.source === frame.current?.contentWindow || + event.source === popup.current + ) { + send(event.source); + } + }; + + window.addEventListener("message", onMessage); + + return () => { + window.removeEventListener("message", onMessage); + }; + }, [frame, origin, popup, send]); +}; + +const FRAME_BORDER_PX = 2; + +const frameBox = ( + device: LivePreviewDevice, + stage: { height: number; width: number }, +) => { + const room = { + height: Math.max(stage.height - FRAME_BORDER_PX, 0), + width: Math.max(stage.width - FRAME_BORDER_PX, 0), + }; + const { height, width } = LIVE_PREVIEW_DEVICES[device]; + if (width === 0) return { ...room, scale: 1 }; + + return { + height, + scale: Math.min(1, room.width / width, room.height / height), + width, + }; +}; + +const PreviewFrame = ({ + device, + frameKey, + frameRef, + title, + url, +}: { + device: LivePreviewDevice; + frameKey: number; + frameRef: React.RefObject; + title: string; + url: string; +}) => { + const { ref, size } = useStageSize(); + const { height, scale, width } = frameBox(device, size); + + return ( +
+ {size.width > 0 ? ( +
+