diff --git a/README.md b/README.md index 80964f8..dba1217 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ See the [Examples](#examples) section below for more demos. ## 🌟 Recent Updates +- Nov 20 - Paste in a URL to screenshot and replicate (requires [ScreenshotOne free API key](https://screenshotone.com?via=screenshot-to-code)) - Nov 19 - Support for dark/light code editor theme - thanks https://github.com/kachbit - Nov 16 - Added a setting to disable DALL-E image generation if you don't need that - Nov 16 - View code directly within the app diff --git a/backend/main.py b/backend/main.py index c6e5556..4eb09fa 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,5 +1,6 @@ # Load environment variables first from dotenv import load_dotenv +from pydantic import BaseModel load_dotenv() @@ -9,19 +10,35 @@ import os import traceback from datetime import datetime from fastapi import FastAPI, WebSocket - +from fastapi.middleware.cors import CORSMiddleware from llm import stream_openai_response from mock import mock_completion from image_generation import create_alt_url_mapping, generate_images from prompts import assemble_prompt +from routes import screenshot + +app = FastAPI(openapi_url=None, docs_url=None, redoc_url=None) + +# Configure CORS + +# Configure CORS settings +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) -app = FastAPI() # Useful for debugging purposes when you don't want to waste GPT4-Vision credits # Setting to True will stream a mock response instead of calling the OpenAI API SHOULD_MOCK_AI_RESPONSE = False +app.include_router(screenshot.router) + + def write_logs(prompt_messages, completion): # Get the logs path from environment, default to the current working directory logs_path = os.environ.get("LOGS_PATH", os.getcwd()) diff --git a/backend/poetry.lock b/backend/poetry.lock index 7a623b9..29e7937 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -471,4 +471,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "5e4aa03dda279f66a9b3d30f7327109bcfd395795470d95f8c563897ce1bff84" +content-hash = "b8d248a44a5eea9638a7726096de77d7a9aa8c00673da806534da2c228ffabb4" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 9aa721d..cacdda9 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -13,6 +13,7 @@ websockets = "^12.0" openai = "^1.2.4" python-dotenv = "^1.0.0" beautifulsoup4 = "^4.12.2" +httpx = "^0.25.1" [build-system] requires = ["poetry-core"] diff --git a/backend/routes/screenshot.py b/backend/routes/screenshot.py new file mode 100644 index 0000000..7efcfb8 --- /dev/null +++ b/backend/routes/screenshot.py @@ -0,0 +1,64 @@ +import base64 +from fastapi import APIRouter +from pydantic import BaseModel +import httpx + +router = APIRouter() + + +def bytes_to_data_url(image_bytes: bytes, mime_type: str) -> str: + base64_image = base64.b64encode(image_bytes).decode("utf-8") + return f"data:{mime_type};base64,{base64_image}" + + +async def capture_screenshot(target_url, api_key, device="desktop") -> bytes: + api_base_url = "https://api.screenshotone.com/take" + + params = { + "access_key": api_key, + "url": target_url, + "full_page": "true", + "device_scale_factor": "1", + "format": "png", + "block_ads": "true", + "block_cookie_banners": "true", + "block_trackers": "true", + "cache": "false", + "viewport_width": "342", + "viewport_height": "684", + } + + if device == "desktop": + params["viewport_width"] = "1280" + params["viewport_height"] = "832" + + async with httpx.AsyncClient(timeout=60) as client: + response = await client.get(api_base_url, params=params) + if response.status_code == 200 and response.content: + return response.content + else: + raise Exception("Error taking screenshot") + + +class ScreenshotRequest(BaseModel): + url: str + apiKey: str + + +class ScreenshotResponse(BaseModel): + url: str + + +@router.post("/api/screenshot") +async def app_screenshot(request: ScreenshotRequest): + # Extract the URL from the request body + url = request.url + api_key = request.apiKey + + # TODO: Add error handling + image_bytes = await capture_screenshot(url, api_key=api_key) + + # Convert the image bytes to a data url + data_url = bytes_to_data_url(image_bytes, "image/png") + + return ScreenshotResponse(url=data_url) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 72bd672..ea095c0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ import { IS_RUNNING_ON_CLOUD } from "./config"; import { PicoBadge } from "./components/PicoBadge"; import { OnboardingNote } from "./components/OnboardingNote"; import { usePersistedState } from "./hooks/usePersistedState"; +import { UrlInputSection } from "./components/UrlInputSection"; function App() { const [appState, setAppState] = useState<"INITIAL" | "CODING" | "CODE_READY">( @@ -32,11 +33,15 @@ function App() { const [executionConsole, setExecutionConsole] = useState([]); const [updateInstruction, setUpdateInstruction] = useState(""); const [history, setHistory] = useState([]); - const [settings, setSettings] = usePersistedState({ - openAiApiKey: null, - isImageGenerationEnabled: true, - editorTheme: "cobalt" - }, 'setting'); + const [settings, setSettings] = usePersistedState( + { + openAiApiKey: null, + screenshotOneApiKey: null, + isImageGenerationEnabled: true, + editorTheme: "cobalt", + }, + "setting" + ); const downloadCode = () => { // Create a blob from the generated code @@ -203,9 +208,13 @@ function App() {
{appState === "INITIAL" && ( - <> +
- + +
)} {(appState === "CODING" || appState === "CODE_READY") && ( @@ -232,7 +241,10 @@ function App() { - + diff --git a/frontend/src/components/SettingsDialog.tsx b/frontend/src/components/SettingsDialog.tsx index 33dd401..9b46b90 100644 --- a/frontend/src/components/SettingsDialog.tsx +++ b/frontend/src/components/SettingsDialog.tsx @@ -74,6 +74,33 @@ function SettingsDialog({ settings, setSettings }: Props) { })) } /> + + + + + setSettings((s) => ({ + ...s, + screenshotOneApiKey: e.target.value, + })) + } + /> + diff --git a/frontend/src/components/UrlInputSection.tsx b/frontend/src/components/UrlInputSection.tsx new file mode 100644 index 0000000..20e9c54 --- /dev/null +++ b/frontend/src/components/UrlInputSection.tsx @@ -0,0 +1,87 @@ +import { useState } from "react"; +import { HTTP_BACKEND_URL } from "../config"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { toast } from "react-hot-toast"; + +interface Props { + screenshotOneApiKey: string | null; + doCreate: (urls: string[]) => void; +} + +export function UrlInputSection({ doCreate, screenshotOneApiKey }: Props) { + const [isLoading, setIsLoading] = useState(false); + const [referenceUrl, setReferenceUrl] = useState(""); + const isDisabled = !screenshotOneApiKey; + + async function takeScreenshot() { + if (!screenshotOneApiKey) { + toast.error("Please add a Screenshot API key in the settings dialog"); + return; + } + + if (!referenceUrl) { + toast.error("Please enter a URL"); + return; + } + + if (referenceUrl) { + try { + setIsLoading(true); + const response = await fetch(`${HTTP_BACKEND_URL}/api/screenshot`, { + method: "POST", + body: JSON.stringify({ + url: referenceUrl, + apiKey: screenshotOneApiKey, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error("Failed to capture screenshot"); + } + + const res = await response.json(); + doCreate([res.url]); + } catch (error) { + console.error(error); + toast.error( + "Failed to capture screenshot. Look at the console and your backend logs for more details." + ); + } finally { + setIsLoading(false); + } + } + } + + return ( +
+
Or screenshot a URL...
+ setReferenceUrl(e.target.value)} + value={referenceUrl} + /> + + {isDisabled && ( +
+ + To screenshot a URL, add a{" "} + + ScreenshotOne API key + {" "} + in the settings dialog. + +
+ )} +
+ ); +} diff --git a/frontend/src/config.ts b/frontend/src/config.ts index 054f6ed..7a6251c 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -1,3 +1,9 @@ // Default to false if set to anything other than "true" or unset export const IS_RUNNING_ON_CLOUD = import.meta.env.VITE_IS_DEPLOYED === "true" || false; + +export const WS_BACKEND_URL = + import.meta.env.VITE_WS_BACKEND_URL || "ws://127.0.0.1:7001"; + +export const HTTP_BACKEND_URL = + import.meta.env.VITE_HTTP_BACKEND_URL || "http://127.0.0.1:7001"; diff --git a/frontend/src/generateCode.ts b/frontend/src/generateCode.ts index 3b0f3d6..6fea92d 100644 --- a/frontend/src/generateCode.ts +++ b/frontend/src/generateCode.ts @@ -1,7 +1,6 @@ import toast from "react-hot-toast"; +import { WS_BACKEND_URL } from "./config"; -const WS_BACKEND_URL = - import.meta.env.VITE_WS_BACKEND_URL || "ws://127.0.0.1:7001"; const ERROR_MESSAGE = "Error generating code. Check the Developer Console for details. Feel free to open a Github issue"; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index bc66547..6f44e9f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,5 +1,6 @@ export interface Settings { openAiApiKey: string | null; + screenshotOneApiKey: string | null; isImageGenerationEnabled: boolean; editorTheme: string; }