Skip to content

Commit 828f6e7

Browse files
committed
Comments, visuals and instructions updates to the cms-live-preview extension
1 parent 85941e7 commit 828f6e7

5 files changed

Lines changed: 222 additions & 42 deletions

File tree

extensions/cms-live-preview/6.0.x/extensions/livePreview/README.md

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,54 @@
22

33
With the Live Preview extension, you can instantly see changes in your content as you edit it. This extension provides a seamless experience for content creators, allowing them to visualize their work in real-time without needing to publish or refresh the page.
44

5+
## Install extension
6+
7+
To install the extension run the following command inside your Webiny project:
8+
9+
```shell
10+
yarn webiny extension cms-live-preview
11+
```
12+
13+
Once installed make sure to configure your NextJs/NuxtJs/Angular frontend app to render the pages. To get started clone our reference nextjs app from here: https://github.com/webiny/learn-webiny-nextjs-app/tree/live-preview-ext
14+
Install the dependencies and run the dev server (`yarn dev`)
15+
16+
517
![Live Preview Demo](./screenshot.png)
618

7-
# Overview
19+
## Overview
820

921
This extension consists of:
1022

1123
- **Admin Interface**: a preview pane on the left side of the screen that updates in real-time as you edit content on the right side.
1224
- **Article Model**: a simple content model with a couple of fields
1325

14-
The extension works with a Next.js app which you can clone and host locally.
26+
To add a live preview to an additional content model:
27+
1. Modify `~/extensions/livePreview/admin/AddPreviewPane.tsx` - fine the line below and add your model id to the list:
28+
```ts
29+
const LIVE_PREVIEW_MODEL_IDS = ["article", 'example'];
30+
```
1531

16-
Clone the app from here: https://github.com/webiny/learn-webiny-nextjs-app/tree/live-preview-ext
32+
2. Modify `~/extensions/livePreview/admin/getPreviewUrl.ts` - modify the `previewPaths` and add the preview slug for your new content model:
33+
```ts
34+
const previewPaths: Record<string, string> = {
35+
article: "/articles/preview",
36+
example: "/examples/preview"
37+
};
38+
```
39+
At the same time also add the `slugPaths`:
40+
```ts
41+
const slugPaths: Record<string, string> = {
42+
article: "/articles",
43+
example: "/examples"
44+
};
45+
```
1746

18-
Once cloned, install dependencies and run the development server:
47+
The `previewPaths` variable tell the live preview component - what path to use in your app to render the live preview iframe inside your app for this particular content model.
48+
The `slugPaths` is used together with the value of the `slug` field to compose a path for that entry. For example if we have `/test/path` as the value of the slug field in the example content model, the full slug will look like `/examples/test/path`, and that will be the route that your frontend app will need to handle to render this page.
1949

20-
```bash
21-
yarn dev
22-
```
50+
3. Inside the same `~/extensions/livePreview/admin/getPreviewUrl.ts` file, also modify the `getDomain` function so it returns the live domain of your frontend app.
51+
52+
53+
## Questions
54+
55+
In case of any questions, find us on our community forum: https://www.webiny.com/slack

extensions/cms-live-preview/6.0.x/extensions/livePreview/admin/AddPreviewPane.tsx

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,34 @@
11
import React from "react";
22
import { PreviewPane } from "./PreviewPane";
3-
import { getPreviewUrl } from "./getPreviewUrl";
3+
import { getPreviewUrl, getDisplayUrl } from "./getPreviewUrl";
44
import { ContentEntryForm } from "webiny/admin/cms/entry/editor";
55
import { useModel } from "webiny/admin/cms";
66

