Skip to content

Commit b5109a9

Browse files
committed
cleanup a bit, add question button
1 parent 7f672d9 commit b5109a9

File tree

8 files changed

+6682
-613
lines changed

8 files changed

+6682
-613
lines changed

app/components/APIKeyInput.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useBreakpoint } from '@tldraw/tldraw'
1+
import { Icon, useBreakpoint } from '@tldraw/tldraw'
22
import { ChangeEvent, useCallback } from 'react'
33

44
export function APIKeyInput() {
@@ -14,14 +14,23 @@ export function APIKeyInput() {
1414
return (
1515
<div className={`your-own-api-key ${breakpoint < 5 ? 'your-own-api-key__mobile' : ''}`}>
1616
<div className="your-own-api-key__inner">
17-
<div>Have your own OpenAI API key?</div>
1817
<div className="input__wrapper">
1918
<input
2019
id="openai_key_risky_but_cool"
2120
defaultValue={localStorage.getItem('makeitreal_key') ?? ''}
2221
onChange={handleChange}
2322
/>
2423
</div>
24+
<button
25+
className="question__button"
26+
onClick={() =>
27+
window.alert(
28+
`Sorry, this is weird. The OpenAI APIs that we use are very new. If you have an OpenAI developer key, you can put it in this input and we'll use it. We don't save / store / upload these.\n\nSee https://platform.openai.com/api-keys to get a key.\n\nThis app's source code: https://github.com/tldraw/draw-a-ui`
29+
)
30+
}
31+
>
32+
<Icon icon="question" />
33+
</button>
2534
</div>
2635
</div>
2736
)

app/components/ExportButton.tsx

Lines changed: 3 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -2,137 +2,19 @@ import { useEditor, getSvgAsImage, useToasts, createShapeId } from '@tldraw/tldr
22
import { useState } from 'react'
33
import { PreviewShape } from '../PreviewShape/PreviewShape'
44
import { getHtmlFromOpenAI } from '../lib/getHtmlFromOpenAI'
5+
import { useMakeReal } from '../hooks/useMakeReal'
56

67
export function ExportButton() {
7-
const editor = useEditor()
8-
const toast = useToasts()
8+
const makeReal = useMakeReal()
99

1010
// A tailwind styled button that is pinned to the bottom right of the screen
1111
return (
1212
<button
13-
onClick={async (e) => {
14-
const newShapeId = createShapeId()
15-
16-
try {
17-
e.preventDefault()
18-
19-
const selectedShapes = editor.getSelectedShapes()
20-
21-
if (selectedShapes.length === 0) {
22-
toast.addToast({
23-
title: 'Nothing selected',
24-
description: 'First select something to make real.',
25-
id: 'nothing_selected: First select something to make real.',
26-
})
27-
return
28-
}
29-
30-
const previewPosition = selectedShapes.reduce(
31-
(acc, shape) => {
32-
const bounds = editor.getShapePageBounds(shape)
33-
const right = bounds?.maxX ?? 0
34-
const top = bounds?.minY ?? 0
35-
return {
36-
x: Math.max(acc.x, right),
37-
y: Math.min(acc.y, top),
38-
}
39-
},
40-
{ x: 0, y: Infinity }
41-
)
42-
43-
const previousPreviews = selectedShapes.filter((shape) => {
44-
return shape.type === 'preview'
45-
}) as PreviewShape[]
46-
47-
if (previousPreviews.length > 1) {
48-
toast.addToast({
49-
title: 'Too many previous designs',
50-
description:
51-
'Currently, you can only give the developer one previous design to work with.',
52-
id: 'too_many_previous_designs',
53-
})
54-
return
55-
}
56-
57-
const previousHtml =
58-
previousPreviews.length === 1
59-
? previousPreviews[0].props.html
60-
: 'No previous design has been provided this time.'
61-
62-
const svg = await editor.getSvg(selectedShapes)
63-
if (!svg) {
64-
return
65-
}
66-
67-
const IS_SAFARI = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
68-
69-
const blob = await getSvgAsImage(svg, IS_SAFARI, {
70-
type: 'png',
71-
quality: 1,
72-
scale: 1,
73-
})
74-
75-
const dataUrl = await blobToBase64(blob!)
76-
77-
editor.createShape<PreviewShape>({
78-
id: newShapeId,
79-
type: 'preview',
80-
x: previewPosition.x + 60,
81-
y: previewPosition.y,
82-
props: { html: '', source: dataUrl as string },
83-
})
84-
85-
const json = await getHtmlFromOpenAI({
86-
image: dataUrl,
87-
html: previousHtml,
88-
apiKey:
89-
(document.getElementById('openai_key_risky_but_cool') as HTMLInputElement)?.value ??
90-
null,
91-
})
92-
93-
if (json.error) {
94-
console.error(json)
95-
toast.addToast({
96-
icon: 'cross-2',
97-
title: 'OpenAI API Error',
98-
description: `${json.error.message?.slice(0, 100)}...`,
99-
})
100-
editor.deleteShape(newShapeId)
101-
return
102-
}
103-
104-
const message = json.choices[0].message.content
105-
const start = message.indexOf('<!DOCTYPE html>')
106-
const end = message.indexOf('</html>')
107-
const html = message.slice(start, end + '</html>'.length)
108-
109-
editor.updateShape<PreviewShape>({
110-
id: newShapeId,
111-
type: 'preview',
112-
props: { html, source: dataUrl as string },
113-
})
114-
} catch (e: any) {
115-
console.error(e)
116-
toast.addToast({
117-
icon: 'cross-2',
118-
title: 'Error',
119-
description: `Something went wrong: ${e.message.slice(0, 100)}`,
120-
})
121-
editor.deleteShape(newShapeId)
122-
}
123-
}}
13+
onClick={makeReal}
12414
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded m-2"
12515
style={{ cursor: 'pointer', zIndex: 100000, pointerEvents: 'all' }}
12616
>
12717
Make Real
12818
</button>
12919
)
13020
}
131-
132-
export function blobToBase64(blob: Blob): Promise<string> {
133-
return new Promise((resolve, _) => {
134-
const reader = new FileReader()
135-
reader.onloadend = () => resolve(reader.result as string)
136-
reader.readAsDataURL(blob)
137-
})
138-
}

app/globals.css

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,13 @@
3434
display: flex;
3535
font-size: 12px;
3636
font-family: Inter, sans-serif;
37-
flex-direction: column;
37+
flex-direction: row;
3838
max-width: 300px;
3939
width: 100%;
40-
gap: 8px;
40+
gap: 4px;
4141
background-color: var(--color-low);
4242
border-radius: 8px;
43-
padding: 8px;
43+
padding: 6px;
4444
pointer-events: all;
4545
}
4646

@@ -59,10 +59,12 @@
5959
color: transparent;
6060
background: var(--color-panel);
6161
width: 100%;
62+
height: 32px;
6263
}
6364

