diff --git a/.gitignore b/.gitignore index a92e72d..7836fb1 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,11 @@ lib/ yalc.lock .idea/ -dist/ \ No newline at end of file +dist/ +/.env +/package-lock.json + +certificates + +/.next +/.env.sandbox diff --git a/README.md b/README.md index 0f633af..c745377 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,83 @@ -# Adobe Universal Editor Sample App +# Adobe Universal Editor Sample App (Next.js) -## Using the Sample App -The Sample App is hosted at https://ue-remote-app.adobe.net. -Per Default the content is retrieved and written back to the Adobe Experience Manager as a Cloud Service ( Production ) Demo Environment: +This is a sample Next.js application demonstrating how to integrate and use the Adobe Universal Editor with an AEM headless backend. -The default settings from [.env](.env) can be overwritten using Query parameters: -* `authorHost`: host to retrieve data from and update content to; default=https://author-p7452-e12433.adobeaemcloud.com -* `service`: Universal Editor Service endpoint; default Universal Editor default -* `protocol`: protocol to use with backend, can be `aem`, `aem65`, `aemcsLegacy`; default: `aem` -* `cors`: defining which cors.js - connection between Universal Editor and application shall be used. Can be `stage` or empty; default `null/empty`. `stage` will use the cors library hosted on stage, else it will use the production version +## Local Development with Universal Editor -To retrieve content from another environment add `authorHost` as query parameters, e.g. +To run this application locally and edit it using the Universal Editor, follow these steps: -[https://ue-remote-app.adobe.net?authorHost=https://author-p7452-e12433.adobeaemcloud.com](https://ue-remote-app.adobe.net?authorHost=https://author-p7452-e12433.adobeaemcloud.com) +### Prerequisites -Similarly, if running the Universal Editor App on local dev environment, add `authorHost` as query parameters like this: +1. **AEM Local Instance**: An AEM 6.5 or AEM as a Cloud Service (AEMCS) local SDK instance running locally. +2. **HTTPS Configuration**: AEM must be configured to run on HTTPS (e.g., `https://localhost:8443`). +3. **Content**: Ensure you have the latest WKND Site or the appropriate headless models/content installed on your local AEM instance. +4. **Universal Editor CORS Proxy**: You must install the AEM Universal Editor CORS proxy package (bundle/jar) on your local AEM instance. You can download the latest Universal Editor local proxy package from the [Adobe Software Distribution portal](https://experience.adobe.com/#/downloads/content/software-distribution/en/aem.html). This is required to bypass CORS restrictions when the editor runs locally. -[https://localhost:3000?authorHost=https://localhost:8443&service=https://localhost:8443/universal-editor](https://localhost:3000?authorHost=https://localhost:8443&service=https://localhost:8443/universal-editor) +### Environment Configuration -## Run locally +For local development, the application uses the `.env.local` file. Ensure it contains the appropriate variables for your local setup. Example: -- AEM 6.5 or AEMCS instance -- Latest WKND Content installed on the AEM instance[https://github.com/adobe/aem-guides-wknd/releases/latest](https://github.com/adobe/aem-guides-wknd/releases/latest) -- AEM configured to run on HTTPS [https://experienceleague.adobe.com/en/docs/experience-manager-learn/foundation/security/use-the-ssl-wizard](https://experienceleague.adobe.com/en/docs/experience-manager-learn/foundation/security/use-the-ssl-wizard) -- `Adobe Granite Token Authentication Handler` configured to set `token.samesite.cookie.attr=Partitioned` -- Remove `X-FRAME-Options=SAMEORIGIN` from `Apache Sling Main Servlet`'s `sling.additional.response.headers` attribute if run locally -- Add policy for `https://localhost:3000` to `Adobe Granite Cross-Origin Resource Sharing Policy`. The default `adobe` configuraiton can be used as blueprint if run local copy of the app -- Follow configuration on [https://github.com/maximilianvoss/universal-editor-service-proxy](https://github.com/maximilianvoss/universal-editor-service-proxy) for local development set up -- Open Universal Editor either - - under AEM domain for AEMCS, e.g. [https://author-p7452-e12433.adobeaemcloud.com/ui#/aem/universal-editor/canvas/](https://author-p7452-e12433.adobeaemcloud.com/ui#/aem/universal-editor/canvas/) - - or on [https://experience.adobe.com/#/aem/editor/canvas/](https://experience.adobe.com/#/aem/editor/canvas/) -- For experience.adobe.com use the `Local Developer Login` to authenticate against your local AEM instance when using a local SDK or AEM 6.5 +```env +NEXT_PUBLIC_AEM_ACCESS_TOKEN="admin:admin" +NEXT_PUBLIC_AEM_HOST="https://localhost:8443" +NEXT_PUBLIC_UE_SERVICE="https://localhost:8000" +NODE_TLS_REJECT_UNAUTHORIZED=0 +``` -## Available Scripts +- `NEXT_PUBLIC_AEM_HOST`: Points to your local AEM author instance. +- `NEXT_PUBLIC_AEM_ACCESS_TOKEN`: The credentials (e.g., Basic Auth or Bearer token) needed to fetch content from your local AEM instance. +- `NEXT_PUBLIC_UE_SERVICE`: Points to the local Universal Editor service if you are running it locally. -In the project directory, you can run: +### Running the App Locally + +To start the Next.js development server specifically configured for a local AEM instance: + +```bash +npm run dev:local +``` + +**Note:** The `dev:local` script automatically includes a local CA certificate (`NODE_EXTRA_CA_CERTS=certificates/localhost.pem`) and enables experimental HTTPS in Next.js (`--experimental-https`). This is required to resolve local SSL certificate errors when fetching data from AEM over HTTPS on localhost. + +The app will be available at [https://localhost:3000](https://localhost:3000). -### `yarn start` +### Local Universal Editor Service Proxy -Runs the app in the development mode.\ -Open [https://localhost:3000](https://localhost:3000) to view it in your browser. +If you are running the Universal Editor service proxy locally, you must create and trust a local certificate before starting the service: -The page will reload when you make changes.\ -You may also see any lint errors in the console. +1. **Generate a local certificate:** + ```bash + openssl req -x509 -newkey rsa:2048 -keyout key.pem -out certificate.pem -days 365 -nodes -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" + ``` +2. **Trust the certificate on your system (macOS):** + ```bash + sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain certificate.pem + ``` +3. **Start the local Universal Editor service:** + ```bash + node universal-editor-service.cjs + ``` -### `yarn build` +### Opening in Universal Editor + +1. Open the Universal Editor. Ensure your local Universal Editor service proxy is running (e.g., at `https://localhost:8000`). +2. Point the Universal Editor to your local Next.js app URL: `https://localhost:3000`. +3. You can now edit the Next.js application in context. The changes will be pushed back to your local AEM instance at `https://localhost:8443`. + +## Available Scripts + +In the project directory, you can run: -Builds the app for production to the `dist` folder. +### `npm run dev:local` +Runs the app in development mode, tailored for localhost. It injects local certificates to bypass SSL connection errors when communicating with local AEM over HTTPS. -### `yarn preview` +### `npm run dev:sandbox` +Runs the app using configurations defined in `.env.sandbox`. Useful when connecting to a remote sandbox AEM environment instead of localhost. -Run the built app in production mode locally to verify the build. +### `npm run build` +Builds the Next.js app for production to the `.next` folder. -### `yarn deploy` +### `npm run start` +Starts the built Next.js application in production mode. -Build the application and push it to GitHub pages \ No newline at end of file +### `npm run deploy` +Builds the application and deploys it to GitHub Pages. \ No newline at end of file diff --git a/index.html b/index.html deleted file mode 100644 index a3b9a54..0000000 --- a/index.html +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - React App - - - - - -
- - - - - \ No newline at end of file diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..597c70d --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,22 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: false, + async headers() { + debugger; + return [ + { + source: "/:path*", + headers: [ + { key: "Access-Control-Allow-Credentials", value: "true" }, + { key: "Access-Control-Allow-Origin", value: "https://experience.adobe.com" }, + { key: "Access-Control-Allow-Methods", value: "GET, POST, OPTIONS, PUT, PATCH, DELETE" }, + { key: "Access-Control-Allow-Headers", value: "Content-Type, Authorization, X-Requested-With" }, + // CRITICAL: This allows a public site to access your local network + { key: "Access-Control-Allow-Private-Network", value: "true" }, + ], + }, + ] + } +}; + +export default nextConfig; diff --git a/package.json b/package.json index d43a54a..4a9c177 100644 --- a/package.json +++ b/package.json @@ -17,27 +17,25 @@ ], "type": "module", "scripts": { - "start": "vite", - "build": "vite build", - "preview": "vite preview", - "deploy": "npm run build && gh-pages -d dist --cname ue-remote-app.adobe.net" + "start": "next start", + "dev:sandbox": "env-cmd -f .env.sandbox next dev --experimental-https", + "build": "next build", + "deploy": "next build && gh-pages -d out --cname ue-remote-app.adobe.net", + "dev:local": "NODE_EXTRA_CA_CERTS=certificates/localhost.pem next dev --experimental-https" }, "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-router-dom": "^7.9.6", + "@adobe/jwt-auth": "^2.0.0", + "next": "^16.2.4", + "react": "^19.2.5", + "react-dom": "^19.2.5", "sass": "^1.94.1" }, "devDependencies": { - "@adobe/aem-headless-client-js": "^4.0.0", "@adobe/aem-headless-client-nodejs": "^2.0.0", "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", - "@vitejs/plugin-basic-ssl": "^2.1.0", - "@vitejs/plugin-react": "^4.2.1", - "gh-pages": "^6.1.0", - "react-helmet-async": "^2.0.4", - "vite": "^5.0.8" + "env-cmd": "^11.0.0", + "gh-pages": "^6.1.0" }, "eslintConfig": { "extends": [ diff --git a/public/static/component-definition.json b/public/static/component-definition.json index 34b59ee..cbc8489 100644 --- a/public/static/component-definition.json +++ b/public/static/component-definition.json @@ -71,6 +71,18 @@ } } } + }, + { + "title": "Carousel Slide", + "id": "carousel-item", + "model": "carousel-item", + "plugins": { + "aem": { + "page": { + "resourceType": "wknd/components/container" + } + } + } } ] }, @@ -106,6 +118,19 @@ } } } + }, + { + "title": "Carousel", + "id": "carousel", + "model": "carousel", + "filter": "carousel", + "plugins": { + "aem": { + "page": { + "resourceType": "wknd/components/carousel" + } + } + } } ] } diff --git a/public/static/filter-definition.json b/public/static/filter-definition.json index 07f42f1..69790a7 100644 --- a/public/static/filter-definition.json +++ b/public/static/filter-definition.json @@ -1,14 +1,20 @@ [ -{ + { "id": "container", "components": [ - "text", "image", "title", "accordion", "container", "richtext" + "text", "image", "title", "accordion", "carousel", "container", "richtext" ] - }, + }, { "id": "accordion", "components": [ "accordion-item" ] + }, + { + "id": "carousel", + "components": [ + "carousel-item" + ] } -] \ No newline at end of file +] diff --git a/public/static/model-definition.json b/public/static/model-definition.json index a3d595b..fcb54c2 100644 --- a/public/static/model-definition.json +++ b/public/static/model-definition.json @@ -55,5 +55,57 @@ "required": true } ] + }, + { + "id": "carousel", + "fields": [ + { + "component": "boolean", + "name": "autoplay", + "label": "Auto-play slides", + "valueType": "boolean" + }, + { + "component": "boolean", + "name": "showIndicators", + "label": "Show slide indicators", + "valueType": "boolean", + "value": true + }, + { + "component": "boolean", + "name": "showNavigation", + "label": "Show navigation arrows", + "valueType": "boolean", + "value": true + } + ] + }, + { + "id": "carousel-item", + "fields": [ + { + "component": "text", + "name": "cq:panelTitle", + "value": "", + "label": "Slide Title", + "valueType": "string", + "required": true + }, + { + "component": "select", + "name": "overlayStrength", + "value": "medium", + "label": "Image Overlay Strength", + "description": "Darkens the image so text overlay is readable. Applies only when an image and other content share a slide.", + "valueType": "string", + "options": [ + { "name": "None", "value": "none" }, + { "name": "Subtle", "value": "subtle" }, + { "name": "Medium (default)", "value": "medium" }, + { "name": "Strong", "value": "strong" } + ] + } + ] } ] diff --git a/src/App.jsx b/src/App.jsx deleted file mode 100644 index 9f2806a..0000000 --- a/src/App.jsx +++ /dev/null @@ -1,82 +0,0 @@ -import {React, useEffect} from "react"; -import { Helmet, HelmetProvider } from 'react-helmet-async'; -import {HashRouter as Router, Route, Routes} from "react-router-dom"; -import Home from "./components/Home"; -import AdventureDetail from "./components/AdventureDetail"; -import Articles from "./components/Articles"; -import ArticleDetail from "./components/ArticleDetail"; -import About from "./components/About"; -import {getAuthorHost, getProtocol, getService} from "./utils/fetchData"; -import {getQueryStringForHashRouting} from "./utils/commons"; -import logo from "./images/wknd-logo-dk.svg"; -import "./App.scss"; - -const NavMenu = () => { - const query = getQueryStringForHashRouting(); - return ( - - ); -}; - -const Header = () => { - return ( -
{/*WKND Logo*/} - WKND Logo - - -
- ); -}; - -const Footer = () => ( - -); - -function App() { - // With hash routing, keep query params only in the hash to avoid duplication - useEffect(() => { - if (window.location.search) { - const hash = window.location.hash || "#/"; - const newHash = hash + (hash.includes("?") ? "" : window.location.search); - window.history.replaceState(null, "", window.location.pathname + newHash); - } - }, []); - - return ( - -
- - - { getService() && } - - -
-
-
- - } /> - } /> - } /> - } /> - } /> - -
- -
-
-
- ); -} - -export default App; diff --git a/src/App.scss b/src/App.scss index a4da989..e9ef144 100644 --- a/src/App.scss +++ b/src/App.scss @@ -6,8 +6,8 @@ accordance with the terms of the Adobe license agreement accompanying it. */ /* Normalize */ -@import './styles/variables'; -@import './styles/fonts'; +@use './styles/variables' as *; +@use './styles/fonts' as *; body { background-color: $black; diff --git a/src/api/.graphqlServer.js.swp b/src/api/.graphqlServer.js.swp new file mode 100644 index 0000000..392c3a6 Binary files /dev/null and b/src/api/.graphqlServer.js.swp differ diff --git a/src/api/graphqlServer.js b/src/api/graphqlServer.js new file mode 100644 index 0000000..9d2e431 --- /dev/null +++ b/src/api/graphqlServer.js @@ -0,0 +1,23 @@ +import AEMHeadless from '@adobe/aem-headless-client-nodejs'; + +// We fall back to a default since `getAuthorHost` used `window.location`. +// On the server, we must rely on env variables for the host. +const AEM_HOST = process.env.NEXT_PUBLIC_AEM_HOST || "https://author-p117303-e1695777.adobeaemcloud.com"; +const AEM_TOKEN = process.env.NEXT_PUBLIC_AEM_ACCESS_TOKEN; + +const sdk = new AEMHeadless({ + serviceURL: AEM_HOST, + endpoint: "/content/graphql/global/endpoint.json", + auth: AEM_TOKEN ? (AEM_TOKEN.includes(':') ? AEM_TOKEN.split(':') : AEM_TOKEN) : undefined, + fetch: fetch +}); + +export async function fetchPersistedQuery(path, variables = {}) { + try { + const response = await sdk.runPersistedQuery(path, variables); + return { data: response.data, errors: response.errors }; + } catch (error) { + console.error("Error fetching GraphQL from server:", error); + return { data: null, errors: [error] }; + } +} diff --git a/src/api/useGraphQL.js b/src/api/useGraphQL.js deleted file mode 100644 index 4a222b9..0000000 --- a/src/api/useGraphQL.js +++ /dev/null @@ -1,60 +0,0 @@ -/* -Copyright 2020 Adobe -All Rights Reserved. -NOTICE: Adobe permits you to use, modify, and distribute this file in -accordance with the terms of the Adobe license agreement accompanying -it. -*/ -import {useState, useEffect} from 'react'; -import {getAuthorHost} from "../utils/fetchData"; -import {AEMHeadless} from '@adobe/aem-headless-client-js'; - - -/** - * Custom React Hook to perform a GraphQL query - * @param path - Persistent query path - */ -function useGraphQL(path) { - let [data, setData] = useState(null); - let [errorMessage, setErrors] = useState(null); - useEffect(() => { - function makeRequest() { - const sdk = new AEMHeadless({ - serviceURL: getAuthorHost(), - endpoint: "/content/graphql/global/endpoint.json", - }); - const request = sdk.runPersistedQuery.bind(sdk); - - request(path, {}, {credentials: "include"}) - .then(({data, errors}) => { - //If there are errors in the response set the error message - if (errors) { - setErrors(mapErrors(errors)); - } - //If data in the response set the data as the results - if (data) { - setData(data); - } - }) - .catch((error) => { - setErrors(error); - sessionStorage.removeItem('accessToken'); - }); - } - - makeRequest(); - }, [path]); - - - return {data, errorMessage} -} - -/** - * concatenate error messages into a single string. - * @param {*} errors - */ -function mapErrors(errors) { - return errors.map((error) => error.message).join(","); -} - -export default useGraphQL; diff --git a/src/app/UniversalEditorMeta.jsx b/src/app/UniversalEditorMeta.jsx new file mode 100644 index 0000000..1028040 --- /dev/null +++ b/src/app/UniversalEditorMeta.jsx @@ -0,0 +1,33 @@ +"use client"; + +import { useEffect } from "react"; +import { getProtocol, getService } from "../utils/fetchData"; + +export default function UniversalEditorMeta() { + useEffect(() => { + // Add urn:adobe:aue:system:aemconnection + const aemConnectionMeta = document.createElement("meta"); + aemConnectionMeta.name = "urn:adobe:aue:system:aemconnection"; + aemConnectionMeta.content = `${getProtocol()}:${process.env.NEXT_PUBLIC_AEM_HOST}`; + aemConnectionMeta.dataRh ="true"; + document.head.appendChild(aemConnectionMeta); + + // Add urn:adobe:aue:config:service if it exists + const service = getService(); + if (service) { + const configServiceMeta = document.createElement("meta"); + configServiceMeta.name = "urn:adobe:aue:config:service"; + configServiceMeta.content = service; + document.head.appendChild(configServiceMeta); + } + + return () => { + document.head.removeChild(aemConnectionMeta); + if (service) { + document.head.querySelector('meta[name="urn:adobe:aue:config:service"]')?.remove(); + } + }; + }, []); + + return null; +} diff --git a/src/app/aboutus/page.jsx b/src/app/aboutus/page.jsx new file mode 100644 index 0000000..bdb8812 --- /dev/null +++ b/src/app/aboutus/page.jsx @@ -0,0 +1,6 @@ + +import About from "../../components/About"; + +export default function Page() { + return ; +} diff --git a/src/app/adventure/[slug]/page.jsx b/src/app/adventure/[slug]/page.jsx new file mode 100644 index 0000000..b6e2412 --- /dev/null +++ b/src/app/adventure/[slug]/page.jsx @@ -0,0 +1,6 @@ +import AdventureDetail from "../../../components/AdventureDetail"; + +export default async function Page(props) { + const params = await props.params; + return ; +} diff --git a/src/app/aem-proxy/[...path]/route.js b/src/app/aem-proxy/[...path]/route.js new file mode 100644 index 0000000..06fd53e --- /dev/null +++ b/src/app/aem-proxy/[...path]/route.js @@ -0,0 +1,62 @@ +import { NextResponse } from 'next/server'; + +export async function GET(request, { params }) { + return handleProxy(request); +} + +export async function POST(request, { params }) { + return handleProxy(request); +} + +export async function OPTIONS(request, { params }) { + return handleProxy(request); +} + +async function handleProxy(request) { + const requestHeaders = new Headers(request.headers); + + // Remove host header to avoid SSL mismatch issues when proxying + requestHeaders.delete('host'); + + const token = process.env.NEXT_PUBLIC_AEM_ACCESS_TOKEN; + const aemHost = process.env.NEXT_PUBLIC_AEM_HOST || "https://author-p117303-e1695777.adobeaemcloud.com"; + + if (token) { + if (token.includes(':')) { + requestHeaders.set('Authorization', `Basic ${btoa(token)}`); + } else { + requestHeaders.set('Authorization', `Bearer ${token}`); + } + } + + const path = request.nextUrl.pathname.replace('/aem-proxy', ''); + const search = request.nextUrl.search; + const targetUrl = `${aemHost}${path}${search}`; + + try { + const fetchOptions = { + method: request.method, + headers: requestHeaders, + redirect: 'manual' + }; + + if (request.method !== 'GET' && request.method !== 'HEAD') { + fetchOptions.body = await request.arrayBuffer(); + } + + const response = await fetch(targetUrl, fetchOptions); + + const responseHeaders = new Headers(response.headers); + // Clean up some headers that might cause issues + responseHeaders.delete('content-encoding'); + + return new NextResponse(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders + }); + } catch (error) { + console.error("Proxy error:", error); + return new NextResponse("Failed to proxy: " + error.message, { status: 500 }); + } +} diff --git a/src/app/articles/article/[slug]/page.jsx b/src/app/articles/article/[slug]/page.jsx new file mode 100644 index 0000000..2a0774b --- /dev/null +++ b/src/app/articles/article/[slug]/page.jsx @@ -0,0 +1,6 @@ +import ArticleDetail from "../../../../components/ArticleDetail"; + +export default async function Page(props) { + const params = await props.params; + return ; +} diff --git a/src/app/articles/page.jsx b/src/app/articles/page.jsx new file mode 100644 index 0000000..ffeb6ad --- /dev/null +++ b/src/app/articles/page.jsx @@ -0,0 +1,6 @@ + +import Articles from "../../components/Articles"; + +export default function Page() { + return ; +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..992853b --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,8 @@ +body { + margin: 0; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} diff --git a/src/app/layout.jsx b/src/app/layout.jsx new file mode 100644 index 0000000..5dc22e8 --- /dev/null +++ b/src/app/layout.jsx @@ -0,0 +1,72 @@ +import React from "react"; +import logo from "../images/wknd-logo-dk.svg"; +import "../App.scss"; +import "./globals.css"; + +const NavMenu = () => { + return ( + + ); +}; + +const Header = () => { + return ( +
+ WKND Logo + + +
+ ); +}; + +const Footer = () => ( +
+ WKND Logo + + Copyright © 2023 Adobe. All rights reserved +
+); + +import UniversalEditorMeta from "./UniversalEditorMeta"; + +export default function RootLayout({ children }) { + return ( + + +