7+
const LIVE_PREVIEW_MODEL_IDS = ["article"];
8+
79
export const AddPreviewPane = ContentEntryForm.createDecorator(Original => {
810
return function ContentEntryForm(props) {
911
const { model } = useModel();
1012

11-
if (model.modelId !== "article") {
13+
if (!LIVE_PREVIEW_MODEL_IDS.includes(model.modelId)) {
14+
return <Original {...props} />;
15+
}
16+
17+
const previewUrl = getPreviewUrl(model.modelId, window.location.origin);
18+
19+
if (!previewUrl) {
1220
return <Original {...props} />;
1321
}
1422

1523
return (
1624
<div
1725
data-role={"live-preview-pane"}
18-
className="flex h-[calc(100vh-var(--spacing-header)-200px)]"
26+
className="grid grid-cols-12 h-[calc(100vh-var(--spacing-header)-120px)]"
1927
>
20-
<PreviewPane previewUrl={getPreviewUrl(window.location.origin)} />
21-
<div className="flex-1 px-md">
28+
<div className="col-span-6 xl:col-span-8 h-full">
29+
<PreviewPane previewUrl={previewUrl} modelId={model.modelId} />
30+
</div>
31+
<div className="flex-1 ml-lg col-span-6 xl:col-span-4 h-full overflow-y-scroll">
2232
<Original {...props} />
2333
</div>
2434
</div>

extensions/cms-live-preview/6.0.x/extensions/livePreview/admin/LivePreviewEditor.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const getWindowObject = () => {
1414

1515
/**
1616
* Decorate `useContentEntryForm` hook, and notify the Live Preview when changes happen.
17+
* Also dispatches a custom event with the current slug so the PreviewPane can show it.
1718
*/
1819
export const LivePreviewEditor = useContentEntryForm.createDecorator(baseHook => {
1920
return () => {
@@ -27,6 +28,17 @@ export const LivePreviewEditor = useContentEntryForm.createDecorator(baseHook =>
2728

2829
useEffect(() => {
2930
updateLivePreview(hook.entry);
31+
32+
// Notify the PreviewPane of the current slug and entry ID.
33+
const entry = hook.entry as Record<string, any>;
34+
window.dispatchEvent(
35+
new CustomEvent("wby:slugChange", {
36+
detail: {
37+
slug: entry?.values?.slug || "",
38+
entryId: entry?.id || "",
39+
},
40+
})
41+
);
3042
}, [hook.entry]);
3143

3244
return hook;

extensions/cms-live-preview/6.0.x/extensions/livePreview/admin/PreviewPane.tsx

Lines changed: 124 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,74 @@
1-
import React, { useRef, useState } from "react";
1+
import React, { useRef, useState, useCallback, useEffect } from "react";
22
import { IconButton } from "webiny/admin/ui";
33
import { Input } from "webiny/admin/ui";
44
import { OverlayLoader } from "webiny/admin/ui";
5+
import { SegmentedControl } from "webiny/admin/ui";
56
import { ReactComponent as RefreshIcon } from "webiny/admin/icons/refresh.svg";
7+
import { ReactComponent as ContentCopyIcon } from "webiny/admin/icons/content_copy.svg";
8+
import { ReactComponent as LaptopIcon } from "webiny/admin/icons/laptop.svg";
9+
import { ReactComponent as SmartphoneIcon } from "webiny/admin/icons/smartphone.svg";
10+
import { ReactComponent as OpenInNewIcon } from "webiny/admin/icons/open_in_new.svg";
11+
import { getDisplayUrl } from "./getPreviewUrl";
612

713
export interface PreviewProps {
814
previewUrl: string;
15+
modelId: string;
916
}
1017

18+
type ViewportMode = "desktop" | "mobile";
19+
1120
export const PreviewPane = (props: PreviewProps) => {
1221
const [loading, setLoading] = useState(true);
13-
const [address, setAddress] = useState(props.previewUrl);
22+
const [address, setAddress] = useState(getDisplayUrl(props.modelId) || props.previewUrl);
1423
const [previewUrl, setPreviewUrl] = useState(props.previewUrl);
24+
const [viewport, setViewport] = useState<ViewportMode>("desktop");
25+
const [entryId, setEntryId] = useState<string>("");
1526
const iframeRef = useRef<HTMLIFrameElement | null>(null);
1627

17-
const reload = () => {
18-
const iframe = iframeRef.current;
28+
// Listen for slug and entryId changes dispatched by LivePreviewEditor.
29+
useEffect(() => {
30+
const handler = (e: Event) => {
31+
const detail = (e as CustomEvent).detail;
32+
const slug = detail?.slug as string | undefined;
33+
const id = detail?.entryId as string | undefined;
34+
const displayUrl = getDisplayUrl(props.modelId, slug);
35+
if (displayUrl) {
36+
setAddress(displayUrl);
37+
}
38+
if (id) {
39+
setEntryId(id);
40+
}
41+
};
1942

20-
if (!iframe) {
21-
return;
22-
}
43+
window.addEventListener("wby:slugChange", handler);
44+
return () => window.removeEventListener("wby:slugChange", handler);
45+
}, [props.modelId]);
46+
47+
const reload = useCallback(() => {
48+
const iframe = iframeRef.current;
49+
if (!iframe) return;
2350

2451
setLoading(true);
2552
const newSrc = new URL(iframe.src ?? previewUrl);
2653
newSrc.searchParams.set("ts", Date.now().toString());
27-
2854
iframe.src = newSrc.toString();
29-
};
55+
}, [previewUrl]);
3056

31-
const onLoadFinish = (e: any) => {
57+
const copyUrl = useCallback(() => {
58+
navigator.clipboard.writeText(address);
59+
}, [address]);
60+
61+
const openInNewTab = useCallback(() => {
62+
// Open with preview=true and id so the page fetches the draft version.
63+
const url = new URL(address);
64+
url.searchParams.set("preview", "true");
65+
if (entryId) {
66+
url.searchParams.set("id", entryId);
67+
}
68+
window.open(url.toString(), "_blank");
69+
}, [address, entryId]);
70+
71+
const onLoadFinish = () => {
3272
setLoading(false);
3373
};
3474

@@ -38,25 +78,82 @@ export const PreviewPane = (props: PreviewProps) => {
3878
};
3979

4080
return (
41-
<div className="relative flex flex-col border-r border-neutral-dimmed max-[960px]:hidden flex-1">
42-
<div className={"flex items-center gap-sm py-sm pr-sm border-b border-neutral-dimmed"}>
43-
<Input value={address} onChange={setAddress} onEnter={onEnter} size={"md"} />
44-
<IconButton onClick={reload} icon={<RefreshIcon />} variant="ghost" size="md" />
81+
<div className="relative border border-neutral-dimmed rounded-t-lg flex flex-col max-[960px]:hidden flex-1 h-full overflow-y-scroll">
82+
{/* Toolbar */}
83+
<div className="flex p-md items-center p-md bg-white border-b border-neutral-dimmed">
84+
85+
{/* URL input */}
86+
<div className="w-full border-neutral-dimmed p-xs rounded-lg">
87+
<Input
88+
value={address}
89+
onChange={setAddress}
90+
onEnter={onEnter}
91+
size="md"
92+
className={"bg-gray-100"}
93+
/>
94+
</div>
95+
96+
{/* Copy & Reload */}
97+
<div className="flex border-neutral-dimmed bg-white p-xs rounded-lg ml-2">
98+
<div className="flex items-center gap-xxs">
99+
{/* Open in new tab */}
100+
<IconButton
101+
onClick={openInNewTab}
102+
icon={<OpenInNewIcon />}
103+
variant="ghost"
104+
size="md"
105+
/>
106+
107+
<IconButton
108+
onClick={copyUrl}
109+
icon={<ContentCopyIcon />}
110+
variant="ghost"
111+
size="sm"
112+
/>
113+
114+
<IconButton
115+
onClick={reload}
116+
icon={<RefreshIcon />}
117+
variant="ghost"
118+
size="sm"
119+
/>
120+
</div>
121+
122+
{/* Desktop / Mobile toggle */}
123+
<SegmentedControl
124+
value={viewport}
125+
onChange={(value: string) => setViewport(value as ViewportMode)}
126+
items={[
127+
{ value: "desktop", label: "", icon: <LaptopIcon /> },
128+
{ value: "mobile", label: "", icon: <SmartphoneIcon /> },
129+
]}
130+
/>
131+
132+
</div>
45133
</div>
46-
<div className="block box-border h-full w-full">
47-
{loading ? <OverlayLoader text={"Connecting to Live Preview..."} /> : null}
48-
<iframe
49-
onLoad={onLoadFinish}
50-
ref={ref => {
51-
iframeRef.current = ref;
134+
135+
{/* Preview iframe */}
136+
<div className="block box-border h-full w-full overflow-auto">
137+
{loading ? <OverlayLoader text="Connecting to Live Preview..." /> : null}
138+
<div
139+
className="mx-auto h-full transition-all duration-300 p-md bg-white"
140+
style={{
141+
width: viewport === "mobile" ? "375px" : "100%",
52142
}}
53-
id={"live-preview-iframe"}
54-
sandbox={"allow-same-origin allow-scripts allow-forms"}
55-
src={previewUrl}
56-
width="100%"
57-
height="100%"
58-
className="h-full"
59-
/>
143+
>
144+
<iframe
145+
onLoad={onLoadFinish}
146+
ref={(ref) => {
147+
iframeRef.current = ref;
148+
}}
149+
id="live-preview-iframe"
150+
sandbox="allow-same-origin allow-scripts allow-forms"
151+
src={previewUrl}
152+
width="100%"
153+
height="100%"
154+
className="h-full"
155+
/>
156+
</div>
60157
</div>
61158
</div>
62159
);
Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
1-
const domain = "http://localhost:3000";
1+
/**
2+
* This function returns the domain name where the live preview app runs.
3+
* If you are running Webiny locally it will return http://localhost:3000
4+
* If you're currently accessing Webiny from a live domain, it should return the url to your live frontend application.
5+
* NOTE: modify the todo line with your live domain info.
6+
*/
7+
const getDomain = () => {
8+
if (window.location.hostname === "localhost") {
9+
return "http://localhost:3000";
10+
}
11+
// todo: put your live domain here
12+
return "http://localhost:3000";
13+
};
14+
15+
const previewPaths: Record<string, string> = {
16+
article: "/articles/preview"
17+
};
18+
19+
const slugPaths: Record<string, string> = {
20+
article: "/articles"
21+
};
22+
23+
export const getPreviewUrl = (modelId: string, editorOrigin: string) => {
24+
const path = previewPaths[modelId];
25+
if (!path) return null;
26+
return `${getDomain()}${path}?origin=${editorOrigin}`;
27+
};
228

3-
export const getPreviewUrl = (editorOrigin: string) => {
4-
return `${domain}/articles/preview?origin=${editorOrigin}`;
29+
export const getDisplayUrl = (modelId: string, slug?: string) => {
30+
const basePath = slugPaths[modelId];
31+
if (!basePath) return null;
32+
return `${getDomain()}${basePath}/${slug || ""}`;
533
};

0 commit comments

Comments
 (0)