6465
.input__wrapper {
6566
position: relative;
67+
flex-grow: 2;
6668
}
6769

6870
.input__wrapper:not(:focus-within)::after {
@@ -95,3 +97,16 @@
9597
.lockup__link__mobile {
9698
bottom: 60px;
9799
}
100+
101+
.question__button {
102+
all: unset;
103+
flex-shrink: 0;
104+
width: 32px;
105+
height: 32px;
106+
background-color: none;
107+
border-radius: 4px;
108+
display: flex;
109+
align-items: center;
110+
justify-content: center;
111+
cursor: pointer;
112+
}

app/hooks/useMakeReal.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { useEditor, useToasts } from '@tldraw/tldraw'
2+
import { useCallback } from 'react'
3+
import { makeReal } from '../lib/makeReal'
4+
5+
export function useMakeReal() {
6+
const editor = useEditor()
7+
const toast = useToasts()
8+
9+
return useCallback(async () => {
10+
const input = document.getElementById('openai_key_risky_but_cool') as HTMLInputElement
11+
const apiKey = input?.value ?? null
12+
try {
13+
await makeReal(editor, apiKey)
14+
} catch (e: any) {
15+
console.error(e)
16+
toast.addToast({
17+
icon: 'cross-2',
18+
title: 'Something went wrong',
19+
description: `${e.message.slice(0, 100)}`,
20+
})
21+
}
22+
}, [editor, toast])
23+
}

app/lib/makeReal.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { Editor, createShapeId, getSvgAsImage } from '@tldraw/tldraw'
2+
import { PreviewShape } from '../PreviewShape/PreviewShape'
3+
import { getHtmlFromOpenAI } from './getHtmlFromOpenAI'
4+
5+
export async function makeReal(editor: Editor, apiKey: string) {
6+
const newShapeId = createShapeId()
7+
const selectedShapes = editor.getSelectedShapes()
8+
9+
if (selectedShapes.length === 0) {
10+
throw Error('First select something to make real.')
11+
}
12+
13+
const previewPosition = selectedShapes.reduce(
14+
(acc, shape) => {
15+
const bounds = editor.getShapePageBounds(shape)
16+
const right = bounds?.maxX ?? 0
17+
const top = bounds?.minY ?? 0
18+
return {
19+
x: Math.max(acc.x, right),
20+
y: Math.min(acc.y, top),
21+
}
22+
},
23+
{ x: 0, y: Infinity }
24+
)
25+
26+
const previousPreviews = selectedShapes.filter((shape) => {
27+
return shape.type === 'preview'
28+
}) as PreviewShape[]
29+
30+
if (previousPreviews.length > 1) {
31+
throw Error(`You can only have one previous design selected.`)
32+
}
33+
34+
const previousHtml =
35+
previousPreviews.length === 1
36+
? previousPreviews[0].props.html
37+
: 'No previous design has been provided this time.'
38+
39+
const svg = await editor.getSvg(selectedShapes)
40+
if (!svg) throw Error(`Could not get the SVG.`)
41+
42+
const IS_SAFARI = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
43+
44+
const blob = await getSvgAsImage(svg, IS_SAFARI, {
45+
type: 'png',
46+
quality: 1,
47+
scale: 1,
48+
})
49+
50+
const dataUrl = await blobToBase64(blob!)
51+
52+
editor.createShape<PreviewShape>({
53+
id: newShapeId,
54+
type: 'preview',
55+
x: previewPosition.x + 60,
56+
y: previewPosition.y,
57+
props: { html: '', source: dataUrl as string },
58+
})
59+
60+
try {
61+
const json = await getHtmlFromOpenAI({
62+
image: dataUrl,
63+
html: previousHtml,
64+
apiKey,
65+
})
66+
67+
if (json.error) {
68+
throw Error(`${json.error.message?.slice(0, 100)}...`)
69+
}
70+
71+
const message = json.choices[0].message.content
72+
const start = message.indexOf('<!DOCTYPE html>')
73+
const end = message.indexOf('</html>')
74+
const html = message.slice(start, end + '</html>'.length)
75+
76+
editor.updateShape<PreviewShape>({
77+
id: newShapeId,
78+
type: 'preview',
79+
props: { html, source: dataUrl as string },
80+
})
81+
} catch (e) {
82+
editor.deleteShape(newShapeId)
83+
throw e
84+
}
85+
}
86+
87+
export function blobToBase64(blob: Blob): Promise<string> {
88+
return new Promise((resolve, _) => {
89+
const reader = new FileReader()
90+
reader.onloadend = () => resolve(reader.result as string)
91+
reader.readAsDataURL(blob)
92+
})
93+
}

0 commit comments

Comments
 (0)