├── .gitignore ├── README.md ├── data-client ├── .env.local.example ├── .gitignore ├── .vscode │ └── launch.json ├── README.md ├── app │ ├── (authenticated) │ │ ├── auth-info │ │ │ └── page.js │ │ ├── layout.js │ │ └── site │ │ │ └── [id] │ │ │ ├── custom-code │ │ │ └── page.js │ │ │ ├── layout.js │ │ │ └── pages │ │ │ └── page.js │ ├── api │ │ ├── auth │ │ │ └── route.js │ │ ├── custom-code │ │ │ └── route.js │ │ ├── hello │ │ │ └── route.js │ │ ├── images │ │ │ └── route.js │ │ ├── logout │ │ │ └── route.js │ │ ├── page │ │ │ └── route.js │ │ ├── publish-site │ │ │ └── route.js │ │ └── sites │ │ │ └── route.js │ ├── favicon.ico │ ├── globals.css │ ├── images │ │ └── page.js │ ├── layout.js │ ├── login │ │ └── page.js │ ├── page.js │ └── webflow_redirect │ │ └── page.js ├── components │ ├── buttons.jsx │ ├── custom-code.jsx │ ├── index.jsx │ ├── page-list.jsx │ ├── page-row.jsx │ ├── sidebar.jsx │ ├── slide-over.jsx │ └── webflow-redirect.jsx ├── jsconfig.json ├── middleware.js ├── next.config.js ├── package-lock.json ├── package.json ├── public │ ├── logo.svg │ ├── next.svg │ ├── thirteen.svg │ └── vercel.svg ├── tailwind.config.js └── utils │ ├── custom_code_helper.js │ ├── openai_helper.js │ └── webflow_helper.js ├── designer-extension ├── .env.local.example ├── .gitignore ├── .vscode │ └── settings.json ├── README.md ├── config │ └── tailwind.config.js ├── next.config.js ├── package-lock.json ├── package.json ├── public │ ├── logo.svg │ ├── next.svg │ └── vercel.svg ├── src │ └── app │ │ ├── favicon.ico │ │ ├── globals.css │ │ ├── layout.tsx │ │ └── page.tsx ├── tsconfig.json └── webflow.json ├── package.json └── public ├── Large GIF (1184x674).gif ├── assets-panel.png ├── authenticated-xp.png ├── authentication-screen.png ├── designer-extension-details.png ├── edit-app.png ├── login-prompt.png └── open-designer-extension.png /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | package-lock.json 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | .pnpm-debug.log* 28 | 29 | # local env files 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # Ignore server files. 36 | server.* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Building a Hybrid App 2 | 3 | > [!WARNING] 4 | > This project is archived! To see an up-to-date example of a Hybrid App w/ Authentication, [check out this project](https://github.com/Webflow-Examples/Hybrid-App-Authentication/tree/main). 5 | 6 | This guide will walk you through the process of building a Hybrid App which allows a user to generate images from a Designer Extension using OpenAI’s Dall-E service and add them to a site’s assets. A Hybrid App uses the capabilities of both a [Data Client App](https://docs.developers.webflow.com/v2.0.0/docs/build-a-data-client) and a [Designer Extension](https://docs.developers.webflow.com/v2.0.0/docs/getting-started-1). Please read through our documentation to familiarize yourself with these concepts. 7 | 8 | ## What we're building 9 | 10 | ![hybrid app walktrhough](/public/Large%20GIF%20(1184x674).gif) 11 | 12 | We're diving into the capabilities of a Webflow App with an example that showcases Designer Extensions and Data Clients. 13 | 14 | You'll set up an app named "Devflow Party". Once installed, this app integrates a Designer Extension into the Webflow canvas, prompting users to generate images via OpenAI. Following this, it employs Webflow's REST APIs to automatically integrate the chosen images into a Webflow site. 15 | 16 | [🏃🏽‍♂️ Jump to the tutorial!](#walkthrough) 17 | 18 | ### App Structure 19 | 20 | While these two App building blocks work closely together, they are hosted and managed very differently in a Hybrid App. 21 | Hybrid Apps are split into two different areas of functionality: 22 | 23 | This example app is contained within two separate folders. 24 | 25 | - designer-extension 26 | - data-client 27 | 28 | ### Communicating between Designer Extensions and Data Clients 29 | 30 | ![diagram of how apps communicate](https://user-images.githubusercontent.com/32463/246034069-06bd9352-ca53-4442-973a-00890bf34490.png) 31 | 32 | This example implements a lightweight API to pass data between the Designer Extension and Data Client. The Data Client makes API calls to the Webflow Data API and serves that data to your Extension. While it's possible to call third-party APIs directly from the Designer Extension, we’ve decided to call APIs from the Data Client, so that we're keeping all secrets contained on our backend. 33 | 34 | ### Calling Webflow APIs 35 | > Adding Images to Site Assets 36 | 37 | Devflow Party, our Data Client app, spins up a server with helpers to handle OAuth as well as Webflow Data API requests. 38 | 39 | Once a user has used the Designer Extension to generate the desired images, our Data Client will need to add them to the site’s Assets. The user will select the images they wish to add, and the Devflow Extension will send a request to the Devflow service, telling the service to upload the assets. 40 | 41 | Once the service receives an upload request, it will temporarily cache the image to disk, then upload the image to S3 and apply it to the site’s Assets list. 42 | 43 | # Walkthrough 44 | 45 | ### Prerequisites 46 | 47 | - [x] A Webflow workspace and site for development and testing 48 | - [x] A Webflow App with the [Data Client](https://docs.developers.webflow.com/docs/data-clients) and [Designer Extension](https://docs.developers.webflow.com/docs/designer-extensions) Building Blocks enabled 49 | - [x] The ID, Secret, and Client URL from your Webflow App 50 | - [x] A free [OpenAI](https://openai.com) account with an [API Key](https://platform.openai.com/account/api-keys) 51 | - [x] Node.js 16.20 or later 52 | - [x] Familiarity building single-page applications 53 | 54 | ## Step 1: Setting up your development environment 55 | 56 | First, install the Webflow CLI: 57 | `npm i @webflow/webflow-cli -g` 58 | 59 | We're going to use GitHub codespaces as our development environment, to tak advantage of it's built in port forwarding, but please feel free to follow along developing locally. 60 | 61 | 1. **Clone the example repo to your development environment.** Navigate the [Hybrid-App-Example repo](https://github.com/Webflow-Examples/Hybrid-App-Example/tree/main). Select the `code` button and open the repo in a GitHub codespace or, if you'd like, clone the repo to your local environment. 62 | 63 | 2. **Find your redirect URI.** If you're using GitHub Codespaces, you'll want to get the URI of the forwarded port to use as the redirect URL for our Data Client app. To do this, copy the Github Codespaces URL in the address bar of your browser. Then add a `-3001.app` to the link as shown: 64 | - Orignial URL: `https://curly-train-5rg69pjrrp9f4v6v.github.dev` 65 | - Modified URL: `https://curly-train-5rg69pjrrp9f4v6v-3001.app.github.dev` 66 | 67 | > [!NOTE] 68 | > If you're hosting your app locally, copy the URI for your port. Please note, redirect URIs for data clients are required to use `https`. You can use tools like NGROK to expose your local server over `https` or setup your own security certificate for local development. 69 | 70 | 3. **Modify your App's redirect URI.** Navigate to the "App Development" section of your Workspace Settings and select the `Edit App` button. Select the "Building Blocks" panel. In the "Data Client" section, replace the redirect URI with the your local development URI. Click the `Save app` button. 71 | 72 | ![Paste your redirect URI in the Yellow Box](/public/edit-app.png) 73 | 74 | 4. **Configure your environment variables.** In your IDE, copy the `.env.local.example` file and name it `.env.local`. Replace the variables with your own. 75 | 76 | > [!NOTE] 77 | > There are 2 .env.local.example files. You must update the environment variables for both. 78 | > "NEXT_PUBLIC_BACKEND_URL" should be set to the public URL of your Data Client. 79 | 80 | 5. **Run your Hybrid App!** Now it's time to run your App! Open your terminal and navigate to your project folder. Enter the following command: `npm run dev`. This will install the dependencies for your Data Client and Designer Extension, and then run them on their default ports `3001` and `1337`, respectively. 81 | 82 | ## Step 2: Authorize your Data Client 83 | 84 | 1. **Open the Data Client in your browser.** Open the link to your forwarded port. You should see a login screen. Click the `login` button. 85 | ![Screenshot of the login experience for the DevFlow Party App](/public/login-prompt.png) 86 | 87 | > [!IMPORTANT] 88 | > If you're using Github Codespaces, your ports will already be forwarded. Be sure to set your port visibility to public, so that you'll be able to see your App running in Webflow. 89 | 90 | 2. **Authorize App to your development workspace.** Because we're working with a Data Client that will access Webflow's REST API we'll need to authorize our App to make changes on behalf of a user. You are only able to authorize an App to sites within a single workspace, or one completee workspace. Be sure to select the Workspace where you created your App. 91 | ![Screenshot of the Authorization Screen](/public/authentication-screen.png) 92 | Once authorized, you'll be redirected to a success screen in your Data Client. 93 | 94 | ## Step 3: Open your Designer Extension in Webflow 95 | 96 | 1. **Open your test Site in your Development Workspace.** Navigate to your Development Workspace and open a test site. 97 | 98 | 2. **Select your App from the Apps Panel.** In the left toolbar, select the Apps panel. Navigate to your App, and select it to see it's details. 99 | 100 | 3. **Replace your `Development URL.`** Similar to how we changed our redirect URI to the forwarded URL for port `3001`, change your Development URL to point to the forwarded URL for port `1337.` 101 | 102 | ![designer extension details](/public/designer-extension-details.png) 103 | 104 | 4. **Launch your App in the Designer! 🚀** Select the `Launch development App` button. 105 | 106 | ![launched app!](/public/open-designer-extension.png) 107 | 108 | ## Step 4: Generate images via the Designer Extension. 109 | 110 | 1. **Enter your image prompt.** Our App is designed to generate images from a prompt using OpenAI's DALL-E API. To generate images, enter a prompt in the input section. 111 | 112 | 2. **Select your image size and quantity.** 113 | 114 | 3. **Click the `Generate` button to create new images.** We're sending our prompt to our DevFlow Party App, which then makes an authenticated API call to DALL-E to generate the images. DevFlow party then saves the images to our sites's Assets via the REST API. 115 | 116 | > [!Important] 117 | > **Refresh your browser!** Until we make some changes to the designer, you'll need to refersh your browser to see changes from the API take effect. 🙇🏾‍♀️ 118 | 119 | 4. **Open your Apps pane to see your new Images.** Refresh your browser, and then navigate to the Assets panel in the left toolbar. You'll now see your images, which are ready to use in the designer! 120 | 121 | ![assets panel](/public/assets-panel.png) 122 | 123 | ## Step 5. Pat yourself on the back! 124 | 125 | You did it! 126 | -------------------------------------------------------------------------------- /data-client/.env.local.example: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY= 2 | # The following variables below are provided when creating an app in Webflow. 3 | CLIENT_URL= 4 | CLIENT_SECRET= 5 | CLIENT_ID= -------------------------------------------------------------------------------- /data-client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # Ignore server files. 35 | server.* -------------------------------------------------------------------------------- /data-client/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // https://nextjs.org/docs/advanced-features/debugging 2 | // This allows us to debug the server and client side code in the same debug session with VS Code. 3 | { 4 | "version": "0.2.0", 5 | "configurations": [ 6 | { 7 | "name": "Next.js: debug server-side", 8 | "type": "node-terminal", 9 | "request": "launch", 10 | "command": "npm run dev" 11 | }, 12 | { 13 | "name": "Next.js: debug client-side", 14 | "type": "chrome", 15 | "request": "launch", 16 | "url": "https://0.0.0.0:3000" 17 | }, 18 | // { 19 | // "name": "Next.js: debug full stack", 20 | // "type": "node-terminal", 21 | // "request": "launch", 22 | // "command": "npm run dev", 23 | // "serverReadyAction": { 24 | // "pattern": "started server on .+, url: (https?://.+)", 25 | // "uriFormat": "%s", 26 | // "action": "debugWithChrome" 27 | // } 28 | // } 29 | ] 30 | } -------------------------------------------------------------------------------- /data-client/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | ``` 14 | 15 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 16 | 17 | You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file. 18 | 19 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`. 20 | 21 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 22 | 23 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 24 | 25 | ## Learn More 26 | 27 | To learn more about Next.js, take a look at the following resources: 28 | 29 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 30 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 31 | 32 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 33 | 34 | ## Deploy on Vercel 35 | 36 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 37 | 38 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 39 | 40 | 41 | ## For development purposes 42 | For Alpha testing use... 43 | "webflow-api": "webflow/js-webflow-api#alpha_tests" 44 | When making changes locally... 45 | "webflow-api": "file:../js-webflow-api" -------------------------------------------------------------------------------- /data-client/app/(authenticated)/auth-info/page.js: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | 3 | import { getAPIClient, getAuthUrl } from '@/utils/webflow_helper' 4 | import { LogoutButton, ReauthorizeButton } from '@/components/buttons'; 5 | 6 | export default async function Home() { 7 | const cookieStore = cookies(); 8 | const webflowAuth = cookieStore.get('webflow_auth').value; 9 | const webflowAPI = getAPIClient(webflowAuth); 10 | // const [user, info] = await Promise.all([webflowAPI.authenticatedUser(), webflowAPI.info()]); 11 | 12 | // const { rateLimit, workspaces, sites, application } = info; 13 | // const authLevel = workspaces.length > 0 ? 'Workspace' : sites.length > 0 ? 'Site' : 'User'; 14 | // const { name, description, homepage } = application; 15 | // const { firstName, lastName, email } = user.user; // this feels like a bug somewhere. 16 | const url = getAuthUrl(); 17 | return ( 18 |
19 | {/* 20 | 21 | */} 22 |
23 | 24 | 25 |
26 |
27 | ) 28 | } 29 | 30 | function UserInfo({name, email}) { 31 | return ( 32 |
33 |
34 |

User Info

35 |
36 |
37 |
38 |
39 |
Name
40 |
{name}
41 |
42 |
43 |
Email
44 |
{email}
45 |
46 |
47 |
48 |
49 | ) 50 | } 51 | 52 | function AppInfo({name, description, homepage}) { 53 | return ( 54 |
55 |
56 |

App Info

57 |
58 |
59 |
60 |
61 |
Name
62 |
{name}
63 |
64 |
65 |
Homepage
66 |
{homepage}
67 |
68 |
69 |
Description
70 |
71 | {description} 72 |
73 |
74 |
75 |
76 |
77 | ) 78 | } 79 | 80 | function Stats({authLevel, rateLimit}) { 81 | const authLevelElement = ( 82 |
83 |
Authorization Level
84 |
85 |
86 | {authLevel} 87 |
88 |
89 |
90 | ); 91 | 92 | const rateLimitElement = ( 93 |
94 |
Rate Limit
95 |
96 |
97 | {rateLimit} 98 | requests remaining 99 |
100 |
101 |
102 | ); 103 | 104 | return ( 105 |
106 |
107 | {authLevelElement} 108 | {rateLimitElement} 109 |
110 |
111 | ) 112 | } 113 | -------------------------------------------------------------------------------- /data-client/app/(authenticated)/layout.js: -------------------------------------------------------------------------------- 1 | 2 | import { cookies } from 'next/headers'; 3 | import Sidebar from '@/components/sidebar'; 4 | import { getAPIClient } from '@/utils/webflow_helper'; 5 | 6 | export default async function AuthenticatedLayout({ children }) { 7 | const cookieStore = cookies(); 8 | const webflowAuth = cookieStore.get('webflow_auth').value; 9 | const webflowAPI = getAPIClient(webflowAuth); 10 | const sites = await webflowAPI.sites(); 11 | // We can't pass an object that has non-serializable values such as functions, promises, or classes, 12 | // which can cause issues when trying to rehydrate the component on the client. e.g. the Sidebar. 13 | // Below we serialize and then deserialize the data, effectively creating a new plain object. 14 | // TODO: Find a better way to do this. 15 | const plainSites = JSON.parse(JSON.stringify(sites)) || []; 16 | return ( 17 | 18 | {children} 19 | 20 | ) 21 | } -------------------------------------------------------------------------------- /data-client/app/(authenticated)/site/[id]/custom-code/page.js: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import { getAPIClient } from '@/utils/webflow_helper' 3 | import CustomCode from '@/components/custom-code'; 4 | 5 | export default async function CustomCodeTab({ params: { id: siteId } }) { 6 | const cookieStore = cookies(); 7 | const webflowAuth = cookieStore.get('webflow_auth').value; 8 | const webflowAPI = getAPIClient(webflowAuth); 9 | let savedCode = null; 10 | try { 11 | savedCode = await webflowAPI.getCustomCode({siteId}); 12 | } catch(e) {} 13 | 14 | return ; 15 | } 16 | -------------------------------------------------------------------------------- /data-client/app/(authenticated)/site/[id]/layout.js: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import { getAPIClient } from '@/utils/webflow_helper' 3 | import Image from 'next/image'; 4 | import { PublishPopoverMenu, Tab } from '@/components'; 5 | import { timeAgo, getTitleTimestamp, placeholderImage } from '@/utils'; 6 | 7 | export default async function SiteLayout({ params: { id: siteId }, children }) { 8 | // TODO: Stop duplicating this code 9 | const cookieStore = cookies(); 10 | const webflowAuth = cookieStore.get('webflow_auth').value; 11 | const webflowAPIv2 = getAPIClient(webflowAuth); 12 | const webflowAPIv1 = getAPIClient(webflowAuth, false); 13 | const [site, domains] = await Promise.all([webflowAPIv2.site({siteId}), webflowAPIv1.domains({siteId})]); 14 | 15 | const tabs = [ 16 | 'pages', 17 | 'custom-code', 18 | 'webhooks', 19 | 'cms', 20 | 'ecommerce', 21 | 'memberships', 22 | ]; 23 | 24 | const getSiteInfo = () => { 25 | return ( 26 |
27 |
28 |
29 |

Site Info

30 | 0 ? domains : [{name: `${site.shortName}.webflow.io`}]} /> 31 |
32 |
33 |
34 |
35 |
36 | {site.previewUrl ? ( 37 | 47 | ) : ( 48 |
61 | No preview found 62 |
63 | )} 64 |
65 |
66 |
67 |
68 |
Name
69 |
{site.displayName}
70 |
Short Name
71 |
{site.shortName}
72 |
Time Zone
73 |
{site.timeZone}
74 |
Created
75 |
{timeAgo(site.createdOn)}
76 |
Last Updated
77 |
{timeAgo(site.lastUpdated)}
78 |
Last Published
79 |
{site.lastPublished ? timeAgo(site.lastPublished) : "N/A"}
80 |
81 |
82 |
83 |
84 |
85 |
86 | ) 87 | } 88 | 89 | const getSiteTabs = () => { 90 | return ( 91 |
92 |
93 |
94 | 99 |
100 |
101 |
102 | {children} 103 |
104 |
105 | ) 106 | } 107 | 108 | return ( 109 |
110 | {getSiteInfo()} 111 | {getSiteTabs()} 112 |
113 | ) 114 | } 115 | -------------------------------------------------------------------------------- /data-client/app/(authenticated)/site/[id]/pages/page.js: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import { getAPIClient } from '@/utils/webflow_helper' 3 | import PageList from '@/components/page-list'; 4 | 5 | export default async function PageTab({ params: { id: siteId } }){ 6 | const cookieStore = cookies(); 7 | const webflowAuth = cookieStore.get('webflow_auth').value; 8 | const webflowAPI = getAPIClient(webflowAuth); 9 | const pages = await webflowAPI.pages({siteId}); 10 | const plainPages = JSON.parse(JSON.stringify(pages)) || []; 11 | 12 | // TODO: Add pagination component: https://tailwindui.com/components/application-ui/navigation/pagination#component-f9a9347de5384a492c79c34cf6ce3ccf 13 | return ( 14 |
15 | 16 |
17 | ) 18 | } -------------------------------------------------------------------------------- /data-client/app/api/auth/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { cookies } from 'next/headers'; 3 | import { getAPIClient, getAuthUrl } from '@/utils/webflow_helper'; 4 | 5 | export async function GET(req) { 6 | const origin = req.headers.get('origin'); 7 | let body; 8 | const cookieStore = cookies(); 9 | const webflowAuthCookie = cookieStore.get('webflow_auth'); 10 | const webflowAuth = webflowAuthCookie ? webflowAuthCookie.value : undefined; 11 | 12 | if (!webflowAuth) { 13 | body = {msg: 'Not authenticated', authUrl: getAuthUrl()}; 14 | } else { 15 | const webflowAPI = getAPIClient(webflowAuth); 16 | res = await webflowAPI.authenticatedUser(); 17 | body = {user: res}; 18 | } 19 | 20 | return NextResponse.json(body, { 21 | headers: { 22 | 'Access-Control-Allow-Origin': origin, 23 | 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 24 | 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 25 | }, 26 | }); 27 | } -------------------------------------------------------------------------------- /data-client/app/api/custom-code/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | import { cookies } from 'next/headers'; 3 | import { getAPIClient } from '@/utils/webflow_helper'; 4 | 5 | export async function PUT(request) { 6 | const cookieStore = cookies(); 7 | const webflowAuth = cookieStore.get('webflow_auth').value; 8 | const webflowAPI = getAPIClient(webflowAuth); 9 | const {siteId, code} = await request.json(); 10 | try { 11 | const response = await webflowAPI.writeCustomCode({ siteId, code }); 12 | return NextResponse.json({ data: response }); 13 | } catch (error) { 14 | return NextResponse.json({ error: error.message }); 15 | } 16 | } 17 | 18 | export async function DELETE(request) { 19 | const cookieStore = cookies(); 20 | const webflowAuth = cookieStore.get('webflow_auth').value; 21 | const webflowAPI = getAPIClient(webflowAuth); 22 | const {siteId} = await request.json(); 23 | try { 24 | const deleted = await webflowAPI.deleteCustomCode({ siteId }); 25 | return NextResponse.json(deleted); 26 | } catch (error) { 27 | return NextResponse.json({ error: error.message }); 28 | } 29 | } -------------------------------------------------------------------------------- /data-client/app/api/hello/route.js: -------------------------------------------------------------------------------- 1 | export async function GET(request) { 2 | return new Response('Hello, Next.js!') 3 | } 4 | -------------------------------------------------------------------------------- /data-client/app/api/images/route.js: -------------------------------------------------------------------------------- 1 | import { generateImages } from '@/utils/openai_helper'; 2 | import { getAPIClient } from '@/utils/webflow_helper'; 3 | import { NextResponse } from 'next/server'; 4 | import { cookies } from 'next/headers'; 5 | import crypto from 'crypto'; 6 | import { promises as fs, readFileSync } from "fs"; 7 | import { join } from 'path'; 8 | import os from 'os'; 9 | import { pipeline } from 'stream'; 10 | import { promisify } from 'util'; 11 | import FormData from 'form-data'; 12 | import fetch from 'isomorphic-fetch'; 13 | 14 | const pipelineAsync = promisify(pipeline); 15 | 16 | /** 17 | * An asynchronous function that handles POST requests. 18 | * 19 | * This function performs the following operations: 20 | * 1. Checks if the 'webflow_auth' cookie is present. If not, it returns a JSON response indicating that the user is not authenticated. 21 | * 2. Retrieves the 'webflow_auth' cookie value and initializes the Webflow API client. 22 | * 3. Extracts the 'imageURL' and 'siteId' from the request body. If either of these is missing, it returns a JSON response indicating the missing parameters. 23 | * 4. Downloads the file from the provided 'imageURL', generates its MD5 hash, and stores it in a temporary directory. 24 | * 5. Makes a POST request to the Webflow API to generate an AWS S3 presigned post, using the 'siteId', file name, and file hash. 25 | * 6. Uploads the file to AWS S3 using the details provided in the presigned post. 26 | * 7. If the upload is successful, it deletes the file from the temporary directory and returns a JSON response indicating success and the status of the upload response. 27 | * 8. If any error occurs during the process, it returns a JSON response with the error message. 28 | * 29 | * @param {Object} request - The request object, expected to contain 'imageURL' and 'siteId' in its JSON body. 30 | * @returns {Object} A NextResponse object containing a JSON response. The response contains a status of the operation and, in case of an error, an error message. 31 | * 32 | * @throws Will throw an error if the 'imageURL' or 'siteId' is missing in the request, if there's an HTTP error when fetching the image, or if the upload to Webflow fails. 33 | */ 34 | export async function POST(request) { 35 | const { imageURL, siteId, auth } = await request.json(); 36 | if (!imageURL || !siteId || !auth) { 37 | return NextResponse.json({ error: 'Missing imageURL or siteId or auth' }, { 38 | headers: { 39 | 'Access-Control-Allow-Origin': '*', 40 | 'Access-Control-Allow-Methods': 'POST', 41 | 'Access-Control-Allow-Headers': 'Content-Type', 42 | }, 43 | }); 44 | } 45 | const webflowAPI = getAPIClient(auth); 46 | 47 | try { 48 | // Download the file 49 | // TODO: Identify a way to stream the File to AWS without saving it to disk 50 | const splitURL = imageURL.split('?')[0].split('/'); 51 | const fileName = splitURL[splitURL.length - 1]; 52 | const filePath = join(os.tmpdir(), fileName); 53 | const res = await fetch(imageURL); 54 | const buffer = await res.arrayBuffer(); 55 | await fs.writeFile(filePath, Buffer.from(buffer)); 56 | 57 | // Generate the md5 file hash and store it in a variable 58 | // TODO: Optimize this so that fetch is only called once. 59 | const res2 = await fetch(imageURL); 60 | if (!res2.ok) { 61 | throw new Error(`HTTP error! status: ${res2.status}`); 62 | } 63 | const fileStream = res2.body; 64 | const hashStream = crypto.createHash('md5'); 65 | await pipelineAsync( 66 | fileStream, 67 | hashStream 68 | ); 69 | const fileHash = hashStream.digest('hex'); 70 | 71 | // Generate an AWS s3 Presigned Post 72 | const response = await webflowAPI.post(`/sites/${siteId}/assets`, {fileName, fileHash}); 73 | const { uploadDetails } = response.data; 74 | 75 | // Upload the file to AWS 76 | const form = new FormData(); 77 | Object.entries(uploadDetails).forEach(([field, value]) => { 78 | if (value === null) { 79 | // TODO: Track down why X-Amz-Security-Token is showing a null value. 80 | return; 81 | } 82 | form.append(field, value); 83 | }); 84 | form.append("file", readFileSync(filePath)); 85 | // TODO: This URL should be returned from the API 86 | const uploadUrl2 = 'https://webflow-prod-assets.s3.amazonaws.com/'; 87 | const uploadResponse = await fetch(uploadUrl2, { 88 | method: 'POST', 89 | body: form, 90 | }); 91 | if (!uploadResponse.ok) { 92 | throw new Error(`Upload to Webflow failed! status: ${uploadResponse.status}\n${uploadResponse.text()}}`); 93 | } 94 | await fs.unlink(filePath); 95 | return NextResponse.json({ ok: true, status: uploadResponse.status }, { 96 | headers: { 97 | 'Access-Control-Allow-Origin': '*', 98 | 'Access-Control-Allow-Methods': 'POST', 99 | 'Access-Control-Allow-Headers': 'Content-Type', 100 | }, 101 | }); 102 | } catch (error) { 103 | return NextResponse.json({ error: error.message }); 104 | } 105 | } 106 | 107 | // Send query to DALL-E 108 | export async function GET(request) { 109 | const { searchParams } = new URL(request.url); 110 | if (!searchParams.get('auth')) { 111 | return NextResponse.json({ok: false, error: 'Not authenticated'}, { 112 | headers: { 113 | 'Access-Control-Allow-Origin': '*', 114 | 'Access-Control-Allow-Methods': 'GET', 115 | 'Access-Control-Allow-Headers': 'Content-Type', 116 | }, 117 | }); 118 | } 119 | 120 | const prompt = searchParams.get('prompt'); 121 | const n = parseInt(searchParams.get('n')); 122 | const size = parseInt(searchParams.get('size')); 123 | try { 124 | const response = await generateImages(prompt, n, size); 125 | return NextResponse.json({ images: response.data.data }, { 126 | headers: { 127 | 'Access-Control-Allow-Origin': '*', 128 | 'Access-Control-Allow-Methods': 'GET', 129 | 'Access-Control-Allow-Headers': 'Content-Type', 130 | }, 131 | }); 132 | } catch (error) { 133 | return NextResponse.json({ error: error.message }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /data-client/app/api/logout/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | import { cookies } from 'next/headers'; 3 | import { getAPIClient } from '@/utils/webflow_helper'; 4 | 5 | const { CLIENT_ID, CLIENT_SECRET } = process.env; 6 | 7 | export async function POST(request) { 8 | const cookieStore = cookies(); 9 | if (!cookieStore.has('webflow_auth')) { 10 | return NextResponse.json({ok: true}); 11 | } 12 | const webflowAuth = cookieStore.get('webflow_auth').value; 13 | const webflowAPI = getAPIClient(webflowAuth, false); 14 | const res = await webflowAPI.revokeToken({access_token: webflowAuth, client_id: CLIENT_ID, client_secret: CLIENT_SECRET }); 15 | const response = NextResponse.json(res); 16 | response.cookies.delete('webflow_auth') 17 | return response; 18 | } -------------------------------------------------------------------------------- /data-client/app/api/page/route.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | import { cookies } from 'next/headers'; 4 | import { getAPIClient } from '@/utils/webflow_helper'; 5 | import { generateMetadataSuggestionsAndAnalysis } from '@/utils/openai_helper'; 6 | 7 | export async function GET(request) { 8 | const { searchParams } = new URL(request.url); 9 | const pageId = searchParams.get('id'); 10 | const cookieStore = cookies(); 11 | const webflowAuth = cookieStore.get('webflow_auth').value; 12 | const webflowAPI = getAPIClient(webflowAuth); 13 | const { 14 | seo, 15 | openGraph, 16 | ...pageDetails 17 | } = await webflowAPI.page({pageId}); 18 | 19 | let res; 20 | try { 21 | res = await generateMetadataSuggestionsAndAnalysis( 22 | seo.title, 23 | seo.description, 24 | openGraph.title, 25 | openGraph.description, 26 | ); 27 | } catch (error) { 28 | console.error('Error generating metadata suggestions and analysis:', error); 29 | res = { 30 | seoTitleAnalysis: null, 31 | seoTitleSuggestions: null, 32 | seoDescriptionAnalysis: null, 33 | seoDescriptionSuggestions: null, 34 | openGraphTitleAnalysis: null, 35 | openGraphTitleSuggestions: null, 36 | openGraphDescriptionAnalysis: null, 37 | openGraphDescriptionSuggestions: null, 38 | }; 39 | } 40 | 41 | const pageData = { 42 | ...pageDetails, 43 | seo: { 44 | title: seo.title, 45 | titleAnalysis: res.seoTitleAnalysis, 46 | titleSuggestions: res.seoTitleSuggestions, 47 | description: seo.description, 48 | descriptionAnalysis: res.seoDescriptionAnalysis, 49 | descriptionSuggestions: res.seoDescriptionSuggestions, 50 | }, 51 | openGraph: { 52 | title: openGraph.title, 53 | titleCopied: openGraph.titleCopied, 54 | titleAnalysis: res.openGraphTitleAnalysis, 55 | titleSuggestions: res.openGraphTitleSuggestions, 56 | description: openGraph.description, 57 | descriptionCopied: openGraph.descriptionCopied, 58 | descriptionAnalysis: res.openGraphDescriptionAnalysis, 59 | descriptionSuggestions: res.openGraphDescriptionSuggestions, 60 | }, 61 | }; 62 | 63 | return Response.json(pageData); 64 | } 65 | -------------------------------------------------------------------------------- /data-client/app/api/publish-site/route.js: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers'; 2 | import { getAPIClient } from '@/utils/webflow_helper'; 3 | 4 | export async function POST(request) { 5 | const cookieStore = cookies(); 6 | const webflowAuth = cookieStore.get('webflow_auth').value; 7 | const webflowAPI = getAPIClient(webflowAuth, false); 8 | 9 | const {siteId, domains} = await request.json(); 10 | 11 | try { 12 | const response = await webflowAPI.publishSite({ siteId, domains}); 13 | return Response.json(response); 14 | } catch (error) { 15 | if (error.message.includes('429')) { 16 | return Response.json({ error: 'You\'ve been recently published your site. Please wait 1 minute before publishing again.' }); 17 | } 18 | return Response.json({ error: error.message }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /data-client/app/api/sites/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { getAPIClient } from '@/utils/webflow_helper'; 3 | 4 | export async function GET(request) { 5 | const webflowAuth = request.nextUrl.searchParams.get('auth'); 6 | const webflowAPI = getAPIClient(webflowAuth); 7 | const sites = await webflowAPI.sites(); 8 | 9 | return NextResponse.json({ sites }, { 10 | headers: { 11 | 'Access-Control-Allow-Origin': "*", 12 | 'Access-Control-Allow-Methods': 'GET', 13 | 'Access-Control-Allow-Headers': 'Content-Type', 14 | }, 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /data-client/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Webflow-Examples/hybrid-app-example/b792bdf46b5b92e48a902505952e6fcb63d40bfa/data-client/app/favicon.ico -------------------------------------------------------------------------------- /data-client/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer components { 6 | .card { 7 | transform: rotateY(180deg); 8 | transform-style: preserve-3d; 9 | } 10 | .card-back { 11 | transform: rotateY(180deg); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /data-client/app/images/page.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useState, useEffect } from "react"; 3 | 4 | export default function SiteSelection() { 5 | const [sites, setSites] = useState([]); 6 | const [selectedSite, setSelectedSite] = useState(null); 7 | const [loading, setLoading] = useState(true); 8 | 9 | useEffect(() => { 10 | fetchSites(); 11 | }, []); 12 | 13 | const fetchSites = async () => { 14 | try { 15 | const response = await fetch('/api/sites', { 16 | method: 'GET', 17 | headers: { 18 | 'Content-Type': 'application/json' 19 | } 20 | }); 21 | 22 | if (!response.ok) { 23 | throw new Error(`HTTP error! status: ${response.status}`); 24 | } 25 | 26 | const data = await response.json(); 27 | setSites(data.sites); 28 | } catch (error) { 29 | console.error('Error:', error); 30 | } finally { 31 | setLoading(false); 32 | } 33 | }; 34 | 35 | const handleSiteSelection = (site) => { 36 | debugger 37 | setSelectedSite(site); 38 | }; 39 | 40 | if (selectedSite) { 41 | return ; 42 | } 43 | 44 | return ( 45 |
48 |
49 |
50 |

51 | Select a Site 52 |

53 | {loading &&

Loading sites...

} 54 | {!loading && sites.length === 0 &&

No sites available

} 55 | {!loading && sites.map((site, index) => ( 56 | // This is horrible. Display a dropdown instead. 57 | 64 | ))} 65 |
66 |
67 |
68 | ); 69 | } 70 | 71 | function UserInput({ selectedSite }) { 72 | const [prompt, setPrompt] = useState(""); 73 | const [size, setSize] = useState(256); 74 | const [n, setN] = useState(1); 75 | const [images, setImages] = useState([]); 76 | 77 | const resetUserInput = () => { 78 | setPrompt(""); 79 | setSize(256); 80 | setN(1); 81 | setImages([]); 82 | }; 83 | 84 | const generateImages = async () => { 85 | const params = new URLSearchParams({ prompt: prompt, n: n, size: size }); 86 | 87 | try { 88 | const response = await fetch(`/api/images?${params.toString()}`, { 89 | method: 'GET', 90 | headers: { 91 | 'Content-Type': 'application/json' 92 | } 93 | }); 94 | 95 | if (!response.ok) { 96 | throw new Error(`HTTP error! status: ${response.status}`); 97 | } 98 | 99 | const data = await response.json(); 100 | 101 | if (data.images) { 102 | setImages(data.images); 103 | } 104 | } catch (error) { 105 | console.error('Error:', error); 106 | } 107 | }; 108 | 109 | 110 | if (images.length > 0) { 111 | return ; 112 | } 113 | 114 | return ( 115 |
118 |
119 |
120 |

121 | Describe your desired image 122 |

123 |