├── .eslintrc.json ├── next.config.js ├── .prettierrc ├── app ├── lib │ ├── downloadDataUrlAsFile.ts │ ├── blobToBase64.ts │ ├── getSelectionAsText.ts │ ├── makeReal.tsx │ └── getHtmlFromOpenAI.ts ├── layout.tsx ├── page.tsx ├── components │ ├── MakeRealButton.tsx │ └── RiskyButCoolAPIKeyInput.tsx ├── globals.css ├── prompt.ts └── PreviewShape │ └── PreviewShape.tsx ├── .gitignore ├── package.json ├── tsconfig.json ├── .codesandbox └── tasks.json ├── .devcontainer └── devcontainer.json ├── README.md └── LICENSE /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | module.exports = nextConfig 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "singleQuote": true, 4 | "semi": false, 5 | "printWidth": 100, 6 | "tabWidth": 2, 7 | "useTabs": true 8 | } -------------------------------------------------------------------------------- /app/lib/downloadDataUrlAsFile.ts: -------------------------------------------------------------------------------- 1 | export function downloadDataURLAsFile(dataUrl: string, filename: string) { 2 | const link = document.createElement('a') 3 | link.href = dataUrl 4 | link.download = filename 5 | link.click() 6 | } 7 | -------------------------------------------------------------------------------- /app/lib/blobToBase64.ts: -------------------------------------------------------------------------------- 1 | export function blobToBase64(blob: Blob): Promise { 2 | return new Promise((resolve, _) => { 3 | const reader = new FileReader() 4 | reader.onloadend = () => resolve(reader.result as string) 5 | reader.readAsDataURL(blob) 6 | }) 7 | } 8 | -------------------------------------------------------------------------------- /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: 'make real starter', 9 | description: 'draw a website and make it real', 10 | } 11 | 12 | export default function RootLayout({ children }: { children: React.ReactNode }) { 13 | return ( 14 | 15 | {children} 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "make-real-template", 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 | "next": "14.0.3", 13 | "react": "^18", 14 | "react-dom": "^18", 15 | "tldraw": "^3.14.2" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "^20", 19 | "@types/react": "^18", 20 | "@types/react-dom": "^18", 21 | "eslint": "^8", 22 | "eslint-config-next": "14.0.3", 23 | "typescript": "^5" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /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/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import dynamic from 'next/dynamic' 4 | import 'tldraw/tldraw.css' 5 | import { MakeRealButton } from './components/MakeRealButton' 6 | import { RiskyButCoolAPIKeyInput } from './components/RiskyButCoolAPIKeyInput' 7 | import { PreviewShapeUtil } from './PreviewShape/PreviewShape' 8 | 9 | const Tldraw = dynamic(async () => (await import('tldraw')).Tldraw, { 10 | ssr: false, 11 | }) 12 | 13 | const shapeUtils = [PreviewShapeUtil] 14 | const components = { 15 | SharePanel: () => , 16 | } 17 | export default function App() { 18 | return ( 19 |
20 | 21 | 22 | 23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /.codesandbox/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // These tasks will run in order when initializing your CodeSandbox project. 3 | "setupTasks": [ 4 | { 5 | "name": "Install Dependencies", 6 | "command": "npm install" 7 | } 8 | ], 9 | 10 | // These tasks can be run from CodeSandbox. Running one will open a log in the app. 11 | "tasks": { 12 | "dev": { 13 | "name": "dev", 14 | "command": "npm run dev", 15 | "runAtStart": true, 16 | "preview": { 17 | "port": 3000 18 | } 19 | }, 20 | "build": { 21 | "name": "build", 22 | "command": "npm run build" 23 | }, 24 | "start": { 25 | "name": "start", 26 | "command": "npm run start" 27 | }, 28 | "lint": { 29 | "name": "lint", 30 | "command": "npm run lint" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/components/MakeRealButton.tsx: -------------------------------------------------------------------------------- 1 | import { useEditor, useToasts } from 'tldraw' 2 | import { useCallback } from 'react' 3 | import { makeReal } from '../lib/makeReal' 4 | 5 | export function MakeRealButton() { 6 | const editor = useEditor() 7 | const { addToast } = useToasts() 8 | 9 | const handleClick = useCallback(async () => { 10 | try { 11 | const input = document.getElementById('openai_key_risky_but_cool') as HTMLInputElement 12 | const apiKey = input?.value ?? null 13 | if (!apiKey) throw Error('Make sure you include your API Key!') 14 | await makeReal(editor, apiKey) 15 | } catch (e) { 16 | console.error(e) 17 | addToast({ 18 | icon: 'info-circle', 19 | title: 'Something went wrong', 20 | description: (e as Error).message.slice(0, 100), 21 | }) 22 | } 23 | }, [editor, addToast]) 24 | 25 | return ( 26 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node 3 | { 4 | "name": "Node.js & TypeScript", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bullseye" 7 | 8 | // Features to add to the dev container. More info: https://containers.dev/features. 9 | // "features": {}, 10 | 11 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 12 | // "forwardPorts": [], 13 | 14 | // Use 'postCreateCommand' to run commands after the container is created. 15 | // "postCreateCommand": "yarn install", 16 | 17 | // Configure tool-specific properties. 18 | // "customizations": {}, 19 | 20 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 21 | // "remoteUser": "root" 22 | } 23 | -------------------------------------------------------------------------------- /app/lib/getSelectionAsText.ts: -------------------------------------------------------------------------------- 1 | import { Editor, TLArrowShape, TLGeoShape, TLNoteShape, TLTextShape } from 'tldraw' 2 | 3 | export function getTextFromSelectedShapes(editor: Editor) { 4 | const selectedShapeIds = editor.getSelectedShapeIds() 5 | const selectedShapeDescendantIds = editor.getShapeAndDescendantIds(selectedShapeIds) 6 | 7 | const shapesWithText = Array.from(selectedShapeDescendantIds) 8 | .map((id) => { 9 | const shape = editor.getShape(id)! 10 | return shape 11 | }) 12 | .filter((shape) => { 13 | return ( 14 | shape.type === 'text' || 15 | shape.type === 'geo' || 16 | shape.type === 'arrow' || 17 | shape.type === 'note' 18 | ) 19 | }) as (TLTextShape | TLGeoShape | TLArrowShape | TLNoteShape)[] 20 | 21 | const texts = shapesWithText 22 | .sort((a, b) => { 23 | // top first, then left, based on page position 24 | const pageBoundsA = editor.getShapePageBounds(a) 25 | const pageBoundsB = editor.getShapePageBounds(b) 26 | if (!pageBoundsA || !pageBoundsB) return 0 27 | return pageBoundsA.y === pageBoundsB.y 28 | ? pageBoundsA.x < pageBoundsB.x 29 | ? -1 30 | : 1 31 | : pageBoundsA.y < pageBoundsB.y 32 | ? -1 33 | : 1 34 | }) 35 | .map((shape) => { 36 | const shapeUtil = editor.getShapeUtil(shape) 37 | const text = shapeUtil.getText(shape) 38 | if (shape.props.color === 'red') { 39 | return `Annotation: ${text}` 40 | } 41 | return text 42 | }) 43 | .filter((v) => !!v) 44 | 45 | return texts.join('\n') 46 | } 47 | -------------------------------------------------------------------------------- /app/components/RiskyButCoolAPIKeyInput.tsx: -------------------------------------------------------------------------------- 1 | import { TldrawUiIcon, useBreakpoint } from 'tldraw' 2 | import { ChangeEvent, useCallback } from 'react' 3 | 4 | export function RiskyButCoolAPIKeyInput() { 5 | const breakpoint = useBreakpoint() 6 | 7 | const handleChange = useCallback((e: ChangeEvent) => { 8 | localStorage.setItem('makeitreal_key', e.target.value) 9 | }, []) 10 | 11 | const handleQuestionMessage = useCallback(() => { 12 | window.alert( 13 | `If you have an OpenAI developer key, you can put it in this input and it will be used when posting to OpenAI.\n\nSee https://platform.openai.com/api-keys to get a key.\n\nPutting API keys into boxes is generally a bad idea! If you have any concerns, create an API key and then revoke it after using this site.` 14 | ) 15 | }, []) 16 | 17 | return ( 18 |
19 |
20 |
21 | 30 |
31 | 34 |
35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Make Real 2 | 3 | Use this repo as a template to create Make Real style apps like 4 | [makereal.tldraw.com](https://makereal.tldraw.com). To get started: 5 | 6 | 1. Use the template and clone your new repo to your computer 7 | 2. Run `npm install` to install dependencies 8 | 3. Get an OpenAI API key from [platform.openai.com/api-keys](https://platform.openai.com/api-keys). 9 | 4. Create a `.env.local` file that contains `NEXT_PUBLIC_OPENAI_API_KEY=your api key here` 10 | 5. Run `npm run dev` 11 | 6. Open [localhost:3000](http://localhost:3000) and make some stuff real! 12 | 13 | ## How it works 14 | 15 | Make Real is built with the [tldraw 16 | SDK](https://tldraw.dev/?utm_source=github&utm_medium=readme&utm_campaign=make-real), a very good 17 | React library for creating whiteboards and other infinite canvas experiences. 18 | 19 | To use it, first draw a mockup for a piece of UI. When you're ready, select the drawing, and press 20 | the Make Real button. We'll capture an image of your selection, and send it to 21 | [GPT](https://platform.openai.com/docs/guides/vision) along with instructions to turn it into a HTML 22 | file. 23 | 24 | We take the HTML response and add it to a tldraw [custom 25 | shape](https://tldraw.dev/docs/shapes#Custom-shapes). The custom shape shows the response in an 26 | iframe so that you can interact with it on the canvas. If you want to iterate on the response, 27 | annotate the iframe, select it all, and press 'Make Real' again. 28 | 29 | ## To make changes 30 | 31 | To change how Make Real works, start from the [`prompt.ts`](./app/prompt.ts) file. From there, you 32 | can change the prompt that gets sent to gpt-4. 33 | 34 | You can edit the `makeReal` function in [`makeReal.ts`](./app/lib/makeReal.tsx) to change what 35 | happens when you hit the Make Real button. 36 | 37 | If you'd like Make Real to create something other than HTML, you'll need to either update the 38 | [`PreviewShape`](./app/PreviewShape/PreviewShape.tsx) to display something different, or use one of 39 | tldraw's built-in shapes like image or text. 40 | 41 | ## The dangerous API key input method 42 | 43 | For prototyping, we've also included the `RiskyButCoolAPIKeyInput`, similar to the one found on 44 | [makereal.tldraw.com](https://makereal.tldraw.com). Please use this as carefully and ethically as 45 | you can, as users should be reluctant to add API keys to public sites. 46 | -------------------------------------------------------------------------------- /app/lib/makeReal.tsx: -------------------------------------------------------------------------------- 1 | import { Editor, createShapeId, getSvgAsImage, track } from 'tldraw' 2 | import { PreviewShape } from '../PreviewShape/PreviewShape' 3 | import { blobToBase64 } from './blobToBase64' 4 | import { getHtmlFromOpenAI } from './getHtmlFromOpenAI' 5 | import { getTextFromSelectedShapes } from './getSelectionAsText' 6 | 7 | export async function makeReal(editor: Editor, apiKey: string) { 8 | // Get the selected shapes (we need at least one) 9 | const selectedShapes = editor.getSelectedShapes() 10 | if (selectedShapes.length === 0) throw Error('First select something to make real.') 11 | 12 | // Create the preview shape 13 | const { maxX, midY } = editor.getSelectionPageBounds()! 14 | const newShapeId = createShapeId() 15 | editor.createShape({ 16 | id: newShapeId, 17 | type: 'response', 18 | x: maxX + 60, // to the right of the selection 19 | y: midY - (540 * 2) / 3 / 2, // half the height of the preview's initial shape 20 | props: { html: '' }, 21 | }) 22 | 23 | // Get a screenshot of the selected shapes 24 | const maxSize = 1000 25 | const bounds = editor.getSelectionPageBounds() 26 | if (!bounds) throw Error('Could not get bounds of selection.') 27 | const scale = Math.min(1, maxSize / bounds.width, maxSize / bounds.height) 28 | const { blob } = await editor.toImage(selectedShapes, { 29 | scale: scale, 30 | background: true, 31 | format: 'jpeg', 32 | }) 33 | const dataUrl = await blobToBase64(blob!) 34 | 35 | // Get any previous previews among the selected shapes 36 | const previousPreviews = selectedShapes.filter( 37 | (shape) => shape.type === 'response' 38 | ) as PreviewShape[] 39 | 40 | // Send everything to OpenAI and get some HTML back 41 | try { 42 | const json = await getHtmlFromOpenAI({ 43 | image: dataUrl, 44 | apiKey, 45 | text: getTextFromSelectedShapes(editor), 46 | previousPreviews, 47 | theme: editor.user.getUserPreferences().isDarkMode ? 'dark' : 'light', 48 | }) 49 | 50 | if (!json) throw Error('Could not contact OpenAI.') 51 | if (json?.error) throw Error(`${json.error.message?.slice(0, 128)}...`) 52 | 53 | // Extract the HTML from the response 54 | const message = json.choices[0].message.content 55 | const start = message.indexOf('') 56 | const end = message.indexOf('') 57 | const html = message.slice(start, end + ''.length) 58 | 59 | // No HTML? Something went wrong 60 | if (html.length < 100) { 61 | console.warn(message) 62 | throw Error('Could not generate a design from those wireframes.') 63 | } 64 | 65 | // Update the shape with the new props 66 | editor.updateShape({ 67 | id: newShapeId, 68 | type: 'response', 69 | props: { 70 | html, 71 | }, 72 | }) 73 | 74 | console.log(`Response: ${message}`) 75 | } catch (e) { 76 | // If anything went wrong, delete the shape. 77 | editor.deleteShape(newShapeId) 78 | throw e 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | .tlui-help-menu { 17 | display: none !important; 18 | } 19 | 20 | .tlui-debug-panel { 21 | display: none !important; 22 | } 23 | 24 | .editor { 25 | position: fixed; 26 | inset: 0; 27 | overflow: hidden; 28 | } 29 | 30 | .tldrawLogo { 31 | position: absolute; 32 | z-index: 9999999; 33 | bottom: 0px; 34 | right: 0px; 35 | } 36 | 37 | .tldrawLogo__mobile { 38 | bottom: 60px; 39 | } 40 | 41 | .makeRealButton { 42 | background: var(--color-primary); 43 | color: white; 44 | border: none; 45 | font: inherit; 46 | font-weight: 600; 47 | padding: var(--space-3) var(--space-4); 48 | border-radius: var(--radius-2); 49 | margin: var(--space-3); 50 | cursor: pointer; 51 | pointer-events: all; 52 | } 53 | .makeRealButton:hover { 54 | opacity: 0.95; 55 | } 56 | 57 | /* ------------------ Api Key Input ----------------- */ 58 | 59 | .your-own-api-key { 60 | position: absolute; 61 | bottom: 72px; 62 | right: 0px; 63 | width: 100%; 64 | display: flex; 65 | flex-direction: column; 66 | align-items: center; 67 | justify-content: center; 68 | z-index: 9999999; 69 | color: var(--color-text-0); 70 | pointer-events: none; 71 | } 72 | 73 | .your-own-api-key__inner { 74 | display: flex; 75 | flex-direction: row; 76 | max-width: 300px; 77 | width: 100%; 78 | gap: 4px; 79 | background-color: var(--color-low); 80 | border-radius: 8px; 81 | padding: 6px; 82 | pointer-events: all; 83 | } 84 | 85 | .your-own-api-key input { 86 | all: inset; 87 | padding: 6px 12px; 88 | border-radius: 4px; 89 | color: transparent; 90 | border: none; 91 | font-size: 12px; 92 | background: var(--color-panel); 93 | width: 100%; 94 | height: 32px; 95 | font-size: 12px; 96 | font-family: Inter, sans-serif; 97 | } 98 | 99 | .your-own-api-key__mobile { 100 | bottom: 108px; 101 | } 102 | .your-own-api-key__mobile input { 103 | bottom: 108px; 104 | font-size: 16px !important; 105 | pointer-events: all; 106 | } 107 | 108 | .input__wrapper { 109 | position: relative; 110 | flex-grow: 2; 111 | } 112 | 113 | .input__wrapper:not(:focus-within)::after { 114 | content: 'Your OpenAI API Key (risky but cool)'; 115 | display: block; 116 | position: absolute; 117 | inset: 0px; 118 | display: flex; 119 | align-items: center; 120 | justify-content: flex-start; 121 | padding: 0px 12px; 122 | z-index: 999999999; 123 | background-color: none; 124 | font-size: 12px; 125 | font-family: Inter, sans-serif; 126 | } 127 | 128 | .input__wrapper::after { 129 | pointer-events: none; 130 | } 131 | 132 | .your-own-api-key input:focus { 133 | color: var(--color-text-0); 134 | } 135 | 136 | .question__button { 137 | all: unset; 138 | flex-shrink: 0; 139 | width: 32px; 140 | height: 32px; 141 | background-color: none; 142 | border-radius: 4px; 143 | display: flex; 144 | align-items: center; 145 | justify-content: center; 146 | cursor: pointer; 147 | } 148 | -------------------------------------------------------------------------------- /app/prompt.ts: -------------------------------------------------------------------------------- 1 | export const SYSTEM_PROMPT = `You are an expert web developer who specializes in building working website prototypes from low-fidelity wireframes. Your job is to accept low-fidelity designs and turn them into high-fidelity interactive and responsive working prototypes. 2 | 3 | ## Your task 4 | 5 | When sent new designs, you should reply with a high-fidelity working prototype as a single HTML file. 6 | 7 | ## Important constraints 8 | 9 | - Your ENTIRE PROTOTYPE needs to be included in a single HTML file. 10 | - Your response MUST contain the entire HTML file contents. 11 | - Put any JavaScript in a 70 | ` 71 | ) 72 | 73 | return ( 74 | 75 | {htmlToUse ? ( 76 |