├── .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 | .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 | 
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 | 
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 | 
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 | 
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 | 
103 |
104 | 4. **Launch your App in the Designer! 🚀** Select the `Launch development App` button.
105 |
106 | 
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 | 
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 |
95 | {tabs.map((tab) => (
96 |
97 | ))}
98 |
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 |
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 |
handleSiteSelection(site)}
60 | className="mb-2 rounded-md py-2 px-4 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 cursor-pointer border-gray-700 w-1/2"
61 | >
62 | {site.displayName}
63 |
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 |
130 |
131 |
Image size: {size}
132 |
133 | {[256, 512, 1024].map((btnSize, i) => (
134 | setSize(btnSize)}
140 | >
141 | {btnSize}
142 |
143 | ))}
144 |
145 |
146 |
147 |
Number of images: {n}
148 |
setN(parseInt(e.target.value))}
155 | />
156 |
157 |
158 |
162 | Generate
163 |
164 |
165 |
166 |
167 | );
168 | }
169 |
170 | function ImageOptions({ images, resetUserInput, site }) {
171 | const [page, setPage] = useState(1);
172 | const [addedImages, setAddedImages] = useState([]);
173 | const [selectedImage, setSelectedImage] = useState(null);
174 | const imagesPerPage = 4;
175 | const pageLength = Math.ceil(images.length / 4);
176 | const [uploading, setUploading] = useState(false);
177 | const [error, setError] = useState(null);
178 |
179 | const resetImageOptions = () => {
180 | setPage(1);
181 | setAddedImages([]);
182 | setSelectedImage(null);
183 | setUploading(false);
184 | setError(null);
185 | };
186 |
187 | const navigateToImagePreview = (imgIndex, url) => {
188 | setSelectedImage({index: imgIndex, url: url});
189 | };
190 |
191 | const handleAddImage = async () => {
192 | if (selectedImage && !addedImages.includes(selectedImage.index)) {
193 | setUploading(true);
194 | try {
195 | const response = await postImage(selectedImage.url);
196 | if (response.error) {
197 | throw new Error(response.error);
198 | }
199 | setAddedImages([...addedImages, selectedImage.index]);
200 | setSelectedImage(null);
201 | setError(null);
202 | } catch (error) {
203 | setError(error.message);
204 | } finally {
205 | setUploading(false);
206 | }
207 | }
208 | };
209 |
210 | async function postImage(imageURL) {
211 | const response = await fetch('/api/images', {
212 | method: 'POST',
213 | headers: {
214 | 'Content-Type': 'application/json',
215 | },
216 | body: JSON.stringify({ imageURL, siteId: site.id }),
217 | });
218 |
219 | if (!response.ok) {
220 | throw new Error(`HTTP error! status: ${response.status}`);
221 | }
222 |
223 | const data = await response.json();
224 | return data;
225 | }
226 |
227 | const currentPageImages = images.slice((page - 1) * imagesPerPage, page * imagesPerPage);
228 |
229 | return (
230 |
233 | {!selectedImage ? (
234 |
235 |
236 |
237 | Select Your Custom Images
238 |
239 |
240 |
241 | {currentPageImages.map((img, i) => (
242 |
243 |
{
248 | const index = i + 1 + (page - 1) * 4;
249 | navigateToImagePreview(index, img.url);
250 | }}
251 | />
252 | {addedImages.includes(i + 1 + (page - 1) * 4) && (
253 |
254 | Added!
255 |
256 | )}
257 |
258 | ))}
259 |
260 |
261 | setPage(page - 1)}>
262 | Prev
263 |
264 | Page {page} of {pageLength}
265 | setPage(page + 1)}>
266 | Next
267 |
268 |
269 |
270 | ) : (
271 |
272 |
277 |
278 |
setSelectedImage(null)}>Go Back
279 |
Add
280 | {error &&
Error: {error}
}
281 | {uploading &&
Uploading to {site.displayName}...
}
282 |
283 |
284 | )}
285 |
286 | {resetImageOptions(); resetUserInput();}}
289 | >
290 | Start Over
291 |
292 |
293 |
294 | );
295 | }
--------------------------------------------------------------------------------
/data-client/app/layout.js:
--------------------------------------------------------------------------------
1 | import './globals.css'
2 |
3 | export const metadata = {
4 | title: 'DevFlow Party',
5 | description: 'A simple App to explore the capabilities of the Webflow API',
6 | }
7 |
8 | export default function RootLayout({ children }) {
9 | return (
10 |
11 | {children}
12 |
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/data-client/app/login/page.js:
--------------------------------------------------------------------------------
1 | import LockClosedIcon from '@heroicons/react/24/solid/LockClosedIcon';
2 | import { getAuthUrl } from '@/utils/webflow_helper';
3 | import Image from 'next/image';
4 | import logo from '@/public/logo.svg';
5 | import Link from 'next/link';
6 |
7 | export default function Login() {
8 | return (
9 |
10 |
11 |
12 |
17 |
18 | Login with your Webflow account
19 |
20 |
21 | This is a demo app that explores the possibilities of what you could build with the Webflow API.
22 |
23 |
24 |
25 |
29 |
30 |
31 |
32 | Login
33 |
34 |
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/data-client/app/page.js:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import React, { useState, useEffect } from 'react';
3 | import { ClipboardDocumentIcon } from '@heroicons/react/24/outline';
4 |
5 | const Splash = () => {
6 | const [isCopied, setIsCopied] = useState(false);
7 | const [token, setToken] = useState(null);
8 |
9 | useEffect(() => {
10 | const cookies = document.cookie.split(';');
11 | const tokenCookie = cookies.find(cookie => cookie.includes('webflow_auth='));
12 | const token = tokenCookie ? tokenCookie.split('=')[1] : null;
13 | setToken(token);
14 | }, []);
15 |
16 | const copyToClipboard = () => {
17 | if (token) {
18 | navigator.clipboard.writeText(token);
19 | setIsCopied(true);
20 | setTimeout(() => setIsCopied(false), 2000);
21 | }
22 | };
23 |
24 | return (
25 |
26 |
27 |
28 |
31 |
32 |
Welcome to Devflow.party!
33 |
You have successfully authenticated.
34 | {token ? (
35 | <>
36 |
37 | {isCopied ? 'Copied!' : 'Copy Your Token '}
38 |
39 |
40 |
41 |
42 |
43 | {token}
44 |
45 | >
46 | ) : (
47 |
Something went wrong. No auth token was found.
48 | )
49 | }
50 |
51 |
52 |
Open Webflow to get started with Devflow.party's Image Generator
53 |
54 |
55 |
56 |
57 | );
58 | };
59 |
60 | export default Splash;
61 |
62 |
--------------------------------------------------------------------------------
/data-client/app/webflow_redirect/page.js:
--------------------------------------------------------------------------------
1 | import WebflowRedirect from "@/components/webflow-redirect";
2 |
3 | export default function WebflowRedirectPage() {
4 | return (
5 |
6 | );
7 | }
--------------------------------------------------------------------------------
/data-client/components/buttons.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useRouter } from 'next/navigation';
4 | import { ArrowPathIcon, TrashIcon } from '@heroicons/react/24/outline'
5 |
6 | export function client_side_logout(router, redirect_to = '/login') {
7 | fetch('/api/logout', { method: 'POST' })
8 | .then(response => {
9 | if (response.ok) {
10 | router.push(redirect_to);
11 | } else {
12 | throw new Error('Logout failed');
13 | }
14 | })
15 | .catch(error => {
16 | console.error('Error logging out', error);
17 | });
18 | }
19 |
20 | export function delete_auth_cookie() {
21 | document.cookie = 'authenticated=; expires=Thu, 01 Jan 1970 00:00:00 UTC;';
22 | }
23 |
24 | export function LogoutButton() {
25 | const router = useRouter();
26 | return (
27 | {delete_auth_cookie(); client_side_logout(router)}}
31 | >
32 |
33 | Logout
34 |
35 | );
36 | }
37 |
38 | export function ReauthorizeButton({installUrl}) {
39 | const router = useRouter();
40 |
41 | return (
42 | {delete_auth_cookie(); client_side_logout(router, installUrl)}}
46 | >
47 |
48 | Reauthorize
49 |
50 | );
51 | }
--------------------------------------------------------------------------------
/data-client/components/custom-code.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { useEffect, useState, useRef, useCallback } from 'react';
4 | import { CloudIcon, InformationCircleIcon, CommandLineIcon, DocumentCheckIcon, PaperAirplaneIcon, TrashIcon, XCircleIcon, PlusCircleIcon, CircleStackIcon, ViewfinderCircleIcon, CloudArrowDownIcon, SwatchIcon } from '@heroicons/react/24/outline'
5 |
6 | import { Banner } from '@/components';
7 | import { classNames, timeAgo, getTitleTimestamp } from '@/utils'
8 | import { getTemplateCustomCode } from '@/utils/custom_code_helper';
9 |
10 | const templates = [
11 | {
12 | name: 'Flying Text',
13 | description: 'Add this script to your site to display a "Flying Text" that smoothly moves horizontally across the top of the page in a continuous loop, creating an eye-catching and engaging effect for your visitors.',
14 | code: getTemplateCustomCode('flying-text'),
15 | iconColor: 'bg-green-500',
16 | icon: PaperAirplaneIcon,
17 | },
18 | {
19 | name: 'Rainbow',
20 | description: 'Add this script to your site to apply a colorful rainbow gradient to all text, creating a visually appealing and vibrant effect.',
21 | code: getTemplateCustomCode('rainbow'),
22 | iconColor: 'bg-red-500',
23 | icon: SwatchIcon,
24 | },
25 | {
26 | name: 'Snowfall',
27 | description: 'Add this script to your site to create a gentle snowfall effect with snowflakes drifting down the screen, adding a serene and wintry atmosphere to your webpage.',
28 | code: getTemplateCustomCode('snowfall'),
29 | iconColor: 'bg-yellow-500',
30 | icon: CloudArrowDownIcon,
31 | },
32 | {
33 | name: 'Ball Game',
34 | description: 'Add this script to your site to create an interactive game where blue balls bounce around the screen and repel away from your cursor; click anywhere to add more balls, providing a fun and dynamic user experience.',
35 | code: getTemplateCustomCode('ball-game'),
36 | iconColor: 'bg-blue-500',
37 | icon: ViewfinderCircleIcon,
38 | },
39 | ]
40 |
41 | export default function CustomCode({ siteId, savedCode }) {
42 | const [lastUpdated, setLastUpdated] = useState(savedCode?.lastUpdated || null);
43 | const [code, setCode] = useState(savedCode?.code || '');
44 | const [lineCount, setLineCount] = useState(code.split('\n').length);
45 | const [showEditView, setShowEditView] = useState(savedCode ? true : false);
46 | const [savedCodeState, setSavedCodeState] = useState(savedCode);
47 | const [whichBannerToShow, setWhichBannerToShow] = useState(null);
48 | const [bannerVisible, setBannerVisible] = useState(false);
49 | const textareaRef = useRef(null);
50 |
51 | useEffect(() => {
52 | let timeout;
53 | if (bannerVisible) {
54 | timeout = setTimeout(() => {
55 | setBannerVisible(false);
56 | setWhichBannerToShow(null);
57 | }, 3000);
58 | }
59 |
60 | return () => {
61 | clearTimeout(timeout);
62 | };
63 | }, [setBannerVisible, setWhichBannerToShow, bannerVisible]);
64 |
65 | const onInputSetCode = useCallback((event) => {
66 | const c = event.target.value;
67 | const numberOfLines = c.split('\n').length
68 | setCode(c);
69 | setLineCount(numberOfLines);
70 | }, []);
71 |
72 | const onKeyDownTabOver = useCallback((event) => {
73 | if (event.key === 'Tab') {
74 | event.preventDefault();
75 | const textarea = textareaRef.current;
76 | const start = textarea.selectionStart;
77 | const end = textarea.selectionEnd;
78 | const currentLineStart = textarea.value.lastIndexOf('\n', start) + 1;
79 | const currentLineEnd = textarea.value.indexOf('\n', start);
80 | const currentLine = textarea.value.substring(currentLineStart, currentLineEnd === -1 ? undefined : currentLineEnd);
81 |
82 | if (event.shiftKey) {
83 | // Un-indent with Shift+Tab
84 | if (currentLine.startsWith('\t')) {
85 | const newLine = currentLine.substring(1);
86 | textarea.value = textarea.value.substring(0, currentLineStart) + newLine + textarea.value.substring(currentLineEnd === -1 ? undefined : currentLineEnd);
87 | textarea.selectionStart = start - 1;
88 | textarea.selectionEnd = end - 1;
89 | }
90 | } else {
91 | // Indent with Tab
92 | textarea.value = textarea.value.substring(0, start) + '\t' + textarea.value.substring(end);
93 | textarea.selectionStart = start + 1;
94 | textarea.selectionEnd = end + 1;
95 | }
96 | }
97 | }, []);
98 |
99 | const clearCode = useCallback(() => {
100 | setCode('');
101 | setLineCount(1);
102 | }, []);
103 |
104 | const deleteCode = useCallback(async () => {
105 | let codeWasDeleted = false;
106 | if (lastUpdated) {
107 | try {
108 | const response = await fetch('/api/custom-code', {
109 | method: 'DELETE',
110 | body: JSON.stringify({ siteId }),
111 | headers: {
112 | 'Content-Type': 'application/json'
113 | }
114 | });
115 | const res = await response.json();
116 | if (res.deleted) {
117 | setCode('');
118 | setLastUpdated(null);
119 | setShowEditView(false);
120 | setLineCount(1);
121 | codeWasDeleted = true;
122 | }
123 | } catch (error) {
124 | console.error('Error deleting code on server:', error);
125 | }
126 | }
127 | if (!savedCodeState?.code){
128 | setCode('');
129 | setShowEditView(false);
130 | setLineCount(1);
131 | setSavedCodeState(null);
132 | codeWasDeleted = true;
133 | }
134 | if (codeWasDeleted){
135 | setWhichBannerToShow("deleted");
136 | setBannerVisible(true);
137 | }
138 | }, [lastUpdated, savedCodeState, siteId]);
139 |
140 | const writeCode = useCallback(async () => {
141 | try {
142 | const response = await fetch('/api/custom-code', {
143 | method: 'PUT',
144 | body: JSON.stringify({ code, siteId }),
145 | headers: {
146 | 'Content-Type': 'application/json'
147 | }
148 | });
149 | if (response.ok) {
150 | const res = await response.json();
151 | setLastUpdated(res.data.lastUpdated);
152 | setCode(res.data.code);
153 | setSavedCodeState(res.data);
154 | setWhichBannerToShow("success");
155 | setBannerVisible(true);
156 | } else {
157 | throw new Error('Custom code write failed');
158 | }
159 | } catch (error) {
160 | console.error('Error writing code on server:', error);
161 | }
162 | }, [code, siteId]);
163 |
164 | const selectTemplate = useCallback((t) => {
165 | setShowEditView(true);
166 | setCode(t.code);
167 | setLineCount(t.code.split('\n').length);
168 | }, []);
169 |
170 | const getLabel = () => (
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 | Any custom code saved here will be added to the <head> tag of this site.
179 |
180 |
181 |
182 | Characters left: {2000 - code.length}
183 |
184 |
185 |
186 |
187 |
188 | )
189 |
190 | if (showEditView) {
191 | const spanList = Array.from({ length: lineCount }, (_, index) => (
192 |
193 | ));
194 | return (
195 |
196 | {getLabel()}
197 |
198 |
199 | {spanList}
200 |
201 |
214 |
215 |
216 | {lastUpdated &&
217 |
218 | Last updated {timeAgo(lastUpdated)}
219 |
220 | }
221 |
222 |
227 |
228 | Clear
229 |
230 |
235 |
236 | Delete
237 |
238 | 2000 || code.length === 0}
241 | className={
242 | classNames(code.length > 2000 || code.length === 0 ? "bg-white text-gray-400 border-gray-300 border" : "bg-green-600 text-white hover:bg-green-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-800",
243 | "inline-flex items-center rounded-md px-3 py-2 text-sm font-semibold shadow-sm"
244 | )
245 | }
246 | onClick={writeCode}
247 | >
248 |
249 | Save
250 |
251 |
252 |
253 | {whichBannerToShow === "success" && bannerVisible &&
setBannerVisible(false)} />}
254 |
255 | )
256 | }
257 |
258 | return (
259 |
260 |
Add custom code to your site
261 |
Get started by selecting a template or start from scratch.
262 |
263 | {templates.map((t, tId) => (
264 |
265 |
266 |
272 |
273 |
274 |
275 |
276 | selectTemplate(t)} className="focus:outline-none">
277 |
278 | {t.name}
279 | →
280 |
281 |
282 |
{t.description}
283 |
284 |
285 |
286 | ))}
287 |
288 |
289 |
setShowEditView(true)} className="cursor-pointer text-sm font-medium text-blue-600 hover:text-blue-500">
290 | Start from scratch
291 | →
292 |
293 |
294 | {whichBannerToShow === "deleted" && bannerVisible &&
setBannerVisible(false)} />}
295 |
296 | )
297 | }
298 |
--------------------------------------------------------------------------------
/data-client/components/index.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { usePathname } from 'next/navigation';
4 | import Link from 'next/link';
5 | import {ChevronDownIcon, ChevronUpIcon, ArrowTopRightOnSquareIcon, BellAlertIcon, XMarkIcon, BuildingStorefrontIcon, UserGroupIcon, CircleStackIcon, CodeBracketIcon, DocumentDuplicateIcon, CheckCircleIcon, TrashIcon, MegaphoneIcon, RocketLaunchIcon } from '@heroicons/react/24/outline';
6 | import { Fragment, useState, useEffect } from 'react';
7 | import { Popover, Transition } from '@headlessui/react';
8 |
9 | import { classNames, formatDomainName } from '@/utils';
10 |
11 | export function Tab({type, siteId}) {
12 | const getTabData = () => {
13 | switch (type) {
14 | case 'pages':
15 | return { name: 'Pages', href: `/site/${siteId}/pages`, icon: DocumentDuplicateIcon, disabled: false };
16 | case 'custom-code':
17 | return { name: 'Custom Code', href: `/site/${siteId}/custom-code`, icon: CodeBracketIcon, disabled: false };
18 | case 'webhooks':
19 | return { name: 'Webhooks', href: `/site/${siteId}/webhooks`, icon: BellAlertIcon, disabled: true };
20 | case 'cms':
21 | return { name: 'CMS', href: `/site/${siteId}/cms`, icon: CircleStackIcon, disabled: true };
22 | case 'ecommerce':
23 | return { name: 'Ecommerce', href: `/site/${siteId}/ecommerce`, icon: BuildingStorefrontIcon, disabled: true };
24 | default:
25 | return { name: 'Memberships', href: `/site/${siteId}/memberships`, icon: UserGroupIcon, disabled: true };
26 | }
27 | };
28 |
29 | const current_path = usePathname();
30 | const tab_data = getTabData();
31 | const current = current_path.includes(tab_data.href);
32 | return (
33 |
45 |
53 | {tab_data.name}
54 |
55 | )
56 | }
57 |
58 | export function Banner({Icon, content, handleClose, color}){
59 | const colorClasses = {
60 | red: {
61 | container: 'bg-red-50',
62 | icon: 'text-red-500',
63 | text: 'text-red-800',
64 | button: 'text-red-500 hover:bg-red-100 focus:ring-red-600 focus:ring-offset-red-50',
65 | },
66 | green: {
67 | container: 'bg-green-50',
68 | icon: 'text-green-500',
69 | text: 'text-green-800',
70 | button: 'text-green-500 hover:bg-green-100 focus:ring-green-600 focus:ring-offset-green-50',
71 | },
72 | };
73 | const classes = colorClasses[color];
74 |
75 | return (
76 |
77 |
78 |
79 |
80 |
81 |
84 |
85 |
86 |
91 | Dismiss
92 |
93 |
94 |
95 |
96 |
97 |
98 | )
99 | }
100 |
101 |
102 | export function PublishPopoverMenu({siteId, domains}) {
103 | const [popoverOpen, setPopoverOpen] = useState(false);
104 | const [showBanner, setShowBanner] = useState(false);
105 | const [selectedDomains, setSelectedDomains] = useState([]);
106 |
107 | const isClientSide = () => typeof document !== 'undefined';
108 |
109 | const getCookie = (name) => {
110 | if (!isClientSide()) return null;
111 |
112 | const value = `; ${document.cookie}`;
113 | const parts = value.split(`; ${name}=`);
114 | if (parts.length === 2) return parts.pop().split(';').shift();
115 | return null;
116 | };
117 |
118 | const getLastPublished = () => {
119 | const lastPublished = getCookie(`lastPublished-${siteId}`);
120 | return lastPublished ? new Date(lastPublished) : null;
121 | };
122 |
123 | const calculateTimeLeft = () => {
124 | const lastPublished = getLastPublished();
125 | if (!lastPublished) return 0;
126 |
127 | const timeElapsed = (Date.now() - lastPublished) / 1000;
128 | const remainingTime = 60 - timeElapsed;
129 | return remainingTime > 0 ? remainingTime : 0;
130 | };
131 |
132 | const [timeLeft, setTimeLeft] = useState(() => calculateTimeLeft());
133 |
134 | const setCookie = (name, value, minutes) => {
135 | const expires = new Date(Date.now() + minutes * 60000).toUTCString();
136 | document.cookie = `${name}=${value}; expires=${expires}; path=/`;
137 | };
138 |
139 | const canPublish = () => {
140 | const lastPublished = getLastPublished();
141 | if (!lastPublished) return true;
142 |
143 | const timeElapsed = (Date.now() - lastPublished) / 1000;
144 | return timeElapsed >= 60;
145 | };
146 |
147 | useEffect(() => {
148 | if (timeLeft > 0) {
149 | const timer = setTimeout(() => {
150 | setTimeLeft(timeLeft - 1);
151 | }, 1000);
152 | return () => clearTimeout(timer);
153 | }
154 | }, [timeLeft]);
155 |
156 | useEffect(() => {
157 | let timeout;
158 | if (showBanner) {
159 | timeout = setTimeout(() => {
160 | setShowBanner(false);
161 | }, 3000);
162 | }
163 |
164 | return () => {
165 | clearTimeout(timeout);
166 | };
167 | }, [showBanner]);
168 |
169 | const handleDomainToggle = (domain) => {
170 | setSelectedDomains((prevState) => {
171 | // Check if the domain is already in the selectedDomains array
172 | const index = prevState.indexOf(domain);
173 |
174 | // If the domain is in the array, remove it
175 | if (index !== -1) {
176 | return prevState.filter((d) => d !== domain);
177 | }
178 |
179 | // Otherwise, add the domain to the array
180 | return [...prevState, domain];
181 | });
182 | };
183 |
184 | const handlePopoverStateChange = () => {
185 | setPopoverOpen(!popoverOpen);
186 | };
187 |
188 | const handlePublish = async () => {
189 | if (selectedDomains.length > 0) {
190 | try {
191 | if (canPublish()) {
192 | const response = await fetch('/api/publish-site', {
193 | method: 'POST',
194 | body: JSON.stringify({ siteId, domains: selectedDomains }),
195 | headers: {
196 | 'Content-Type': 'application/json'
197 | }
198 | });
199 | const res = await response.json();
200 | if (res.queued) {
201 | setShowBanner(true);
202 | setPopoverOpen(false);
203 | setCookie(`lastPublished-${siteId}`, new Date().toISOString(), 60);
204 | setTimeLeft(60);
205 | }
206 | }
207 | } catch (error) {
208 | console.error('Error publishing site on server:', error);
209 | }
210 | }
211 | };
212 |
213 | const getPublishButton = () => {
214 | if (!canPublish()) {
215 | return (
216 |
220 | {Math.round(timeLeft)} seconds
221 |
222 | );
223 | }
224 | if (selectedDomains.length === 0) {
225 | return (
226 |
230 | Publish
231 |
232 | );
233 | }
234 | return (
235 | handlePublish()}
237 | className="px-4 py-2 mr-2 text-sm font-semibold text-white bg-blue-600 rounded-md hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-white focus:ring-blue-500"
238 | >
239 | Publish
240 |
241 | )
242 | }
243 |
244 | const getDomainLabel = (domainName) => (
245 |
246 | handleDomainToggle(domainName)}
251 | />
252 | {formatDomainName(domainName)}
253 |
259 |
260 |
261 |
262 | );
263 |
264 | return (
265 | <>
266 |
267 |
274 |
275 | Publish
276 | {popoverOpen ? (
277 |
278 | ) : (
279 |
280 | )}
281 |
282 |
283 |
292 |
293 |
294 |
295 | {domains.map((domain) => getDomainLabel(domain.name))}
296 |
297 |
298 | {getPublishButton()}
299 | setSelectedDomains([])}
301 | className="px-4 py-2 text-sm font-semibold text-gray-700 bg-white rounded-md border border-gray-300 shadow-sm hover:bg-gray-50 focus:outline-none focus
302 | focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-blue-500"
303 | >
304 | Close
305 |
306 |
307 |
308 |
309 |
310 |
311 | {showBanner && setShowBanner(false)} />}
312 | >
313 | );
314 | }
315 |
--------------------------------------------------------------------------------
/data-client/components/page-list.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import PageRow from "./page-row";
5 | import { PageSlideOver } from "./slide-over";
6 |
7 | export default function PageList({pages}){
8 | const [openPageId, setOpenPageId] = useState(null);
9 | return (
10 |
11 | {openPageId &&
}
12 |
13 | {pages.map((page) => (
14 |
15 | ))}
16 |
17 |
18 | )
19 | }
--------------------------------------------------------------------------------
/data-client/components/page-row.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { DocumentIcon, DocumentMagnifyingGlassIcon } from '@heroicons/react/24/outline';
4 |
5 | export default function PageRow({page, setOpenPageId}){
6 |
7 | return (
8 |
9 | setOpenPageId(page.id)} className="flex items-center space-x-2 cursor-pointer focus-within:ring-2 focus-within:ring-blue-500 hover:border-blue-600 hover:text-blue-600 group">
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | {/* Extend touch target to entire panel */}
22 |
23 | {page.seoTitle || page.title}
24 |
25 |
26 | {page.seoDesc &&
{page.seoDesc}
}
27 |
28 |
29 |
30 | )
31 | }
--------------------------------------------------------------------------------
/data-client/components/sidebar.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Fragment, useState } from 'react';
4 | import { Dialog, Transition } from '@headlessui/react';
5 | import {
6 | Bars3Icon,
7 | ShieldCheckIcon,
8 | XMarkIcon,
9 | } from '@heroicons/react/24/outline';
10 | import { classNames } from '@/utils';
11 | import Image from 'next/image';
12 | import logo from '@/public/logo.svg';
13 | import { usePathname, } from 'next/navigation';
14 | import Link from 'next/link';
15 |
16 | export default function Sidebar({sites, children}){
17 | const [sidebarOpen, setSidebarOpen] = useState(false);
18 | const current_path = usePathname();
19 |
20 | const getAuthNav = () => {
21 | const name = 'Authorization Info';
22 | const current = current_path.startsWith('/auth-info');
23 | return (
24 |
32 |
39 | {name}
40 |
41 | )
42 | }
43 |
44 | return (
45 | <>
46 |
47 | setSidebarOpen(false)}>
48 |
57 |
58 |
59 |
60 |
61 |
70 |
71 |
80 |
81 | setSidebarOpen(false)}
85 | >
86 | Close sidebar
87 |
88 |
89 |
90 |
91 |
92 |
93 |
98 |
99 |
100 |
101 | {getAuthNav()}
102 |
103 | {sites && sites.length > 0 &&
104 |
105 |
106 | Sites
107 |
108 |
109 | {sites.map((item) => (
110 |
118 | {item.displayName}
119 |
120 | ))}
121 |
122 |
123 | }
124 |
125 |
126 |
127 |
128 |
{/* Force sidebar to shrink to fit close icon */}
129 |
130 |
131 |
132 |
133 | {/* Static sidebar for desktop */}
134 |
174 |
175 |
176 | setSidebarOpen(true)}
180 | >
181 | Open sidebar
182 |
183 |
184 |
185 |
186 | {children}
187 |
188 |
189 | >
190 | )
191 | }
--------------------------------------------------------------------------------
/data-client/components/slide-over.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Fragment, useState } from 'react'
4 | import { Dialog, Transition } from '@headlessui/react'
5 | import useSWR from 'swr'
6 |
7 | import { XMarkIcon, DocumentDuplicateIcon, InformationCircleIcon, LightBulbIcon } from '@heroicons/react/24/outline';
8 | import { getFunFact } from '../utils';
9 |
10 | export function usePageInfo(pageId) {
11 | const fetcher = (...args) => fetch(...args).then(res => res.json())
12 | const { data, error, isLoading } = useSWR(`/api/page?id=${pageId}`, fetcher)
13 | return { page_info: data, isLoading, isError: error }
14 | }
15 |
16 | export function PageSlideOver({openPageId, setOpenPageId}) {
17 | const { page_info, isLoading, isError} = usePageInfo(openPageId);
18 | const [open, setOpen] = useState(true);
19 |
20 | const closeSlideOver = () => {
21 | setOpenPageId(null);
22 | setOpen(false);
23 | };
24 |
25 | const getPageDetails = () => {
26 | if (isError) return failed to load
;
27 | if (isLoading)
28 | return (
29 |
30 |
31 |
32 | ✨ {getFunFact()}
33 |
34 |
41 |
42 |
43 | );
44 |
45 | if (page_info) {
46 | return (
47 |
48 | {page_info.seo && }
49 | {page_info.openGraph && (
50 |
51 | )}
52 |
53 |
54 | );
55 | }
56 | };
57 |
58 | return (
59 |
60 |
61 | {getPageDetails()}
62 |
63 |
64 | );
65 | }
66 |
67 | function PageDetails({ data }) {
68 | return (
69 | <>
70 | {Object.entries(data).map(([key, value]) => {
71 | if (['title', 'seo', 'openGraph'].includes(key)) return null;
72 | if (['createdOn', 'lastUpdated'].includes(key)) {
73 | const date = new Date(value);
74 | value = date.toLocaleString();
75 | }
76 | return (
77 |
78 |
79 |
83 | {key}
84 |
85 |
86 |
87 |
91 | {JSON.stringify(value)}
92 |
93 |
94 |
95 | );
96 | })}
97 | >
98 | );
99 | }
100 |
101 | function OpenGraphDetails({ data }) {
102 | return (
103 |
104 |
105 |
109 | Open Graph
110 |
111 |
112 |
113 |
114 | {/* Title */}
115 |
116 |
117 | Title
118 | {data.titleCopied && (
119 | <>
120 |
121 |
122 | “titleCopied“ was set to true
123 |
124 | >
125 | )}
126 |
127 |
131 | {JSON.stringify(data.title)}
132 |
133 |
134 | {data.titleAnalysis && (
135 |
136 |
137 |
138 |
139 |
140 |
141 |
Insights and recommendations:
142 |
143 |
{data.titleAnalysis}
144 |
{data.titleSuggestions}
145 |
146 |
147 |
148 |
149 | )}
150 |
151 | {/* Description */}
152 |
153 |
154 | Description
155 | {data.descriptionCopied && (
156 | <>
157 |
158 |
159 | “descriptionCopied“ was set to true
160 |
161 | >
162 | )}
163 |
164 |
168 | {JSON.stringify(data.description)}
169 |
170 | {data.descriptionAnalysis && (
171 |
172 |
173 |
174 |
175 |
176 |
177 |
Insights and recommendations:
178 |
179 |
{data.descriptionAnalysis}
180 |
{data.descriptionSuggestions}
181 |
182 |
183 |
184 |
185 | )}
186 |
187 |
188 |
189 |
190 | );
191 | }
192 |
193 | function SeoDetails({ data }) {
194 | return (
195 |
196 |
197 |
201 | SEO
202 |
203 |
204 |
205 |
206 |
207 |
208 | Title
209 |
210 |
214 | {JSON.stringify(data.title)}
215 |
216 | {data.titleAnalysis && (
217 |
218 |
219 |
220 |
221 |
222 |
223 |
Insights and recommendations:
224 |
225 |
{data.titleAnalysis}
226 |
{data.titleSuggestions}
227 |
228 |
229 |
230 |
231 | )}
232 |
233 |
234 |
235 | Description
236 |
237 |
241 | {JSON.stringify(data.description)}
242 |
243 | {data.descriptionAnalysis && (
244 |
245 |
246 |
247 |
248 |
249 |
250 |
Insights and recommendations:
251 |
252 |
{data.descriptionAnalysis}
253 |
{data.descriptionSuggestions}
254 |
255 |
256 |
257 |
258 | )}
259 |
260 |
261 |
262 |
263 | );
264 | }
265 |
266 | export function SlideOver({title, children, open, closeSlideOver}){
267 | return (
268 |
269 | closeSlideOver()}>
270 |
279 |
280 |
281 |
282 |
283 |
284 |
293 |
294 |
295 | {/* Header */}
296 |
297 |
298 |
299 |
300 | {title}
301 |
302 |
303 |
304 | setOpen(false)}
308 | >
309 | Close panel
310 |
311 |
312 |
313 |
314 |
315 | {/* Main */}
316 |
317 | {children}
318 |
319 |
320 |
321 |
322 |
323 |
324 |
325 |
326 |
327 | )
328 | }
--------------------------------------------------------------------------------
/data-client/components/webflow-redirect.jsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect, useState } from "react";
4 | import { useRouter } from 'next/navigation';
5 |
6 | export default function WebflowRedirect() {
7 | const router = useRouter();
8 | const [checkAuth, setCheckAuth] = useState(false);
9 | const [showElement, setShowElement] = useState(false);
10 |
11 | useEffect(() => {
12 | const cookies = document.cookie.split(';');
13 | const authenticated = cookies.find(cookie => cookie.includes('authenticated='));
14 | if (authenticated && authenticated.split('=')[1] === 'true' && !checkAuth) {
15 | router.push('/');
16 | }
17 | setCheckAuth(true);
18 |
19 | const timeoutId = setTimeout(() => {
20 | setShowElement(true);
21 | }, 2000);
22 |
23 | return () => {
24 | clearTimeout(timeoutId);
25 | };
26 |
27 | }, [router, checkAuth]);
28 |
29 | const getWarning = () => {
30 | if (showElement && checkAuth) return (
31 |
34 | )
35 | }
36 |
37 | return (
38 |
39 |
40 |
41 | {getWarning()}
42 |
43 | Authorizing your Webflow account
44 |
45 |
50 |
51 |
52 |
53 | );
54 | }
--------------------------------------------------------------------------------
/data-client/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "paths": {
4 | "@/*": ["./*"]
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/data-client/middleware.js:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import { getAccessToken } from '@/utils/webflow_helper';
3 |
4 | /**
5 | * A middleware function that retrieves an access token from Webflow and sets it as a cookie
6 | * or redirects to the login page if the access token is missing.
7 | *
8 | * @param {import('next/dist/next-server/server/api-utils').NextApiRequest} request - The Next.js API request object.
9 | * @returns {import('next/dist/next-server/server/api-utils').NextApiResponse} - The Next.js API response object.
10 | */
11 |
12 | const CLIENT_URL = process.env.CLIENT_URL
13 |
14 | export async function middleware(request){
15 | if (request.method === 'OPTIONS') {
16 | // Preflight request. Reply successfully:
17 |
18 | return new NextResponse(null, {
19 | headers: {
20 | 'Access-Control-Allow-Origin': "*",
21 | 'Access-Control-Allow-Methods': 'GET',
22 | 'Access-Control-Allow-Headers': 'Content-Type',
23 | },
24 | });
25 | }
26 | else {
27 | console.error("TEST ERROR")
28 | }
29 |
30 | if (request.nextUrl.pathname === '/webflow_redirect' && request.nextUrl.searchParams.get('code')) {
31 | try {
32 | const code = request.nextUrl.searchParams.get('code');
33 | const token = await getAccessToken(code);
34 | if (token) {
35 | // If the access token is retrieved successfully, set it as a cookie and return the response
36 | const response = NextResponse.next();
37 | response.cookies.set('webflow_auth', token, {
38 | // httpOnly: true,
39 | // secure: true,
40 | // sameSite: 'strict',
41 | maxAge: 60 * 60 * 24 * 30 // 30 days
42 | });
43 | response.cookies.set('authenticated', 'true');
44 | return response;
45 | }
46 | } catch (error) {
47 | // TODO: If access denied in query params then customer rejected install request,
48 | // Send this info to the webflow_redirect page and show an error message to the user.
49 | // Then redirect them to the login page.
50 | console.error(`Failed to get access token: ${error}`);
51 | }
52 | }
53 |
54 | if (request.nextUrl.pathname.startsWith('/api')) {
55 | return NextResponse.next();
56 | }
57 |
58 | if (request.nextUrl.pathname !== '/login' && !request.cookies.get('webflow_auth')) {
59 | return NextResponse.redirect(new URL('/login', request.url));
60 | }
61 |
62 | return NextResponse.next();
63 | }
64 |
65 | // Run this middleware on all pages except /login
66 | export const config = {
67 | matcher: [
68 | '/((?!_next/static|_next/image|favicon.ico).*)',
69 | ]
70 | };
71 |
--------------------------------------------------------------------------------
/data-client/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | formats: ['image/avif', 'image/webp'],
5 | remotePatterns: [
6 | {
7 | protocol: "https",
8 | hostname: "screenshots.webflow.com",
9 | port: '',
10 | pathname: "/**",
11 | }
12 | ],
13 | },
14 | }
15 |
16 | module.exports = nextConfig
17 |
--------------------------------------------------------------------------------
/data-client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "webflow-api-v2-demo",
3 | "version": "0.1.0",
4 | "private": true,
5 | "main": "app/page.js",
6 | "scripts": {
7 | "dev": "next dev -p 3001",
8 | "build": "next build",
9 | "start": "next start",
10 | "lint": "next lint"
11 | },
12 | "dependencies": {
13 | "@headlessui/react": "^1.7.13",
14 | "@heroicons/react": "^2.0.16",
15 | "cors": "^2.8.5",
16 | "dotenv": "^16.3.1",
17 | "eslint": "8.35.0",
18 | "eslint-config-next": "^13.4.4",
19 | "form-data": "^4.0.0",
20 | "isomorphic-fetch": "^3.0.0",
21 | "next": "^13.4.19",
22 | "openai": "^4.19.0",
23 | "react": "^18.2.0",
24 | "react-dom": "^18.2.0",
25 | "sharp": "^0.32.6",
26 | "swr": "^2.0.4",
27 | "webflow-api": "^1.2.2"
28 | },
29 | "devDependencies": {
30 | "@babel/preset-react": "^7.22.15",
31 | "@types/react": "^18.0.28",
32 | "autoprefixer": "^10.4.13",
33 | "http-proxy": "^1.18.1",
34 | "postcss": "^8.4.21",
35 | "tailwindcss": "^3.2.7"
36 | },
37 | "postcss": {
38 | "plugins": [
39 | "tailwindcss",
40 | "autoprefixer"
41 | ]
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/data-client/public/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
22 |
23 |
--------------------------------------------------------------------------------
/data-client/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/data-client/public/thirteen.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/data-client/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/data-client/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | "./app/**/*.{js,jsx}",
5 | "./components/**/*.{js,jsx}",
6 | ],
7 | theme: {
8 | extend: {},
9 | },
10 | plugins: [],
11 | }
12 |
--------------------------------------------------------------------------------
/data-client/utils/custom_code_helper.js:
--------------------------------------------------------------------------------
1 | import { dedent } from '@/utils'
2 |
3 | export const getTemplateCustomCode = (t) => {
4 | switch (t) {
5 | case 'flying-text':
6 | return dedent`
7 |
25 | `;
26 | case 'ball-game':
27 | return dedent`
28 |
47 | `;
48 | case 'snowfall':
49 | return dedent`
50 |
111 | `;
112 | case 'rainbow':
113 | return dedent`
114 |
125 | `;
126 | default:
127 | return '';
128 | }
129 | }
--------------------------------------------------------------------------------
/data-client/utils/openai_helper.js:
--------------------------------------------------------------------------------
1 | import { Configuration, OpenAIApi } from 'openai';
2 |
3 | const configuration = new Configuration({
4 | apiKey: process.env.OPENAI_API_KEY,
5 | });
6 |
7 | const openai = new OpenAIApi(configuration);
8 |
9 |
10 | /**
11 | * Generates metadata content suggestions and provides analysis based on the site's overall theme, target audience, and specified keywords (optional).
12 | *
13 | * @param {string} seoTitle - The current SEO title of the webpage.
14 | * @param {string} seoDescription - The current SEO description of the webpage.
15 | * @param {string} openGraphTitle - The current Open Graph title of the webpage.
16 | * @param {string} openGraphDescription - The current Open Graph description of the webpage.
17 | * @param {string[]} [targetKeywords] - An optional array of target keywords to optimize the suggestions for. Defaults to an empty array.
18 | *
19 | * @returns {Promise} - A Promise that resolves to an object containing metadata analysis and suggestions for each metadata field.
20 | *
21 | * @example
22 | * // Example usage:
23 | * const seoTitle = "Top 10 Marketing Strategies";
24 | * const seoDescription = "Learn effective marketing strategies to boost website traffic and conversions.";
25 | * const openGraphTitle = "Latest Design Trends in Marketing";
26 | * const openGraphDescription = "Discover the latest design trends in the marketing industry and stay ahead of the competition with actionable insights.";
27 | * const targetKeywords = ["web design", "conversion rate"];
28 | *
29 | * generateMetadataSuggestionsAndAnalysis(seoTitle, seoDescription, openGraphTitle, openGraphDescription, targetKeywords)
30 | * .then(metadata => console.log(metadata))
31 | * .catch(error => console.error(error));
32 | *
33 | * // Example output:
34 | * {"SEO title": {
35 | * "analysis": "The current SEO title is not very descriptive and lacks focus on the unique selling points of the website template.",
36 | * "suggestion": "10 Benefits of Using Specifics HTML Template for Your Website"
37 | * },
38 | * "SEO description": {
39 | * "analysis": "The current SEO description is decent, but could benefit from using more specific keywords to attract the target audience.",
40 | * "suggestion": "Create a clean and engaging website for your marketing business with Specifics HTML Template. Perfect for small businesses and startups."
41 | * },
42 | * "Open Graph title": {
43 | * "analysis": "The current Open Graph title is not very engaging and lacks creativity.",
44 | * "suggestion": "Stand out with Specifics HTML Template: Clean Design, Smooth Animations, Unique Layout"
45 | * },
46 | * "Open Graph description": {
47 | * "analysis": "The current Open Graph description is decent, but could benefit from using more specific keywords to attract the target audience.",
48 | * "suggestion": "Create a clean and engaging website for your marketing business with Specifics HTML Template. Perfect for small businesses and startups. Get inspired by our latest design trends and stay ahead of the competition."
49 | * }}
50 | */
51 | export const generateMetadataSuggestionsAndAnalysis = async (seoTitle, seoDescription, openGraphTitle, openGraphDescription) => {
52 | try {
53 | const response = await requestMetadataSuggestionsAndAnalysis(seoTitle, seoDescription, openGraphTitle, openGraphDescription);
54 | return extractMetadataSuggestionsAndAnalysis(response);
55 | } catch (error) {
56 | throw new Error(`Failed to generate metadata suggestions and analysis: ${error.message}`);
57 | }
58 | };
59 |
60 | /**
61 | * This method sends a request to the OpenAI API to generate metadata content suggestions and provide analysis.
62 | * @param {*} seoTitle
63 | * @param {*} seoDescription
64 | * @param {*} openGraphTitle
65 | * @param {*} openGraphDescription
66 | * @param {*} targetKeywords
67 | * @returns {object} - An OpenAI API response object containing metadata analysis and suggestions.
68 | * The response object has the following properties:
69 | * - id (string): The ID of the text completion request.
70 | * - object (string): The type of object, which is always "text_completion".
71 | * - created (number): The Unix timestamp when the request was created.
72 | * - model (string): The name of the GPT-3 model used for the completion.
73 | * - choices (array): An array containing one object with the following properties:
74 | * - text (string): The generated text that provides metadata analysis and suggestions.
75 | * - index (number): The index of the choice, which is always 0.
76 | * - logprobs (null): Null, indicating that no log probabilities were generated.
77 | * - finish_reason (string): The reason why the text generation stopped, which is always "stop".
78 | */
79 | const requestMetadataSuggestionsAndAnalysis = async (seoTitle, seoDescription, openGraphTitle, openGraphDescription) => {
80 | const prompt = `Please generate and return a JSON object with the following keys and information:
81 | {
82 | "seoTitleAnalysis": "A one-sentence analysis of the quality and relevance of '${seoTitle}'. If it's missing, explain why not having it is a problem.",
83 | "seoDescriptionAnalysis": "A one-sentence analysis of the quality and relevance of '${seoDescription}'. If it's missing, explain why not having it is a problem.",
84 | "openGraphTitleAnalysis": "A one-sentence analysis of the quality and relevance of '${openGraphTitle}'. If it's missing, explain why not having it is a problem.",
85 | "openGraphDescriptionAnalysis": "A one-sentence analysis of the quality and relevance of '${openGraphDescription}'. If it's missing, explain why not having it is a problem.",
86 | "seoTitleSuggestions": "A suggestion of a better SEO title instead of '${seoTitle}'.",
87 | "seoDescriptionSuggestions": "A suggestion of a better SEO title instead of '${seoDescription}'.",
88 | "openGraphTitleSuggestions": "A suggestion of a better SEO title instead of '${openGraphTitle}'.",
89 | "openGraphDescriptionSuggestions": "A suggestion of a better SEO title instead of '${openGraphDescription}'."
90 | }
91 | `
92 |
93 | return await openai.createCompletion({
94 | // model specifies the language model to use for generating the completion. In this case, "text-davinci-003" is used,
95 | // which is one of OpenAI's most advanced models capable of producing high-quality text with diverse styles and tones.
96 | model: "text-davinci-003",
97 | prompt: prompt,
98 | // If you want more creative and diverse output, you can increase the temperature value (e.g., 0.8 or higher).
99 | // If you want more focused and deterministic output, you can decrease the temperature value (e.g., 0.2 or lower).
100 | temperature: 0.2,
101 | // You can set the max_tokens value to control the length of the generated text.
102 | // If you want shorter output, you can reduce the max_tokens value,
103 | // but be cautious not to set it too low, as it may result in incomplete or nonsensical text.
104 | max_tokens: 600,
105 | // Higher values for top_p (e.g., 0.8 or higher) allow more diversity in the generated text,
106 | // while lower values (e.g., 0.2 or lower) make the output more focused.
107 | // You can adjust this parameter based on the level of creativity and diversity you want in the generated text.
108 | top_p: 1.0,
109 | // Different values for the frequency_penalty and presence_penalty parameters will
110 | // penalize or allow repetition and similarity in the generated text based on your preference.
111 | // Higher values (e.g., 1.0) will penalize repetition or similarity more strongly,
112 | // while lower values (e.g., 0.0) will allow more repetition or similarity.
113 | frequency_penalty: 0.0,
114 | presence_penalty: 0.0,
115 | });
116 | };
117 |
118 | export const generateImages = async (prompt, n, size, response_format) => {
119 | const getImageSize = (size) => {
120 | switch (size) {
121 | case 256:
122 | return "256x256";
123 | case 512:
124 | return "512x512";
125 | case 1024:
126 | return "1024x1024";
127 | default:
128 | return "512x512";
129 | }
130 | };
131 |
132 | return await openai.createImage({
133 | prompt: prompt,
134 | n: n,
135 | size: getImageSize(size),
136 | })
137 | };
138 |
139 | /**
140 | * Extracts metadata suggestions and analysis from an OpenAI API response object.
141 | *
142 | * @param {object} response - An OpenAI API response object containing metadata suggestions and analysis.
143 | *
144 | * @returns {object} - An object containing metadata analysis and suggestions for each metadata field.
145 | *
146 | * @example
147 | * // Example usage:
148 | * const response = {
149 | * "choices": [
150 | * {
151 | * "text": "SEO title:\n\nThe current SEO title is not very descriptive and lacks focus on the unique selling points of the website template. Consider revising the title to highlight the key benefits and features of the template, such as its clean design, smooth animations, and unique layout.\n\nSuggestion:\n\n10 Benefits of Using Specifics HTML Template for Your Website\n\nOpen Graph title:\n\nThe current Open Graph title is not very engaging and lacks creativity. Consider using a more attention-grabbing title that highlights the unique selling points of the template and encourages users to click on the shared link.\n\nSuggestion:\n\nStand out with Specifics HTML Template: Clean Design, Smooth Animations, Unique Layout\n\nSEO description:\n\nThe current SEO description is decent, but could benefit from using more specific keywords to attract the target audience. Consider mentioning the industries or niches that the template is designed for, such as marketing, advertising, or e-commerce.\n\nSuggestion:\n\nCreate a clean and engaging website for your marketing business with Specifics HTML Template. Perfect for small businesses and startups.\n\nOpen Graph description:\n\nThe current Open Graph description is decent, but could benefit from using more specific keywords to attract the target audience. Consider mentioning the industries or niches that the template is designed for, such as marketing, advertising, or e-commerce.\n\nSuggestion:\n\nCreate a clean and engaging website for your marketing business with Specifics HTML Template. Perfect for small businesses and startups. Get inspired by our latest design trends and stay ahead of the competition."
152 | * }
153 | * ]
154 | * };
155 | *
156 | * const metadataSuggestionsAndAnalysis = extractMetadataSuggestionsAndAnalysis(response);
157 | * console.log(metadataSuggestionsAndAnalysis);
158 | * // Output: {
159 | * // "seoTitleAnalysis": "The current SEO title is not very descriptive and lacks focus on the unique selling points of the website template.",
160 | * // "seoTitleSuggestions": "10 Benefits of Using Specifics HTML Template for Your Website",
161 | * // "seoDescriptionAnalysis": "The current SEO description is decent, but could benefit from using more specific keywords to attract the target audience.",
162 | * // "seoDescriptionSuggestions": "Create a clean and engaging website for your marketing business with Specifics HTML Template. Perfect for small businesses and startups.",
163 | * // "openGraphTitleAnalysis": "The current Open Graph title is not very engaging and lacks creativity.",
164 | * // "openGraphTitleSuggestions": "Stand out with Specifics HTML Template: Clean Design, Smooth Animations, Unique Layout",
165 | * // "openGraphDescriptionAnalysis": "The current Open Graph description is decent, but could benefit from using more specific keywords to attract the target audience.",
166 | * // "openGraphDescriptionSuggestions": "Create a clean and engaging website for your marketing business with Specifics HTML Template. Perfect for small businesses and startups."
167 | * // }
168 | */
169 | const extractMetadataSuggestionsAndAnalysis = (response) => {
170 | return JSON.parse(response.data.choices[0].text);
171 | };
172 |
173 |
--------------------------------------------------------------------------------
/data-client/utils/webflow_helper.js:
--------------------------------------------------------------------------------
1 | import Webflow from "webflow-api"
2 | const { CLIENT_ID, CLIENT_SECRET } = process.env;
3 |
4 | // TODO: We should update our API Client so that any requests that fail with a 403 status code
5 | // to inform the user that they should check to ensure they authorized the user for the correct scopes.
6 |
7 | export function getAPIClient(token, beta=true) {
8 | return new Webflow({
9 | token: token,
10 | version: beta ? "2.0.0" : "1.0.0",
11 | host: beta ? "webflow.com/beta" : "webflow.com",
12 | headers: {
13 | "User-Agent": "Devflow.party",
14 | }
15 | });
16 | }
17 |
18 | export async function revokeToken(access_token) {
19 | const webflowAPI = getAPIClient(access_token);
20 | await webflowAPI.revokeToken({access_token, client_id: CLIENT_ID, client_secret: CLIENT_SECRET});
21 | }
22 |
23 | const ALL_SCOPES = [
24 | 'assets:read',
25 | 'assets:write',
26 | 'authorized_user:read',
27 | 'cms:read',
28 | 'cms:write',
29 | 'custom_code:read',
30 | 'custom_code:write',
31 | 'forms:read',
32 | 'forms:write',
33 | 'pages:read',
34 | 'pages:write',
35 | 'sites:read',
36 | 'sites:write',
37 | ];
38 |
39 | export function getAuthUrl(scope = ALL_SCOPES){
40 | const webflow = new Webflow();
41 | return webflow.authorizeUrl({
42 | scope: scope.join(' '),
43 | client_id: CLIENT_ID,
44 | });
45 | }
46 |
47 | /**
48 | * Retrieves an access token from the Webflow API using an authorization code.
49 | *
50 | * Since we're getting the access token from the server in our middleware, we need to use the node-fetch package
51 | * instead of the browser fetch API. This is because the browser fetch API is not available in
52 | * the server runtime. See the following 2 links:
53 | * https://nextjs.org/docs/messages/node-module-in-edge-runtime
54 | * https://nextjs.org/docs/api-reference/edge-runtime
55 | *
56 | * @param {string} code - An authorization code previously obtained from Webflow.
57 | * @returns {Promise} - A promise that resolves to the access token string.
58 | * @throws {Error} - If the API request fails or the response cannot be parsed.
59 | */
60 | export async function getAccessToken(code){
61 | try {
62 | const response = await fetch("https://api.webflow.com/oauth/access_token", {
63 | method: "POST",
64 | headers: {
65 | "Content-Type": "application/json",
66 | },
67 | body: JSON.stringify({
68 | code: code,
69 | grant_type: "authorization_code",
70 | client_id: CLIENT_ID,
71 | client_secret: CLIENT_SECRET,
72 | }),
73 | });
74 | if (!response.ok) {
75 | // If the response status code is not in the 200-299 range, throw an error.
76 | throw new Error(`Failed to fetch access token: ${response.status} ${response.statusText}`);
77 | }
78 | const data = await response.json();
79 | // Return the access token string from the response data object.
80 | return data.access_token;
81 | } catch (error) {
82 | // If there is an error while parsing the response, throw an error.
83 | throw new Error(`Failed to parse response: ${error.message}`);
84 | }
85 | }
--------------------------------------------------------------------------------
/designer-extension/.env.local.example:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_BACKEND_URL=
--------------------------------------------------------------------------------
/designer-extension/.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 |
27 | # local env files
28 | .env*.local
29 |
30 | # vercel
31 | .vercel
32 |
33 | # typescript
34 | *.tsbuildinfo
35 | next-env.d.ts
36 |
37 | dist
--------------------------------------------------------------------------------
/designer-extension/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnPaste": true,
3 | "editor.formatOnSave": true,
4 | "editor.defaultFormatter": "esbenp.prettier-vscode",
5 | "editor.codeActionsOnSave": {
6 | "source.fixAll.eslint": true,
7 | "source.fixAll.format": true
8 | },
9 | }
--------------------------------------------------------------------------------
/designer-extension/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.tsx`. The page auto-updates as you edit the file.
18 |
19 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
20 |
21 | ## Learn More
22 |
23 | To learn more about Next.js, take a look at the following resources:
24 |
25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
27 |
28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
29 |
30 | ## Deploy on Vercel
31 |
32 | 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.
33 |
34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
35 |
--------------------------------------------------------------------------------
/designer-extension/config/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
5 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
7 | ],
8 | theme: {
9 | extend: {
10 | colors: {
11 | "wf-gray": "#404040",
12 | "wf-lightgray": "#D9D9D9",
13 | },
14 | backgroundImage: {
15 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
16 | "gradient-conic":
17 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
18 | },
19 | },
20 | },
21 | plugins: [],
22 | };
23 |
--------------------------------------------------------------------------------
/designer-extension/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true,
4 | output: "export", // force static build
5 | distDir: "dist", // set output dir, must match with webflow.json
6 | assetPrefix: process.env.NODE_ENV === "production" ? "." : undefined,
7 | images: { unoptimized: true }, // "output: export" doesn't support image optimization since there's no nextjs server to optimize image.
8 | };
9 |
10 | module.exports = nextConfig;
--------------------------------------------------------------------------------
/designer-extension/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "devflow-ext",
3 | "version": "0.1.0",
4 | "private": true,
5 | "main": "index.js",
6 | "scripts": {
7 | "dev": "next build && webflow extension serve",
8 | "build": "next build && webflow extension bundle",
9 | "start": "next start",
10 | "lint": "next lint",
11 | "export": "next export"
12 | },
13 | "dependencies": {
14 | "@heroicons/react": "^2.0.18",
15 | "@types/node": "20.2.5",
16 | "@types/react": "18.2.8",
17 | "@types/react-dom": "18.2.4",
18 | "autoprefixer": "10.4.14",
19 | "concurrently": "^8.2.1",
20 | "dotenv": "^16.3.1",
21 | "eslint-config-next": "13.4.4",
22 | "framer-motion": "^10.12.16",
23 | "next": "^13.4.19",
24 | "postcss": "^8.4.31",
25 | "react": "^18.2.0",
26 | "react-dom": "^18.2.0",
27 | "tailwindcss": "3.3.2",
28 | "typescript": "5.1.3"
29 | },
30 | "devDependencies": {
31 | "@next/eslint-plugin-next": "^13.4.19",
32 | "@typescript-eslint/eslint-plugin": "^5.59.8",
33 | "@typescript-eslint/parser": "^5.59.8",
34 | "@webflow/designer-extension-typings": "^0.1.6",
35 | "eslint": "^8.41.0",
36 | "eslint-config-prettier": "^8.8.0",
37 | "eslint-plugin-jsx-a11y": "^6.7.1",
38 | "eslint-plugin-react": "^7.33.2",
39 | "eslint-plugin-react-hooks": "^4.6.0",
40 | "prettier": "^2.8.8"
41 | },
42 | "eslintConfig": {
43 | "env": {
44 | "browser": true,
45 | "es2021": true,
46 | "node": true
47 | },
48 | "extends": [
49 | "eslint:recommended",
50 | "plugin:@typescript-eslint/recommended",
51 | "plugin:react/recommended",
52 | "plugin:react-hooks/recommended",
53 | "plugin:jsx-a11y/recommended",
54 | "plugin:@next/eslint-plugin-next/recommended",
55 | "prettier"
56 | ],
57 | "parser": "@typescript-eslint/parser",
58 | "parserOptions": {
59 | "ecmaVersion": "latest",
60 | "sourceType": "module",
61 | "ecmaFeatures": {
62 | "jsx": true
63 | }
64 | },
65 | "settings": {
66 | "react": {
67 | "version": "detect"
68 | }
69 | },
70 | "plugins": [
71 | "@typescript-eslint",
72 | "react",
73 | "react-hooks",
74 | "jsx-a11y",
75 | "@next/eslint-plugin-next"
76 | ],
77 | "rules": {
78 | "react/no-unknown-property": [
79 | "error"
80 | ],
81 | "@next/next/no-img-element": "off"
82 | }
83 | },
84 | "prettier": {
85 | "endOfLine": "lf",
86 | "printWidth": 80,
87 | "tabWidth": 2,
88 | "useTabs": false,
89 | "trailingComma": "es5"
90 | },
91 | "postcss": {
92 | "plugins": {
93 | "tailwindcss": "./config/tailwind.config.js",
94 | "autoprefixer": {}
95 | }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/designer-extension/public/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
5 |
22 |
23 |
--------------------------------------------------------------------------------
/designer-extension/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/designer-extension/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/designer-extension/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Webflow-Examples/hybrid-app-example/b792bdf46b5b92e48a902505952e6fcb63d40bfa/designer-extension/src/app/favicon.ico
--------------------------------------------------------------------------------
/designer-extension/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --foreground-rgb: 0, 0, 0;
7 | --background-start-rgb: 214, 219, 220;
8 | --background-end-rgb: 255, 255, 255;
9 | }
10 |
11 | @media (prefers-color-scheme: dark) {
12 | :root {
13 | --foreground-rgb: 255, 255, 255;
14 | --background-start-rgb: 0, 0, 0;
15 | --background-end-rgb: 0, 0, 0;
16 | }
17 | }
18 |
19 | body {
20 | color: rgb(var(--foreground-rgb));
21 | background: linear-gradient(
22 | to bottom,
23 | transparent,
24 | rgb(var(--background-end-rgb))
25 | )
26 | rgb(var(--background-start-rgb));
27 | }
28 |
--------------------------------------------------------------------------------
/designer-extension/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import "./globals.css";
2 | import React from "react";
3 |
4 | export const metadata = {
5 | title: "Create Next App",
6 | description: "Generated by create next app",
7 | };
8 |
9 | export default function RootLayout({
10 | children,
11 | }: {
12 | children: React.ReactNode;
13 | }) {
14 | return (
15 |
16 | {children}
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/designer-extension/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { motion } from "framer-motion"; // For animations
3 | import React, { useEffect, useState } from "react";
4 | // import Image from "next/image";
5 |
6 | const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL;
7 |
8 | interface Site {
9 | id: string;
10 | }
11 |
12 | interface Image {
13 | url: string;
14 | }
15 |
16 | interface UserInputProps {
17 | setImages: any;
18 | setPage: any;
19 | token: string;
20 | }
21 |
22 | interface SelectedImage {
23 | index: number;
24 | url: string;
25 | }
26 |
27 | interface ImageOptionsProps {
28 | images: Image[];
29 | resetUserInput: () => void;
30 | selectedSite: Site | null;
31 | token: string;
32 | }
33 |
34 | interface LoginProps {
35 | setPage: any;
36 | token: any;
37 | setToken: any;
38 | }
39 |
40 | const MainPage: React.FC = () => {
41 | const [page, setPage] = useState(0);
42 | const [token, setToken] = useState("");
43 | const [selectedSite, setSelectedSite] = useState(null);
44 | const [images, setImages] = useState([]);
45 | const [mounted, setMounted] = React.useState(false);
46 |
47 | useEffect(() => {
48 | setMounted(true);
49 | }, []);
50 |
51 | useEffect(() => {
52 | if (typeof window !== "undefined") {
53 | // Get authorization, if already authorized then set setPage to 1
54 | const auth = localStorage.getItem("devflow_token");
55 |
56 | const getSiteInfo = async () => {
57 | const siteInfo = await webflow.getSiteInfo();
58 | const siteId = siteInfo.siteId;
59 | setSelectedSite({ id: siteId });
60 | };
61 | setPage(auth ? 1 : 0);
62 | setToken(auth || "");
63 | getSiteInfo();
64 | }
65 | }, []);
66 |
67 | if (!mounted) {
68 | return null;
69 | }
70 |
71 | // If token is undefined send user to Login Page
72 | if (!token) {
73 | return ;
74 | }
75 |
76 | // This function determines which content appears on the page
77 | switch (page) {
78 | case 0:
79 | return ;
80 | case 1:
81 | return (
82 |
83 | );
84 | case 3:
85 | return (
86 | {
89 | setImages([]);
90 | setPage(2);
91 | }}
92 | selectedSite={selectedSite}
93 | token={token}
94 | />
95 | );
96 | }
97 | };
98 |
99 | const Login: React.FC = ({
100 | setPage,
101 | token,
102 | setToken,
103 | }: {
104 | setPage: any;
105 | token: any;
106 | setToken: any;
107 | }) => {
108 | return (
109 |
115 |
116 |
117 |
118 | Image Generator
119 |
120 |
by Devflow.party
121 |
122 | {
125 | setToken(e.target.value);
126 | }}
127 | className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
128 | placeholder="Enter your auth token"
129 | />
130 | {
134 | localStorage.setItem("devflow_token", token);
135 | setPage(1);
136 | }}
137 | >
138 | Authenticate
139 |
140 |
141 |
142 |
143 |
144 | );
145 | };
146 |
147 | const UserInput: React.FC = ({ setImages, token, setPage }) => {
148 | const [prompt, setPrompt] = useState("");
149 | const [size, setSize] = useState(256);
150 | const [n, setN] = useState(1);
151 | const [isLoading, setIsLoading] = useState(false);
152 |
153 | const generateImages = async () => {
154 | setIsLoading(true);
155 | const params = new URLSearchParams({
156 | auth: token,
157 | prompt: prompt,
158 | n: n.toString(),
159 | size: size.toString(),
160 | });
161 |
162 | try {
163 | const response = await fetch(
164 | `${BACKEND_URL}/api/images?${params.toString()}`,
165 | {
166 | method: "GET",
167 | headers: {
168 | "Content-Type": "application/json",
169 | },
170 | }
171 | );
172 |
173 | if (!response.ok) {
174 | throw new Error(`HTTP error! status: ${response.status}`);
175 | }
176 |
177 | const data = await response.json();
178 | if (data.images) {
179 | setImages(data.images);
180 | setPage(3);
181 | }
182 | } catch (error) {
183 | console.error("Error:", error);
184 | } finally {
185 | setIsLoading(false);
186 | }
187 | };
188 |
189 | return (
190 |
191 |
192 |
193 |
194 | Describe your desired image
195 |
196 |
203 |
204 |
Image size: {size}
205 |
206 | {[256, 512, 1024].map((btnSize, i) => (
207 | setSize(btnSize)}
210 | className={`mb-2 rounded-md py-2 px-2 text-xs font-medium text-white ${
211 | size === btnSize ? "bg-blue-600" : "bg-gray-700"
212 | } hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 cursor-pointer border-gray-700 w-full`}
213 | >
214 | {btnSize}
215 |
216 | ))}
217 |
218 |
219 |
220 |
Number of images: {n}
221 |
setN(parseInt(e.target.value))}
228 | />
229 |
230 |
231 |
236 | {isLoading ? "Generating..." : "Generate"}
237 |
238 |
239 |
240 |
241 | );
242 | };
243 |
244 | const ImageOptions: React.FC = ({
245 | images,
246 | resetUserInput,
247 | selectedSite,
248 | token,
249 | }) => {
250 | const [page, setPage] = useState(1);
251 | const [addedImages, setAddedImages] = useState([]);
252 | const [selectedImage, setSelectedImage] = useState(
253 | null
254 | );
255 | const imagesPerPage = 4;
256 | const pageLength = Math.ceil(images.length / 4);
257 | const [uploading, setUploading] = useState(false);
258 | const [error, setError] = useState(null);
259 |
260 | const resetImageOptions = () => {
261 | setPage(1);
262 | setAddedImages([]);
263 | setSelectedImage(null);
264 | setUploading(false);
265 | setError(null);
266 | };
267 |
268 | const navigateToImagePreview = (imgIndex: number, url: string) => {
269 | setSelectedImage({ index: imgIndex, url: url });
270 | };
271 |
272 | const handleAddImage = async () => {
273 | if (selectedImage && !addedImages.includes(selectedImage.index)) {
274 | setUploading(true);
275 | try {
276 | if (!selectedSite) {
277 | throw new Error("No site selected");
278 | }
279 | const response = await postImage(selectedImage.url, selectedSite.id);
280 | if (response.error) {
281 | throw new Error(response.error);
282 | }
283 | setAddedImages([...addedImages, selectedImage.index]);
284 | setSelectedImage(null);
285 | setError(null);
286 | } catch (error: any) {
287 | setError(error.message);
288 | } finally {
289 | setUploading(false);
290 | }
291 | }
292 | };
293 |
294 | async function postImage(imageURL: string, siteId: string) {
295 | const response = await fetch(`${BACKEND_URL}/api/images`, {
296 | method: "POST",
297 | headers: {
298 | "Content-Type": "application/json",
299 | },
300 | body: JSON.stringify({ imageURL, siteId, auth: token }),
301 | });
302 |
303 | if (!response.ok) {
304 | throw new Error(`HTTP error! status: ${response.status}`);
305 | }
306 |
307 | const data = await response.json();
308 | return data;
309 | }
310 |
311 | const currentPageImages = images.slice(
312 | (page - 1) * imagesPerPage,
313 | page * imagesPerPage
314 | );
315 |
316 | return (
317 |
318 | {!selectedImage ? (
319 |
320 |
321 |
322 | Select Your Custom Images
323 |
324 |
325 |
326 | {currentPageImages.map((img, i) => (
327 |
328 |
{
331 | const index = i + 1 + (page - 1) * 4;
332 | navigateToImagePreview(index, img.url);
333 | }}
334 | onKeyDown={(e) => {
335 | // Check if the key is -Enter-;
336 | if (e.key === "Enter") {
337 | const index = i + 1 + (page - 1) * 4;
338 | navigateToImagePreview(index, img.url);
339 | }
340 | }}
341 | >
342 |
349 |
350 | {addedImages.includes(i + 1 + (page - 1) * 4) && (
351 |
352 | Added!
353 |
354 | )}
355 |
356 | ))}
357 |
358 | {pageLength > 1 && (
359 |
360 | setPage(page - 1)}>
361 | Prev
362 |
363 |
364 | Page {page} of {pageLength}
365 |
366 | setPage(page + 1)}
369 | >
370 | Next
371 |
372 |
373 | )}
374 |
375 | ) : (
376 |
377 |
384 |
385 | setSelectedImage(null)}>Go Back
386 |
387 | {uploading ? "Uploading..." : "Add"}
388 |
389 |
390 | {error &&
Error: {error}
}
391 |
392 | )}
393 |
394 | {
397 | resetImageOptions();
398 | resetUserInput();
399 | }}
400 | >
401 | Start Over
402 |
403 |
404 |
405 | );
406 | };
407 |
408 | export default MainPage;
409 |
--------------------------------------------------------------------------------
/designer-extension/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "typeRoots": [
23 | "./node_modules/@types",
24 | "./node_modules/@webflow"
25 | ],
26 | "paths": {
27 | "@/*": ["./src/*"]
28 | }
29 | },
30 | "include": ["config/next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
31 | "exclude": ["node_modules"]
32 | }
33 |
--------------------------------------------------------------------------------
/designer-extension/webflow.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "devflow-party-ext",
3 | "publicDir": "dist"
4 | }
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hybrid-app-example",
3 | "version": "1.0.0",
4 | "description": "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.",
5 | "scripts": {
6 | "test": "echo \"Error: no test specified\" && exit 1",
7 | "server": "cd data-client && npm install && npm run dev",
8 | "client": "cd designer-extension && npm install && npm run dev",
9 | "dev": "concurrently \"npm run client\" \"npm run server\""
10 | },
11 | "author": "",
12 | "license": "ISC",
13 | "dependencies": {
14 | "@webflow/webflow-cli": "^1.6.2",
15 | "concurrently": "^8.2.1",
16 | "dotenv": "^16.3.1",
17 | "webflow-api": "^1.3.0"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/public/Large GIF (1184x674).gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Webflow-Examples/hybrid-app-example/b792bdf46b5b92e48a902505952e6fcb63d40bfa/public/Large GIF (1184x674).gif
--------------------------------------------------------------------------------
/public/assets-panel.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Webflow-Examples/hybrid-app-example/b792bdf46b5b92e48a902505952e6fcb63d40bfa/public/assets-panel.png
--------------------------------------------------------------------------------
/public/authenticated-xp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Webflow-Examples/hybrid-app-example/b792bdf46b5b92e48a902505952e6fcb63d40bfa/public/authenticated-xp.png
--------------------------------------------------------------------------------
/public/authentication-screen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Webflow-Examples/hybrid-app-example/b792bdf46b5b92e48a902505952e6fcb63d40bfa/public/authentication-screen.png
--------------------------------------------------------------------------------
/public/designer-extension-details.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Webflow-Examples/hybrid-app-example/b792bdf46b5b92e48a902505952e6fcb63d40bfa/public/designer-extension-details.png
--------------------------------------------------------------------------------
/public/edit-app.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Webflow-Examples/hybrid-app-example/b792bdf46b5b92e48a902505952e6fcb63d40bfa/public/edit-app.png
--------------------------------------------------------------------------------
/public/login-prompt.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Webflow-Examples/hybrid-app-example/b792bdf46b5b92e48a902505952e6fcb63d40bfa/public/login-prompt.png
--------------------------------------------------------------------------------
/public/open-designer-extension.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Webflow-Examples/hybrid-app-example/b792bdf46b5b92e48a902505952e6fcb63d40bfa/public/open-designer-extension.png
--------------------------------------------------------------------------------