From 6776a14757440137f53d79799cd6047434183791 Mon Sep 17 00:00:00 2001 From: nicwands Date: Mon, 1 Jun 2026 11:08:03 -0400 Subject: [PATCH] Add preview handling --- STRAPI_PREVIEW_SETUP.md | 193 +++++++++++++++++++++++++++ components/LivePreview.vue | 64 +++++++++ components/PreviewBanner.vue | 106 +++++++++++++++ libs/strapi.js | 104 ++++++++++++++- pages/+Head.vue | 5 + pages/+Layout.vue | 11 ++ pages/+config.ts | 15 +-- pages/@slug/+Page.vue | 85 ++++++++++++ pages/@slug/+data.js | 54 ++++++++ pages/articles/@slug/+Page.vue | 104 +++++++++++++++ pages/articles/@slug/+data.js | 55 ++++++++ pages/index/+data.js | 27 +++- pages/preview-unauthorized/+Page.vue | 32 +++++ pages/preview/+Page.vue | 59 ++++++++ pages/preview/+data.js | 65 +++++++++ strapi-config-example.js | 118 ++++++++++++++++ test-preview.html | 96 +++++++++++++ vite.config.js | 7 + 18 files changed, 1186 insertions(+), 14 deletions(-) create mode 100644 STRAPI_PREVIEW_SETUP.md create mode 100644 components/LivePreview.vue create mode 100644 components/PreviewBanner.vue create mode 100644 pages/@slug/+Page.vue create mode 100644 pages/@slug/+data.js create mode 100644 pages/articles/@slug/+Page.vue create mode 100644 pages/articles/@slug/+data.js create mode 100644 pages/preview-unauthorized/+Page.vue create mode 100644 pages/preview/+Page.vue create mode 100644 pages/preview/+data.js create mode 100644 strapi-config-example.js create mode 100644 test-preview.html diff --git a/STRAPI_PREVIEW_SETUP.md b/STRAPI_PREVIEW_SETUP.md new file mode 100644 index 0000000..0b72d4d --- /dev/null +++ b/STRAPI_PREVIEW_SETUP.md @@ -0,0 +1,193 @@ +# Strapi Preview Implementation for Vike App + +This document explains how to set up and use the Strapi Preview feature with this Vike application. + +## Features Implemented + +✅ **Basic Preview** - Preview draft and published content from Strapi admin +✅ **Dynamic Routes** - Support for articles, pages, and other content types +✅ **Preview Banner** - Visual indicator when in preview mode +✅ **Live Preview Ready** - Communication setup for real-time content updates +✅ **Security** - CSP headers for safe iframe embedding +✅ **Cache Control** - Preview content bypasses caching + +## Setup Instructions + +### 1. Frontend Configuration (Already Done) + +The Vike app is now configured with: + +- **Environment Variables** (`.env`): + ```bash + VITE_CLIENT_URL=https://takerofnotes.com + VITE_PREVIEW_SECRET=preview-secret-key-change-this-in-production + ``` + +- **Preview Route**: `/pages/preview/+data.js` and `/pages/preview/+Page.vue` +- **Enhanced Strapi Client**: Preview-aware data fetching +- **UI Components**: Preview banner and Live Preview communication +- **CSP Headers**: Allow embedding from Strapi admin + +### 2. Strapi Configuration (To Do) + +1. **Copy the admin configuration** from `strapi-config-example.js` to your Strapi project at `config/admin.js` + +2. **Add environment variables** to your Strapi `.env`: + ```bash + CLIENT_URL=https://takerofnotes.com + PREVIEW_SECRET=preview-secret-key-change-this-in-production + ``` + +3. **Update the preview secret** in both frontend and Strapi to match + +### 3. Content Type Setup + +The implementation supports these content type patterns: + +- **Global** (`api::global.global`) → `/` (homepage) +- **Pages** (`api::page.page`) → `/{slug}` +- **Articles** (`api::article.article`) → `/articles/{slug}` + +Add more content types by: +1. Updating `getPreviewPathname()` in the Strapi config +2. Creating corresponding route files in `/pages/` + +## How It Works + +### Preview Flow + +1. **Strapi Admin** → User clicks "Open Preview" on content +2. **Preview Handler** → Generates preview URL with secret and parameters +3. **Preview API** → Validates secret and redirects to content page with preview flags +4. **Content Page** → Detects preview mode and fetches draft content +5. **Preview Banner** → Shows preview status with exit option + +### URL Structure + +Preview URLs follow this pattern: +``` +/preview?secret=xxx&path=/articles/my-article&status=draft&documentId=123&uid=api::article.article +``` + +Which redirects to: +``` +/articles/my-article?preview=true&status=draft&documentId=123&uid=api::article.article +``` + +## File Structure + +``` +├── pages/ +│ ├── preview/ # Preview route handler +│ │ ├── +Page.vue +│ │ └── +data.js +│ ├── @slug/ # Dynamic pages route +│ │ ├── +Page.vue +│ │ └── +data.js +│ ├── articles/@slug/ # Dynamic articles route +│ │ ├── +Page.vue +│ │ └── +data.js +│ ├── index/+data.js # Homepage with preview support +│ └── +Layout.vue # Updated with preview components +├── components/ +│ ├── PreviewBanner.vue # Preview mode indicator +│ └── LivePreview.vue # Live Preview communication +├── libs/ +│ └── strapi.js # Enhanced with preview support +└── strapi-config-example.js # Strapi admin configuration +``` + +## Adding New Content Types + +To add preview support for a new content type: + +1. **Update Strapi Config** (`config/admin.js`): + ```js + case "api::my-content.my-content": + return `/my-content/${slug}`; + ``` + +2. **Create Vike Route**: + ``` + pages/my-content/@slug/ + ├── +Page.vue + └── +data.js + ``` + +3. **Implement Data Fetching**: + ```js + import { getBySlug, getByDocumentId } from '@/libs/strapi' + + export const data = async (pageContext) => { + // Extract preview parameters + const isPreview = searchParams.preview === 'true' + const status = searchParams.status || 'published' + + // Fetch with preview support + const response = await getBySlug('my-content', slug, { + isPreview, + status + }) + + return { content: response.data, isPreview, status } + } + ``` + +## Security Notes + +- **Preview Secret**: Change the default secret in production +- **CSP Headers**: Only allow embedding from your Strapi domain +- **Environment Variables**: Use `VITE_` prefix for client-side access +- **Validation**: Preview API validates secret before redirecting + +## Testing + +### Test Preview Mode + +1. Add `?preview=true&status=draft` to any URL +2. Should see orange preview banner +3. Content should be fetched with draft status + +### Test Preview Route + +Visit: `/preview?secret=your-secret&path=/&status=draft` +Should redirect to: `/?preview=true&status=draft` + +## Live Preview (Strapi Growth/Enterprise) + +The implementation includes Live Preview communication: + +- **Real-time Updates**: Content changes in Strapi trigger page refresh +- **Interactive Editing**: Double-click content to edit (with proper source maps) +- **Source Maps**: Enable with `strapi-encode-source-maps` header + +## Troubleshooting + +### Preview Not Working + +- Check environment variables match between frontend and Strapi +- Verify CSP headers allow iframe embedding +- Ensure Strapi admin config is properly deployed + +### Content Not Loading + +- Check Strapi API authentication +- Verify content type exists and has proper permissions +- Check browser network tab for failed requests + +### Preview Banner Not Showing + +- Verify URL contains `preview=true` parameter +- Check component import in Layout +- Ensure data is being passed correctly + +## Development vs Production + +### Development +- Use `http://localhost:3000` as CLIENT_URL +- Preview works locally during development + +### Production +- Update CLIENT_URL to your production domain +- Ensure HTTPS for iframe embedding +- Update preview secret for security \ No newline at end of file diff --git a/components/LivePreview.vue b/components/LivePreview.vue new file mode 100644 index 0000000..9031bfb --- /dev/null +++ b/components/LivePreview.vue @@ -0,0 +1,64 @@ + + + diff --git a/components/PreviewBanner.vue b/components/PreviewBanner.vue new file mode 100644 index 0000000..bd0c3aa --- /dev/null +++ b/components/PreviewBanner.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/libs/strapi.js b/libs/strapi.js index db5e1aa..f80e63f 100644 --- a/libs/strapi.js +++ b/libs/strapi.js @@ -5,7 +5,105 @@ export const strapiClient = strapi({ auth: import.meta.env.VITE_STRAPI_API_TOKEN, }) -export const getGlobal = async () => { - const globalManager = strapiClient.single('global') - return await globalManager.find() +/** + * Enhanced fetch function with preview support + * @param {string} contentType - The Strapi content type (e.g., 'global', 'articles') + * @param {object} options - Fetch options including preview settings + * @param {boolean} options.isPreview - Whether this is a preview request + * @param {string} options.status - Content status ('draft' or 'published') + * @param {boolean} options.isSingle - Whether this is a single type or collection + * @param {object} options.params - Additional query parameters + * @returns {Promise} Strapi response + */ +export const fetchWithPreview = async (contentType, options = {}) => { + const { + isPreview = false, + status = 'published', + isSingle = false, + params = {}, + ...otherOptions + } = options + + const queryParams = { ...params } + const headers = {} + + // Add status parameter for draft content in preview mode + if (isPreview && status) { + queryParams.status = status + } + + // Enable content source maps for Live Preview + if (isPreview) { + headers['strapi-encode-source-maps'] = 'true' + } + + // Disable caching for preview requests + if (isPreview) { + headers['Cache-Control'] = 'no-cache' + } + + try { + if (isSingle) { + const manager = strapiClient.single(contentType) + return await manager.find(queryParams, { headers, ...otherOptions }) + } else { + const manager = strapiClient.collection(contentType) + return await manager.find(queryParams, { headers, ...otherOptions }) + } + } catch (error) { + console.error(`Error fetching ${contentType}:`, error) + throw error + } +} + +/** + * Fetch global content with preview support + */ +export const getGlobal = async (previewOptions = {}) => { + return await fetchWithPreview('global', { + isSingle: true, + ...previewOptions, + }) +} + +/** + * Fetch a collection item by slug with preview support + */ +export const getBySlug = async (contentType, slug, previewOptions = {}) => { + return await fetchWithPreview(contentType, { + isSingle: false, + params: { + filters: { slug: { $eq: slug } }, + populate: '*', + }, + ...previewOptions, + }) +} + +/** + * Fetch a single item by document ID (useful for preview) + */ +export const getByDocumentId = async ( + contentType, + documentId, + previewOptions = {}, +) => { + const { isSingle = false } = previewOptions + + if (isSingle) { + return await fetchWithPreview(contentType, { + isSingle: true, + params: { documentId }, + ...previewOptions, + }) + } else { + return await fetchWithPreview(contentType, { + isSingle: false, + params: { + filters: { documentId: { $eq: documentId } }, + populate: '*', + }, + ...previewOptions, + }) + } } diff --git a/pages/+Head.vue b/pages/+Head.vue index 9c6fa37..e3f6dcc 100644 --- a/pages/+Head.vue +++ b/pages/+Head.vue @@ -1,6 +1,11 @@ diff --git a/pages/+Layout.vue b/pages/+Layout.vue index 1f730e7..250c771 100644 --- a/pages/+Layout.vue +++ b/pages/+Layout.vue @@ -1,8 +1,14 @@ @@ -11,7 +17,12 @@ import '@/styles/main.scss' import { ref, computed, onMounted } from 'vue' import loadFonts from '@fuzzco/font-loader' import { useWindowSize } from '@vueuse/core' +import { useData } from 'vike-vue/useData' import Lenis from '@/components/Lenis.vue' +import PreviewBanner from '@/components/PreviewBanner.vue' +import LivePreview from '@/components/LivePreview.vue' + +const data = useData() const { height } = useWindowSize() diff --git a/pages/+config.ts b/pages/+config.ts index 42a385b..4500040 100644 --- a/pages/+config.ts +++ b/pages/+config.ts @@ -1,14 +1,13 @@ -import type { Config } from "vike/types"; -import vikeVue from "vike-vue/config"; +import type { Config } from 'vike/types' +import vikeVue from 'vike-vue/config' // Default config (can be overridden by pages) // https://vike.dev/config export default { - // https://vike.dev/head-tags - title: "My Vike App", - description: "Demo showcasing Vike", + title: 'My Vike App', + description: 'Demo showcasing Vike', - prerender: true, - extends: [vikeVue], -} as Config; + prerender: true, + extends: [vikeVue], +} as Config diff --git a/pages/@slug/+Page.vue b/pages/@slug/+Page.vue new file mode 100644 index 0000000..04749c1 --- /dev/null +++ b/pages/@slug/+Page.vue @@ -0,0 +1,85 @@ + + + + + \ No newline at end of file diff --git a/pages/@slug/+data.js b/pages/@slug/+data.js new file mode 100644 index 0000000..6e4b8a7 --- /dev/null +++ b/pages/@slug/+data.js @@ -0,0 +1,54 @@ +import { getBySlug, getByDocumentId } from '@/libs/strapi' + +export const data = async (pageContext) => { + const { routeParams, urlParsed } = pageContext + const { slug } = routeParams + const searchParams = urlParsed.search || {} + + // Extract preview parameters from URL + const isPreview = searchParams.preview === 'true' + const status = searchParams.status || 'published' + const documentId = searchParams.documentId + const uid = searchParams.uid + + try { + let page = null + + // If we have a documentId from preview, fetch by document ID + if (isPreview && documentId && uid === 'api::page.page') { + const response = await getByDocumentId('pages', documentId, { + isPreview, + status, + isSingle: false + }) + page = response.data?.[0] || null + } else { + // Normal fetch by slug + const response = await getBySlug('pages', slug, { + isPreview, + status + }) + page = response.data?.[0] || null + } + + return { + page, + // Pass preview state to the component + isPreview, + status, + documentId, + uid + } + } catch (error) { + console.error('Error fetching page:', error) + + // Return null page but preserve preview state + return { + page: null, + isPreview, + status, + documentId, + uid + } + } +} \ No newline at end of file diff --git a/pages/articles/@slug/+Page.vue b/pages/articles/@slug/+Page.vue new file mode 100644 index 0000000..cee7c7d --- /dev/null +++ b/pages/articles/@slug/+Page.vue @@ -0,0 +1,104 @@ + + + + + \ No newline at end of file diff --git a/pages/articles/@slug/+data.js b/pages/articles/@slug/+data.js new file mode 100644 index 0000000..1dc72b8 --- /dev/null +++ b/pages/articles/@slug/+data.js @@ -0,0 +1,55 @@ +import { getBySlug, getByDocumentId } from '@/libs/strapi' + +export const data = async (pageContext) => { + const { routeParams, urlParsed } = pageContext + const { slug } = routeParams + const searchParams = urlParsed.search || {} + + // Extract preview parameters from URL + const isPreview = searchParams.preview === 'true' + const status = searchParams.status || 'published' + const documentId = searchParams.documentId + const uid = searchParams.uid + + try { + let article = null + + // If we have a documentId from preview, fetch by document ID + // This ensures we get the exact document being previewed + if (isPreview && documentId && uid === 'api::article.article') { + const response = await getByDocumentId('articles', documentId, { + isPreview, + status, + isSingle: false + }) + article = response.data?.[0] || null + } else { + // Normal fetch by slug + const response = await getBySlug('articles', slug, { + isPreview, + status + }) + article = response.data?.[0] || null + } + + return { + article, + // Pass preview state to the component + isPreview, + status, + documentId, + uid + } + } catch (error) { + console.error('Error fetching article:', error) + + // Return null article but preserve preview state for proper UI rendering + return { + article: null, + isPreview, + status, + documentId, + uid + } + } +} \ No newline at end of file diff --git a/pages/index/+data.js b/pages/index/+data.js index b499586..588d454 100644 --- a/pages/index/+data.js +++ b/pages/index/+data.js @@ -1,7 +1,28 @@ import { getGlobal } from '@/libs/strapi' -export const data = async () => { - const global = await getGlobal() +export const data = async (pageContext) => { + const { urlParsed } = pageContext + const searchParams = urlParsed.search || {} + + // Extract preview parameters from URL + const isPreview = searchParams.preview === 'true' + const status = searchParams.status || 'published' + const documentId = searchParams.documentId + const uid = searchParams.uid + + // Fetch global content with preview support + const global = await getGlobal({ + isPreview, + status, + ...(documentId && { params: { documentId } }) + }) - return { global: global.data } + return { + global: global.data, + // Pass preview state to the component + isPreview, + status, + documentId, + uid + } } diff --git a/pages/preview-unauthorized/+Page.vue b/pages/preview-unauthorized/+Page.vue new file mode 100644 index 0000000..f89513e --- /dev/null +++ b/pages/preview-unauthorized/+Page.vue @@ -0,0 +1,32 @@ + + + \ No newline at end of file diff --git a/pages/preview/+Page.vue b/pages/preview/+Page.vue new file mode 100644 index 0000000..59e67a7 --- /dev/null +++ b/pages/preview/+Page.vue @@ -0,0 +1,59 @@ + + + + + \ No newline at end of file diff --git a/pages/preview/+data.js b/pages/preview/+data.js new file mode 100644 index 0000000..df0840d --- /dev/null +++ b/pages/preview/+data.js @@ -0,0 +1,65 @@ +import { redirect } from 'vike/abort' + +export const data = async (pageContext) => { + try { + // Extract URL parameters safely + const { urlParsed } = pageContext + const searchParams = urlParsed?.search || {} + + console.log('Preview route - pageContext keys:', Object.keys(pageContext)) + console.log('Preview route - urlParsed:', urlParsed) + console.log('Preview route - searchParams:', searchParams) + + const { + secret, + path = '/', + status = 'draft', + documentId, + uid + } = searchParams + + // Validate preview secret + if (!secret || secret !== import.meta.env.VITE_PREVIEW_SECRET) { + console.log('Invalid secret:', secret, 'Expected:', import.meta.env.VITE_PREVIEW_SECRET) + throw redirect('/preview-unauthorized') + } + + // Build the preview URL path (not full URL since we're redirecting within same origin) + const previewPath = path || '/' + const previewUrl = new URL(previewPath, 'http://localhost') + + // Add preview parameters + previewUrl.searchParams.set('preview', 'true') + previewUrl.searchParams.set('status', status) + + // Optional: include document metadata for advanced preview functionality + if (documentId) { + previewUrl.searchParams.set('documentId', documentId) + } + if (uid) { + previewUrl.searchParams.set('uid', uid) + } + + const redirectUrl = previewUrl.pathname + previewUrl.search + console.log('Redirecting to:', redirectUrl) + + // Use Vike's redirect + throw redirect(redirectUrl) + + } catch (error) { + // If it's a redirect, let it pass through + if (error.redirectTo) { + throw error + } + + console.error('Preview data error:', error) + + // For debugging, let's not redirect on error, but show what went wrong + return { + redirectUrl: null, + error: error.message || 'Failed to process preview request', + pageContext: Object.keys(pageContext), + urlParsed: pageContext.urlParsed + } + } +} \ No newline at end of file diff --git a/strapi-config-example.js b/strapi-config-example.js new file mode 100644 index 0000000..ff0508f --- /dev/null +++ b/strapi-config-example.js @@ -0,0 +1,118 @@ +/** + * Strapi Admin Configuration for Preview Feature + * + * This file should be placed in your Strapi project at: config/admin.js + * + * Environment Variables Required in Strapi .env: + * CLIENT_URL=https://takerofnotes.com + * PREVIEW_SECRET=preview-secret-key-change-this-in-production + */ + +/** + * Generate preview pathname based on content type and document + * @param {string} uid - Content type identifier (e.g., 'api::article.article') + * @param {object} options - Options containing locale and document data + * @returns {string|null} - The preview pathname or null if no preview available + */ +const getPreviewPathname = (uid, { locale, document }) => { + const { slug } = document || {}; + + switch (uid) { + // Global single type - preview on homepage + case "api::global.global": + return "/"; + + // Generic pages with slugs + case "api::page.page": + if (!slug) { + return null; // No slug, no preview + } + return `/${slug}`; + + // Articles/blog posts + case "api::article.article": + if (!slug) { + return "/articles"; // Could redirect to articles index + } + return `/articles/${slug}`; + + // Products (example) + case "api::product.product": + if (!slug) { + return "/products"; + } + return `/products/${slug}`; + + // Add more content types as needed + default: + // Return null for content types that shouldn't have previews + // (e.g., configuration, metadata, components) + return null; + } +}; + +// Main Strapi admin configuration +export default ({ env }) => { + // Get environment variables + const clientUrl = env("CLIENT_URL"); + const previewSecret = env("PREVIEW_SECRET"); + + return { + // Other admin configurations can go here + // auth: { ... }, + // apiToken: { ... }, + + preview: { + enabled: true, + config: { + // Allow preview iframe to load from your frontend domain + allowedOrigins: clientUrl, + + /** + * Preview handler - generates preview URLs for content + * @param {string} uid - Content type UID + * @param {object} context - Preview context + * @param {string} context.documentId - Document ID being previewed + * @param {string} context.locale - Document locale + * @param {string} context.status - Document status ('draft' or 'published') + * @returns {string|null} - Preview URL or null to disable preview + */ + async handler(uid, { documentId, locale, status }) { + try { + // Fetch the complete document from Strapi + const document = await strapi.documents(uid).findOne({ + documentId, + // Ensure we get the correct version based on status + ...(status === 'draft' && { status: 'draft' }) + }); + + // Generate the preview pathname + const pathname = getPreviewPathname(uid, { locale, document }); + + // If no pathname is generated, disable preview for this content type + if (!pathname) { + return null; + } + + // Build preview URL with all necessary parameters + const urlSearchParams = new URLSearchParams({ + path: pathname, + secret: previewSecret, + status: status || 'draft', + documentId, + uid, + ...(locale && { locale }) + }); + + // Return the complete preview URL pointing to your Vike app's preview page + return `${clientUrl}/preview?${urlSearchParams}`; + + } catch (error) { + console.error('Preview handler error:', error); + return null; + } + }, + }, + }, + }; +}; \ No newline at end of file diff --git a/test-preview.html b/test-preview.html new file mode 100644 index 0000000..fdc5da2 --- /dev/null +++ b/test-preview.html @@ -0,0 +1,96 @@ + + + + Test Strapi Preview Implementation + + + +

