├── .eslintrc.json ├── demo.gif ├── app ├── favicon.ico ├── globals.css ├── layout.tsx ├── api │ └── toHtml │ │ └── route.ts └── page.tsx ├── next.config.js ├── postcss.config.js ├── lib ├── blobToBase64.ts ├── getBrowserCanvasMaxSize.ts ├── getSvgAsImage.ts └── png.ts ├── .gitignore ├── tailwind.config.ts ├── public ├── vercel.svg └── next.svg ├── .github └── workflows │ └── ci.yml ├── tsconfig.json ├── package.json ├── LICENSE ├── README.md └── components └── PreviewModal.tsx /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code/app-draw-a-ui/main/demo.gif -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code/app-draw-a-ui/main/app/favicon.ico -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | module.exports = nextConfig 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .tlui-help-menu { 6 | display: none !important; 7 | } 8 | 9 | .tlui-debug-panel { 10 | display: none !important; 11 | } 12 | -------------------------------------------------------------------------------- /lib/blobToBase64.ts: -------------------------------------------------------------------------------- 1 | export function blobToBase64(blob: Blob) { 2 | return new Promise((resolve, _) => { 3 | const reader = new FileReader(); 4 | reader.onloadend = () => resolve(reader.result); 5 | reader.readAsDataURL(blob); 6 | }); 7 | } 8 | -------------------------------------------------------------------------------- /.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 | .yarn/install-state.gz 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 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | const config: Config = { 4 | content: [ 5 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 13 | 'gradient-conic': 14 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | } 20 | export default config 21 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build and Lint 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build-and-lint: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v2 18 | with: 19 | node-version: "20" # Specify the Node.js version 20 | 21 | - name: Install dependencies 22 | run: npm install 23 | 24 | - name: Run build 25 | run: npm run build 26 | 27 | - name: Run lint 28 | run: npm run lint 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const inter = Inter({ subsets: ["latin"] }); 6 | 7 | export const metadata: Metadata = { 8 | title: "Create Next App", 9 | description: "Generated by create next app", 10 | }; 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: { 15 | children: React.ReactNode; 16 | }) { 17 | return ( 18 | 19 | 20 | 24 | 25 | {children} 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /lib/getBrowserCanvasMaxSize.ts: -------------------------------------------------------------------------------- 1 | import canvasSize from "canvas-size"; 2 | 3 | export type CanvasMaxSize = { 4 | maxWidth: number; 5 | maxHeight: number; 6 | maxArea: number; 7 | }; 8 | 9 | let maxSizePromise: Promise | null = null; 10 | 11 | export function getBrowserCanvasMaxSize() { 12 | if (!maxSizePromise) { 13 | maxSizePromise = calculateBrowserCanvasMaxSize(); 14 | } 15 | 16 | return maxSizePromise; 17 | } 18 | 19 | async function calculateBrowserCanvasMaxSize(): Promise { 20 | const maxWidth = await canvasSize.maxWidth({ usePromise: true }); 21 | const maxHeight = await canvasSize.maxHeight({ usePromise: true }); 22 | const maxArea = await canvasSize.maxArea({ usePromise: true }); 23 | return { 24 | maxWidth: maxWidth.width, 25 | maxHeight: maxHeight.height, 26 | maxArea: maxArea.width * maxArea.height, 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "draw-a-ui", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@tldraw/tldraw": "2.0.0-alpha.17", 13 | "canvas-size": "^1.2.6", 14 | "next": "^14.2.3", 15 | "openai": "^4.47.1", 16 | "prismjs": "^1.29.0", 17 | "react": "^18.3.1", 18 | "react-dom": "^18.3.1" 19 | }, 20 | "devDependencies": { 21 | "@types/canvas-size": "^1.2.1", 22 | "@types/node": "^20", 23 | "@types/prismjs": "^1.26.3", 24 | "@types/react": "^18", 25 | "@types/react-dom": "^18", 26 | "autoprefixer": "^10.0.1", 27 | "eslint": "^8", 28 | "eslint-config-next": "^14.2.3", 29 | "postcss": "^8", 30 | "tailwindcss": "^3.3.0", 31 | "typescript": "^5" 32 | }, 33 | "engines": { 34 | "node": ">=18.0.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Sawyer Hood 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # draw-a-ui 2 | 3 | This is an app that uses tldraw and the gpt-4-vision api to generate html based on a wireframe you draw. 4 | 5 | > The spiritual successor to this project is [Terragon Labs](https://terragonlabs.com). 6 | 7 | ![A demo of the app](./demo.gif) 8 | 9 | This works by just taking the current canvas SVG, converting it to a PNG, and sending that png to gpt-4-vision with instructions to return a single html file with tailwind. 10 | 11 | > Disclaimer: This is a demo and is not intended for production use. It doesn't have any auth so you will go broke if you deploy it. 12 | 13 | ## Getting Started 14 | 15 | This is a Next.js app. To get started run the following commands in the root directory of the project. You will need an OpenAI API key with access to the GPT-4 Vision API. 16 | 17 | > Note this uses Next.js 14 and requires a version of `node` greater than 18.17. [Read more here](https://nextjs.org/docs/pages/building-your-application/upgrading/version-14). 18 | 19 | ```bash 20 | echo "OPENAI_API_KEY=sk-your-key" > .env.local 21 | npm install 22 | npm run dev 23 | ``` 24 | 25 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 26 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/api/toHtml/route.ts: -------------------------------------------------------------------------------- 1 | import { OpenAI } from "openai"; 2 | 3 | const systemPrompt = `You are an expert tailwind developer. A user will provide you with a 4 | low-fidelity wireframe of an application and you will return 5 | a single html file that uses tailwind to create the website. Use creative license to make the application more fleshed out. 6 | if you need to insert an image, use placehold.co to create a placeholder image. Respond only with the html file.`; 7 | 8 | export async function POST(request: Request) { 9 | const openai = new OpenAI(); 10 | const { image } = await request.json(); 11 | 12 | const resp = await openai.chat.completions.create({ 13 | model: "gpt-4o", 14 | max_tokens: 4096, 15 | messages: [ 16 | { 17 | role: "system", 18 | content: systemPrompt, 19 | }, 20 | { 21 | role: "user", 22 | content: [ 23 | { 24 | type: "image_url", 25 | image_url: { url: image, detail: "high" }, 26 | }, 27 | { 28 | type: "text", 29 | text: "Turn this into a single html file using tailwind.", 30 | }, 31 | ], 32 | }, 33 | ], 34 | }); 35 | 36 | return new Response(JSON.stringify(resp), { 37 | headers: { 38 | "content-type": "application/json; charset=UTF-8", 39 | }, 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /components/PreviewModal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { use, useEffect, useState } from "react"; 4 | import Prism from "prismjs"; 5 | import "prismjs/components/prism-cshtml"; 6 | 7 | import "prismjs/themes/prism-tomorrow.css"; 8 | 9 | export function PreviewModal({ 10 | html, 11 | setHtml, 12 | }: { 13 | html: string | null; 14 | setHtml: (html: string | null) => void; 15 | }) { 16 | const [activeTab, setActiveTab] = useState<"preview" | "code">("preview"); 17 | 18 | useEffect(() => { 19 | const highlight = async () => { 20 | await Prism.highlightAll(); // <--- prepare Prism 21 | }; 22 | highlight(); // <--- call the async function 23 | }, [html, activeTab]); // <--- run when post updates 24 | 25 | if (!html) { 26 | return null; 27 | } 28 | 29 | return ( 30 |
{ 32 | e.stopPropagation(); 33 | }} 34 | className="bg-white rounded-lg shadow-xl flex flex-col" 35 | style={{ 36 | width: "calc(100% - 64px)", 37 | height: "calc(100% - 64px)", 38 | }} 39 | > 40 |
41 |
42 | { 45 | setActiveTab("preview"); 46 | }} 47 | > 48 | Preview 49 | 50 | { 53 | setActiveTab("code"); 54 | }} 55 | > 56 | Code 57 | 58 |
59 | 60 | 82 |
83 | 84 | {activeTab === "preview" ? ( 85 |