diff --git a/apps/web/content/docs/content-engine/access-api.mdx b/apps/web/content/docs/content-engine/access-api.mdx index 4a242fd50..52cd0aebf 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 000000000..8cf662f44 Binary files /dev/null and b/apps/web/content/docs/content-engine/editorial-live-preview.webp differ diff --git a/apps/web/content/docs/content-engine/editorial-previews.mdx b/apps/web/content/docs/content-engine/editorial-previews.mdx index 637650d5f..63299fff7 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 229b68d2d..5434d059a 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 16d5b38c9..8026e3f7b 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 000000000..2e802130b --- /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 af632a2d6..9b54a6aec 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 b4610eea1..dd70941ce 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 f21a33f97..64cb69592 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 b9227ba0b..53d7895c7 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 d9975f8f1..6b20be3f1 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 64ae6f736..5044d731d 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 000000000..306c07e48 --- /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 6f97a01d6..a3e827314 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 7e4bb6f50..92c4db52f 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 0e2d19f7b..408454e44 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 fa8f0f63f..6f1fe393b 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 95c17c29b..d2cbd2008 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 d1bd41626..01728153b 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 000000000..f8ef6814b --- /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 ? ( +
+