Strapi Preview Implementation Test

+ +
+

1. Test Preview Route (Valid Secret)

+

This should redirect to the homepage with preview parameters:

+ + Test Valid Preview + +
+ +
+

2. Test Preview Route (Invalid Secret)

+

This should show an unauthorized page:

+ + Test Invalid Preview + +
+ +
+

3. Test Preview Mode Directly

+

This should show the homepage with preview banner:

+ + Test Preview Mode + +
+ +
+

4. Test Article Preview (if you have articles)

+

This should test dynamic route preview:

+ + Test Article Preview + +
+ +
+

Expected Strapi Configuration

+

For reference, your Strapi config should generate URLs like this:

+
https://takerofnotes.com/preview?secret=preview-secret-key-change-this-in-production&path=/articles/my-article&status=draft&documentId=123&uid=api::article.article
+
+ +
+

Environment Variables Check

+

Make sure these are set in your .env file:

+
VITE_CLIENT_URL=https://takerofnotes.com
+VITE_PREVIEW_SECRET=preview-secret-key-change-this-in-production
+ +

And in your Strapi .env file:

+
CLIENT_URL=https://takerofnotes.com
+PREVIEW_SECRET=preview-secret-key-change-this-in-production
+
+ + \ No newline at end of file diff --git a/vite.config.js b/vite.config.js index daba855..0c3c3f7 100644 --- a/vite.config.js +++ b/vite.config.js @@ -12,6 +12,13 @@ export default defineConfig({ '@': fileURLToPath(new URL('./', import.meta.url)), }, }, + server: { + headers: { + // Allow the app to be embedded in iframes from Strapi admin + 'Content-Security-Policy': + "frame-ancestors 'self' https://cms.takerofnotes.com", + }, + }, css: { preprocessorOptions: { scss: {