Merge branch 'main' into pr/122

This commit is contained in:
Abi Raja 2023-11-30 13:56:38 -05:00
commit 45a64326f6
19 changed files with 594 additions and 215 deletions

View File

@ -1,4 +1,4 @@
### Getting an OpenAI API key ### Getting an OpenAI API key with GPT4-Vision model access
You don't need a ChatGPT Pro account. Screenshot to code uses API keys from your OpenAI developer account. In order to get access to the GPT4 Vision model, log into your OpenAI account and then, follow these instructions: You don't need a ChatGPT Pro account. Screenshot to code uses API keys from your OpenAI developer account. In order to get access to the GPT4 Vision model, log into your OpenAI account and then, follow these instructions:
@ -10,6 +10,7 @@ You don't need a ChatGPT Pro account. Screenshot to code uses API keys from your
5. Go to Settings > Limits and check at the bottom of the page, your current tier has to be "Tier 1" to have GPT4 access 5. Go to Settings > Limits and check at the bottom of the page, your current tier has to be "Tier 1" to have GPT4 access
<img width="785" alt="285636973-da38bd4d-8a78-4904-8027-ca67d729b933" src="https://github.com/abi/screenshot-to-code/assets/23818/8d07cd84-0cf9-4f88-bc00-80eba492eadf"> <img width="785" alt="285636973-da38bd4d-8a78-4904-8027-ca67d729b933" src="https://github.com/abi/screenshot-to-code/assets/23818/8d07cd84-0cf9-4f88-bc00-80eba492eadf">
6. Go to Screenshot to code and paste it in the Settings dialog under OpenAI key (gear icon). Your key is only stored in your browser. Never stored on our servers.
Some users have also reported that it can take upto 30 minutes after your credit purchase for the GPT4 vision model to be activated. Some users have also reported that it can take upto 30 minutes after your credit purchase for the GPT4 vision model to be activated.

27
backend/access_token.py Normal file
View File

@ -0,0 +1,27 @@
import json
import os
import httpx
async def validate_access_token(access_code: str):
async with httpx.AsyncClient() as client:
url = (
"https://backend.buildpicoapps.com/screenshot_to_code/validate_access_token"
)
data = json.dumps(
{
"access_code": access_code,
"secret": os.environ.get("PICO_BACKEND_SECRET"),
}
)
headers = {"Content-Type": "application/json"}
response = await client.post(url, content=data, headers=headers)
response_data = response.json()
if response_data["success"]:
print("Access token is valid.")
return True
else:
print(f"Access token validation failed: {response_data['failure_reason']}")
return False

View File

@ -5,8 +5,8 @@ from openai import AsyncOpenAI
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
async def process_tasks(prompts, api_key): async def process_tasks(prompts, api_key, base_url):
tasks = [generate_image(prompt, api_key) for prompt in prompts] tasks = [generate_image(prompt, api_key, base_url) for prompt in prompts]
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
processed_results = [] processed_results = []
@ -20,8 +20,8 @@ async def process_tasks(prompts, api_key):
return processed_results return processed_results
async def generate_image(prompt, api_key): async def generate_image(prompt, api_key, base_url):
client = AsyncOpenAI(api_key=api_key) client = AsyncOpenAI(api_key=api_key, base_url=base_url)
image_params = { image_params = {
"model": "dall-e-3", "model": "dall-e-3",
"quality": "standard", "quality": "standard",
@ -60,7 +60,7 @@ def create_alt_url_mapping(code):
return mapping return mapping
async def generate_images(code, api_key, image_cache): async def generate_images(code, api_key, base_url, image_cache):
# Find all images # Find all images
soup = BeautifulSoup(code, "html.parser") soup = BeautifulSoup(code, "html.parser")
images = soup.find_all("img") images = soup.find_all("img")
@ -87,7 +87,7 @@ async def generate_images(code, api_key, image_cache):
return code return code
# Generate images # Generate images
results = await process_tasks(prompts, api_key) results = await process_tasks(prompts, api_key, base_url)
# Create a dict mapping alt text to image URL # Create a dict mapping alt text to image URL
mapped_image_urls = dict(zip(prompts, results)) mapped_image_urls = dict(zip(prompts, results))

View File

@ -6,9 +6,12 @@ MODEL_GPT_4_VISION = "gpt-4-vision-preview"
async def stream_openai_response( async def stream_openai_response(
messages, api_key: str, callback: Callable[[str], Awaitable[None]] messages,
api_key: str,
base_url: str | None,
callback: Callable[[str], Awaitable[None]],
): ):
client = AsyncOpenAI(api_key=api_key) client = AsyncOpenAI(api_key=api_key, base_url=base_url)
model = MODEL_GPT_4_VISION model = MODEL_GPT_4_VISION

View File

@ -1,6 +1,5 @@
# Load environment variables first # Load environment variables first
from dotenv import load_dotenv from dotenv import load_dotenv
from pydantic import BaseModel
load_dotenv() load_dotenv()
@ -16,6 +15,7 @@ from mock import mock_completion
from image_generation import create_alt_url_mapping, generate_images from image_generation import create_alt_url_mapping, generate_images
from prompts import assemble_prompt from prompts import assemble_prompt
from routes import screenshot from routes import screenshot
from access_token import validate_access_token
app = FastAPI(openapi_url=None, docs_url=None, redoc_url=None) app = FastAPI(openapi_url=None, docs_url=None, redoc_url=None)
@ -81,13 +81,27 @@ async def stream_code(websocket: WebSocket):
# Get the OpenAI API key from the request. Fall back to environment variable if not provided. # Get the OpenAI API key from the request. Fall back to environment variable if not provided.
# If neither is provided, we throw an error. # If neither is provided, we throw an error.
if params["openAiApiKey"]: openai_api_key = None
openai_api_key = params["openAiApiKey"] if "accessCode" in params and params["accessCode"]:
print("Using OpenAI API key from client-side settings dialog") print("Access code - using platform API key")
if await validate_access_token(params["accessCode"]):
openai_api_key = os.environ.get("PLATFORM_OPENAI_API_KEY")
else:
await websocket.send_json(
{
"type": "error",
"value": "Invalid access code or you're out of credits. Please try again.",
}
)
return
else: else:
openai_api_key = os.environ.get("OPENAI_API_KEY") if params["openAiApiKey"]:
if openai_api_key: openai_api_key = params["openAiApiKey"]
print("Using OpenAI API key from environment variable") print("Using OpenAI API key from client-side settings dialog")
else:
openai_api_key = os.environ.get("OPENAI_API_KEY")
if openai_api_key:
print("Using OpenAI API key from environment variable")
if not openai_api_key: if not openai_api_key:
print("OpenAI API key not found") print("OpenAI API key not found")
@ -99,6 +113,22 @@ async def stream_code(websocket: WebSocket):
) )
return return
# Get the OpenAI Base URL from the request. Fall back to environment variable if not provided.
openai_base_url = None
# Disable user-specified OpenAI Base URL in prod
if not os.environ.get("IS_PROD"):
if "openAiBaseURL" in params and params["openAiBaseURL"]:
openai_base_url = params["openAiBaseURL"]
print("Using OpenAI Base URL from client-side settings dialog")
else:
openai_base_url = os.environ.get("OPENAI_BASE_URL")
if openai_base_url:
print("Using OpenAI Base URL from environment variable")
if not openai_base_url:
print("Using official OpenAI URL")
# Get the image generation flag from the request. Fall back to True if not provided.
should_generate_images = ( should_generate_images = (
params["isImageGenerationEnabled"] params["isImageGenerationEnabled"]
if "isImageGenerationEnabled" in params if "isImageGenerationEnabled" in params
@ -137,6 +167,7 @@ async def stream_code(websocket: WebSocket):
completion = await stream_openai_response( completion = await stream_openai_response(
prompt_messages, prompt_messages,
api_key=openai_api_key, api_key=openai_api_key,
base_url=openai_base_url,
callback=lambda x: process_chunk(x), callback=lambda x: process_chunk(x),
) )
@ -149,7 +180,10 @@ async def stream_code(websocket: WebSocket):
{"type": "status", "value": "Generating images..."} {"type": "status", "value": "Generating images..."}
) )
updated_html = await generate_images( updated_html = await generate_images(
completion, api_key=openai_api_key, image_cache=image_cache completion,
api_key=openai_api_key,
base_url=openai_base_url,
image_cache=image_cache,
) )
else: else:
updated_html = completion updated_html = completion

View File

@ -21,6 +21,34 @@
<%- injectHead %> <%- injectHead %>
<title>Screenshot to Code</title> <title>Screenshot to Code</title>
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="Screenshot to Code" />
<meta
property="og:description"
content="Convert any screenshot or design to clean code"
/>
<meta
property="og:image"
content="https://screenshottocode.com/brand/twitter-summary-card.png"
/>
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="628" />
<meta property="og:url" content="https://screenshottocode.com" />
<meta property="og:type" content="website" />
<!-- Twitter Card tags -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@picoapps" />
<!-- Keep in sync with og:title, og:description and og:image -->
<meta name="twitter:title" content="Screenshot to Code" />
<meta
name="twitter:description"
content="Convert any screenshot or design to clean code"
/>
<meta
name="twitter:image"
content="https://screenshottocode.com/brand/twitter-summary-card.png"
/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -19,6 +19,7 @@
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-select": "^2.0.0", "@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

View File

@ -48,17 +48,19 @@ function App() {
const [settings, setSettings] = usePersistedState<Settings>( const [settings, setSettings] = usePersistedState<Settings>(
{ {
openAiApiKey: null, openAiApiKey: null,
openAiBaseURL: null,
screenshotOneApiKey: null, screenshotOneApiKey: null,
isImageGenerationEnabled: true, isImageGenerationEnabled: true,
editorTheme: EditorTheme.COBALT, editorTheme: EditorTheme.COBALT,
isTermOfServiceAccepted: false, isTermOfServiceAccepted: false,
accessCode: null,
}, },
"setting" "setting"
); );
const [outputSettings, setOutputSettings] = useState<OutputSettings>({ const [outputSettings, setOutputSettings] = useState<OutputSettings>({
css: CSSOption.TAILWIND, css: CSSOption.TAILWIND,
js: JSFrameworkOption.VANILLA,
components: UIComponentOption.HTML, components: UIComponentOption.HTML,
js: JSFrameworkOption.NO_FRAMEWORK,
}); });
const [shouldIncludeResultImage, setShouldIncludeResultImage] = const [shouldIncludeResultImage, setShouldIncludeResultImage] =
useState<boolean>(false); useState<boolean>(false);
@ -172,35 +174,33 @@ function App() {
}; };
return ( return (
<div className="mt-2"> <div className="mt-2 dark:bg-black dark:text-white">
{IS_RUNNING_ON_CLOUD && <PicoBadge />} {IS_RUNNING_ON_CLOUD && <PicoBadge settings={settings} />}
{IS_RUNNING_ON_CLOUD && ( {IS_RUNNING_ON_CLOUD && (
<TermsOfServiceDialog <TermsOfServiceDialog
open={!settings.isTermOfServiceAccepted} open={!settings.isTermOfServiceAccepted}
onOpenChange={handleTermDialogOpenChange} onOpenChange={handleTermDialogOpenChange}
/> />
)} )}
<div className="lg:fixed lg:inset-y-0 lg:z-40 lg:flex lg:w-96 lg:flex-col"> <div className="lg:fixed lg:inset-y-0 lg:z-40 lg:flex lg:w-96 lg:flex-col">
<div className="flex grow flex-col gap-y-2 overflow-y-auto border-r border-gray-200 bg-white px-6"> <div className="flex grow flex-col gap-y-2 overflow-y-auto border-r border-gray-200 bg-white px-6 dark:bg-zinc-950 dark:text-white">
<div className="flex items-center justify-between mt-10"> <div className="flex items-center justify-between mt-10 mb-2">
<h1 className="text-2xl ">Screenshot to Code</h1> <h1 className="text-2xl ">Screenshot to Code</h1>
<SettingsDialog settings={settings} setSettings={setSettings} /> <SettingsDialog settings={settings} setSettings={setSettings} />
</div> </div>
{appState === AppState.INITIAL && (
<h2 className="text-sm text-gray-500 mb-2">
Drag & drop a screenshot to get started.
</h2>
)}
{appState === AppState.INITIAL && ( <OutputSettingsSection
<OutputSettingsSection outputSettings={outputSettings}
outputSettings={outputSettings} setOutputSettings={setOutputSettings}
setOutputSettings={setOutputSettings} shouldDisableUpdates={
/> appState === AppState.CODING || appState === AppState.CODE_READY
)} }
/>
{IS_RUNNING_ON_CLOUD && !settings.openAiApiKey && <OnboardingNote />} {IS_RUNNING_ON_CLOUD &&
!(settings.openAiApiKey || settings.accessCode) && (
<OnboardingNote />
)}
{(appState === AppState.CODING || {(appState === AppState.CODING ||
appState === AppState.CODE_READY) && ( appState === AppState.CODE_READY) && (
@ -213,7 +213,10 @@ function App() {
{executionConsole.slice(-1)[0]} {executionConsole.slice(-1)[0]}
</div> </div>
<div className="flex mt-4 w-full"> <div className="flex mt-4 w-full">
<Button onClick={stop} className="w-full"> <Button
onClick={stop}
className="w-full dark:text-white dark:bg-gray-700"
>
Stop Stop
</Button> </Button>
</div> </div>
@ -230,26 +233,32 @@ function App() {
value={updateInstruction} value={updateInstruction}
/> />
<div className="flex justify-between items-center gap-x-2"> <div className="flex justify-between items-center gap-x-2">
<div className="font-500 text-xs text-slate-700"> <div className="font-500 text-xs text-slate-700 dark:text-white">
Include screenshot of current version? Include screenshot of current version?
</div> </div>
<Switch <Switch
checked={shouldIncludeResultImage} checked={shouldIncludeResultImage}
onCheckedChange={setShouldIncludeResultImage} onCheckedChange={setShouldIncludeResultImage}
className="dark:bg-gray-700"
/> />
</div> </div>
<Button onClick={doUpdate}>Update</Button> <Button
onClick={doUpdate}
className="dark:text-white dark:bg-gray-700"
>
Update
</Button>
</div> </div>
<div className="flex items-center gap-x-2 mt-2"> <div className="flex items-center gap-x-2 mt-2">
<Button <Button
onClick={downloadCode} onClick={downloadCode}
className="flex items-center gap-x-2" className="flex items-center gap-x-2 dark:text-white dark:bg-gray-700"
> >
<FaDownload /> Download <FaDownload /> Download
</Button> </Button>
<Button <Button
onClick={reset} onClick={reset}
className="flex items-center gap-x-2" className="flex items-center gap-x-2 dark:text-white dark:bg-gray-700"
> >
<FaUndo /> <FaUndo />
Reset Reset

View File

@ -2,8 +2,15 @@ export function OnboardingNote() {
return ( return (
<div className="flex flex-col space-y-4 bg-green-700 p-2 rounded text-stone-200 text-sm"> <div className="flex flex-col space-y-4 bg-green-700 p-2 rounded text-stone-200 text-sm">
<span> <span>
To use Screenshot to Code, you need an OpenAI API key with GPT4 vision To use Screenshot to Code,{" "}
access.{" "} <a
className="inline underline hover:opacity-70"
href="https://buy.stripe.com/8wM6sre70gBW1nqaEE"
target="_blank"
>
buy some credits (100 generations for $36)
</a>{" "}
or use your own OpenAI API key with GPT4 vision access.{" "}
<a <a
href="https://github.com/abi/screenshot-to-code/blob/main/Troubleshooting.md" href="https://github.com/abi/screenshot-to-code/blob/main/Troubleshooting.md"
className="inline underline hover:opacity-70" className="inline underline hover:opacity-70"
@ -11,18 +18,8 @@ export function OnboardingNote() {
> >
Follow these instructions to get yourself a key. Follow these instructions to get yourself a key.
</a>{" "} </a>{" "}
Then, paste it in the Settings dialog (gear icon above). and paste it in the Settings dialog (gear icon above). Your key is only
</span> stored in your browser. Never stored on our servers.
<span>
Your key is only stored in your browser. Never stored on our servers. If
you prefer, you can also run this app completely locally.{" "}
<a
href="https://github.com/abi/screenshot-to-code"
className="inline underline hover:opacity-70"
target="_blank"
>
See the Github project for instructions.
</a>
</span> </span>
</div> </div>
); );

View File

@ -5,16 +5,18 @@ import {
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
} from "./ui/select"; } from "./ui/select";
import { CSSOption, UIComponentOption, JSFrameworkOption, OutputSettings } from "../types";
import { import {
Accordion, CSSOption,
AccordionContent, UIComponentOption,
AccordionItem, JSFrameworkOption,
AccordionTrigger, OutputSettings,
} from "./ui/accordion"; } from "../types";
import { capitalize } from "../lib/utils"; import { capitalize } from "../lib/utils";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useEffect } from "react"; import { useEffect } from "react";
import { Label } from "@radix-ui/react-label";
import { Button } from "./ui/button";
import { Popover, PopoverTrigger, PopoverContent } from "./ui/popover";
function displayCSSOption(option: CSSOption) { function displayCSSOption(option: CSSOption) {
switch (option) { switch (option) {
@ -27,6 +29,17 @@ function displayCSSOption(option: CSSOption) {
} }
} }
function displayJSOption(option: JSFrameworkOption) {
switch (option) {
case JSFrameworkOption.REACT:
return "React";
case JSFrameworkOption.NO_FRAMEWORK:
return "No Framework";
default:
return option;
}
}
function convertStringToCSSOption(option: string) { function convertStringToCSSOption(option: string) {
switch (option) { switch (option) {
case "tailwind": case "tailwind":
@ -38,24 +51,63 @@ function convertStringToCSSOption(option: string) {
} }
} }
function generateDisplayString(settings: OutputSettings) {
if (
settings.js === JSFrameworkOption.REACT &&
settings.css === CSSOption.TAILWIND
) {
return (
<div className="text-gray-800 dark:text-white">
Generating <span className="font-bold">React</span> +{" "}
<span className="font-bold">Tailwind</span> code
</div>
);
} else if (
settings.js === JSFrameworkOption.NO_FRAMEWORK &&
settings.css === CSSOption.TAILWIND
) {
return (
<div className="text-gray-800 dark:text-white">
Generating <span className="font-bold">HTML</span> +{" "}
<span className="font-bold">Tailwind</span> code
</div>
);
} else if (
settings.js === JSFrameworkOption.NO_FRAMEWORK &&
settings.css === CSSOption.BOOTSTRAP
) {
return (
<div className="text-gray-800 dark:text-white">
Generating <span className="font-bold">HTML</span> +{" "}
<span className="font-bold">Bootstrap</span> code
</div>
);
}
}
interface Props { interface Props {
outputSettings: OutputSettings; outputSettings: OutputSettings;
setOutputSettings: React.Dispatch<React.SetStateAction<OutputSettings>>; setOutputSettings: React.Dispatch<React.SetStateAction<OutputSettings>>;
shouldDisableUpdates?: boolean;
} }
function OutputSettingsSection({ outputSettings, setOutputSettings }: Props) { function OutputSettingsSection({
outputSettings,
setOutputSettings,
shouldDisableUpdates = false,
}: Props) {
const onCSSValueChange = (value: string) => { const onCSSValueChange = (value: string) => {
setOutputSettings((prev) => { setOutputSettings((prev) => {
if (prev.js === JSFrameworkOption.REACT) { if (prev.js === JSFrameworkOption.REACT) {
if (value !== CSSOption.TAILWIND) { if (value !== CSSOption.TAILWIND) {
toast.error( toast.error(
"React only supports Tailwind CSS. Change JS framework to Vanilla to use Bootstrap." 'React only supports Tailwind CSS. Change JS framework to "No Framework" to use Bootstrap.'
); );
} }
return { return {
css: CSSOption.TAILWIND, css: CSSOption.TAILWIND,
js: JSFrameworkOption.REACT, js: JSFrameworkOption.REACT,
components: UIComponentOption.HTML components: UIComponentOption.HTML,
}; };
} else { } else {
return { return {
@ -71,7 +123,7 @@ function OutputSettingsSection({ outputSettings, setOutputSettings }: Props) {
setOutputSettings(() => ({ setOutputSettings(() => ({
css: CSSOption.TAILWIND, css: CSSOption.TAILWIND,
js: value as JSFrameworkOption, js: value as JSFrameworkOption,
components: UIComponentOption.HTML components: UIComponentOption.HTML,
})); }));
} else { } else {
setOutputSettings((prev) => ({ setOutputSettings((prev) => ({
@ -85,8 +137,8 @@ function OutputSettingsSection({ outputSettings, setOutputSettings }: Props) {
if (value === UIComponentOption.IONIC) { if (value === UIComponentOption.IONIC) {
setOutputSettings(() => ({ setOutputSettings(() => ({
css: CSSOption.TAILWIND, css: CSSOption.TAILWIND,
js: JSFrameworkOption.VANILLA, js: JSFrameworkOption.NO_FRAMEWORK,
components: value as UIComponentOption components: value as UIComponentOption,
})); }));
} else { } else {
setOutputSettings((prev) => ({ setOutputSettings((prev) => ({
@ -96,128 +148,182 @@ function OutputSettingsSection({ outputSettings, setOutputSettings }: Props) {
} }
}; };
const checkUIComponentOptionOrDefault = (
const checkUIComponentOptionOrDefault = (valueItem: UIComponentOption ) : UIComponentOption => { valueItem: UIComponentOption
): UIComponentOption => {
switch (valueItem) { switch (valueItem) {
case UIComponentOption.IONIC: case UIComponentOption.IONIC:
if (outputSettings.js != JSFrameworkOption.VANILLA || outputSettings.css != CSSOption.TAILWIND) { if (
return UIComponentOption.HTML outputSettings.js != JSFrameworkOption.NO_FRAMEWORK ||
outputSettings.css != CSSOption.TAILWIND
) {
return UIComponentOption.HTML;
} }
} }
return valueItem; return valueItem;
} };
const checkCSSOptionOrDefault = (valueItem: CSSOption ) : CSSOption => { const checkCSSOptionOrDefault = (valueItem: CSSOption): CSSOption => {
switch (valueItem) { switch (valueItem) {
default: default:
return valueItem; return valueItem;
} }
} };
const checkJSFrameworkOptionOrDefault = (valueItem: JSFrameworkOption ) : JSFrameworkOption => { const checkJSFrameworkOptionOrDefault = (
valueItem: JSFrameworkOption
): JSFrameworkOption => {
switch (valueItem) { switch (valueItem) {
case JSFrameworkOption.REACT: case JSFrameworkOption.REACT:
if(outputSettings.css != CSSOption.TAILWIND) { if (outputSettings.css != CSSOption.TAILWIND) {
return JSFrameworkOption.VANILLA return JSFrameworkOption.NO_FRAMEWORK;
} }
break; break;
} }
return valueItem; return valueItem;
} };
useEffect(() => { useEffect(() => {
checkOutputSettingsOptions(); checkOutputSettingsOptions();
}, [outputSettings]); }, [outputSettings]);
const checkOutputSettingsOptions = () => { const checkOutputSettingsOptions = () => {
if ( isHiddenOption(outputSettings.css) || isHiddenOption(outputSettings.js) || isHiddenOption(outputSettings.components)) if (
{ isHiddenOption(outputSettings.css) ||
setOutputSettings((prev) => { isHiddenOption(outputSettings.js) ||
return { isHiddenOption(outputSettings.components)
css: checkCSSOptionOrDefault(prev.css), ) {
js: checkJSFrameworkOptionOrDefault(prev.js), setOutputSettings((prev) => {
components: checkUIComponentOptionOrDefault(prev.components), return {
}; css: checkCSSOptionOrDefault(prev.css),
}) js: checkJSFrameworkOptionOrDefault(prev.js),
} components: checkUIComponentOptionOrDefault(prev.components),
};
});
}
}; };
const isHiddenOption = ( option : CSSOption| JSFrameworkOption | UIComponentOption ) : boolean => { const isHiddenOption = (
if (Object.values(CSSOption).includes(option as CSSOption)){ option: CSSOption | JSFrameworkOption | UIComponentOption
return checkCSSOptionOrDefault(option as CSSOption) != option ): boolean => {
if (Object.values(CSSOption).includes(option as CSSOption)) {
return checkCSSOptionOrDefault(option as CSSOption) != option;
} }
if (Object.values(JSFrameworkOption).includes(option as JSFrameworkOption)){ if (
return checkJSFrameworkOptionOrDefault(option as JSFrameworkOption) != option Object.values(JSFrameworkOption).includes(option as JSFrameworkOption)
) {
return (
checkJSFrameworkOptionOrDefault(option as JSFrameworkOption) != option
);
} }
if (Object.values(UIComponentOption).includes(option as UIComponentOption)){ if (
return checkUIComponentOptionOrDefault(option as UIComponentOption) != option Object.values(UIComponentOption).includes(option as UIComponentOption)
) {
return (
checkUIComponentOptionOrDefault(option as UIComponentOption) != option
);
} }
return true return true;
} };
return ( return (
<Accordion type="single" collapsible className="w-full"> <div className="flex flex-col gap-y-2 justify-between text-sm">
<AccordionItem value="item-1"> {generateDisplayString(outputSettings)}{" "}
<AccordionTrigger> {!shouldDisableUpdates && (
<div className="flex gap-x-2">Output Settings </div> <Popover>
</AccordionTrigger> <PopoverTrigger asChild>
<AccordionContent className="gap-y-2 flex flex-col pt-2"> <Button variant="outline">Customize</Button>
<div className="flex justify-between items-center pr-2"> </PopoverTrigger>
<span className="text-sm">CSS</span> <PopoverContent className="w-80 text-sm">
<Select value={outputSettings.css} onValueChange={onCSSValueChange}> <div className="grid gap-4">
<SelectTrigger className="w-[180px]"> <div className="space-y-2">
{displayCSSOption(outputSettings.css)} <h4 className="font-medium leading-none">Code Settings</h4>
</SelectTrigger> <p className="text-muted-foreground">
<SelectContent> Customize your code output
<SelectGroup> </p>
<SelectItem value={CSSOption.TAILWIND}>Tailwind</SelectItem> </div>
<SelectItem value={CSSOption.BOOTSTRAP}>Bootstrap</SelectItem> <div className="grid gap-2">
</SelectGroup> <div className="grid grid-cols-3 items-center gap-4">
</SelectContent> <Label htmlFor="output-settings-js">JS</Label>
</Select> <Select
</div> value={outputSettings.js}
<div className="flex justify-between items-center pr-2"> onValueChange={onJsFrameworkChange}
<span className="text-sm">JS Framework</span> >
<Select <SelectTrigger
value={outputSettings.js} className="col-span-2 h-8"
onValueChange={onJsFrameworkChange} id="output-settings-js"
> >
<SelectTrigger className="w-[180px]"> {displayJSOption(outputSettings.js)}
{capitalize(outputSettings.js)} </SelectTrigger>
</SelectTrigger> <SelectContent>
<SelectContent> <SelectGroup>
<SelectGroup> <SelectItem value={JSFrameworkOption.NO_FRAMEWORK}>
<SelectItem value={JSFrameworkOption.VANILLA}> No Framework
Vanilla </SelectItem>
</SelectItem> <SelectItem value={JSFrameworkOption.REACT}>
<SelectItem value={JSFrameworkOption.REACT} disabled={isHiddenOption(JSFrameworkOption.REACT)}>React</SelectItem> React
</SelectGroup> </SelectItem>
</SelectContent> </SelectGroup>
</Select> </SelectContent>
</div> </Select>
<div className="flex justify-between items-center pr-2"> </div>
<span className="text-sm">Component Library</span> <div className="grid grid-cols-3 items-center gap-4">
<Select <Label htmlFor="output-settings-css">CSS</Label>
value={outputSettings.components} <Select
onValueChange={onUIComponentOptionChange} value={outputSettings.css}
> onValueChange={onCSSValueChange}
<SelectTrigger className="w-[180px]"> >
{capitalize(outputSettings.components)} <SelectTrigger
</SelectTrigger> className="col-span-2 h-8"
<SelectContent> id="output-settings-css"
<SelectGroup> >
<SelectItem value={UIComponentOption.HTML}>HTML</SelectItem> {displayCSSOption(outputSettings.css)}
<SelectItem value={UIComponentOption.IONIC} disabled={isHiddenOption(UIComponentOption.IONIC)}>Ionic</SelectItem> </SelectTrigger>
</SelectGroup> <SelectContent>
</SelectContent> <SelectGroup>
</Select> <SelectItem value={CSSOption.TAILWIND}>
</div> Tailwind
<div className="flex justify-between pr-2 mt-2"> </SelectItem>
<span className="text-sm text-gray-500">Output: {outputSettings.js} + {outputSettings.css} + {outputSettings.components}</span> <SelectItem value={CSSOption.BOOTSTRAP}>
</div> Bootstrap
</AccordionContent> </SelectItem>
</AccordionItem> </SelectGroup>
</Accordion> </SelectContent>
</Select>
</div>
<div className="grid grid-cols-3 items-center gap-4">
<Label htmlFor="output-settings-component">Components</Label>
<Select
value={outputSettings.components}
onValueChange={onUIComponentOptionChange}
>
<SelectTrigger
id="output-settings-component"
className="col-span-2 h-8"
>
{capitalize(outputSettings.components)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value={UIComponentOption.HTML}>
HTML
</SelectItem>
<SelectItem
value={UIComponentOption.IONIC}
disabled={isHiddenOption(UIComponentOption.IONIC)}
>
Ionic
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
); );
} }

View File

@ -1,4 +1,6 @@
export function PicoBadge() { import { Settings } from "../types";
export function PicoBadge({ settings }: { settings: Settings }) {
return ( return (
<> <>
<a <a
@ -12,14 +14,26 @@ export function PicoBadge() {
feature requests? feature requests?
</div> </div>
</a> </a>
<a href="https://picoapps.xyz?ref=screenshot-to-code" target="_blank"> {!settings.accessCode && (
<div <a href="https://picoapps.xyz?ref=screenshot-to-code" target="_blank">
className="fixed z-50 bottom-5 right-5 rounded-md shadow text-black <div
className="fixed z-50 bottom-5 right-5 rounded-md shadow text-black
bg-white px-4 text-xs py-3 cursor-pointer" bg-white px-4 text-xs py-3 cursor-pointer"
> >
an open source project by Pico an open source project by Pico
</div> </div>
</a> </a>
)}
{settings.accessCode && (
<a href="mailto:support@picoapps.xyz" target="_blank">
<div
className="fixed z-50 bottom-5 right-5 rounded-md shadow text-black
bg-white px-4 text-xs py-3 cursor-pointer"
>
email support
</div>
</a>
)}
</> </>
); );
} }

View File

@ -15,6 +15,13 @@ import { Label } from "./ui/label";
import { Input } from "./ui/input"; import { Input } from "./ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
import { capitalize } from "../lib/utils"; import { capitalize } from "../lib/utils";
import { IS_RUNNING_ON_CLOUD } from "../config";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "./ui/accordion";
interface Props { interface Props {
settings: Settings; settings: Settings;
@ -38,6 +45,31 @@ function SettingsDialog({ settings, setSettings }: Props) {
<DialogHeader> <DialogHeader>
<DialogTitle className="mb-4">Settings</DialogTitle> <DialogTitle className="mb-4">Settings</DialogTitle>
</DialogHeader> </DialogHeader>
{/* Access code */}
{IS_RUNNING_ON_CLOUD && (
<div className="flex flex-col space-y-4 bg-slate-300 p-4 rounded dark:text-white dark:bg-slate-800">
<Label htmlFor="access-code">
<div>Access Code</div>
<div className="font-light mt-1 leading-relaxed">
Buy an access code.
</div>
</Label>
<Input
id="access-code dark:border-gray-700 dark:bg-gray-800 dark:text-white"
placeholder="Enter your Screenshot to Code access code"
value={settings.accessCode || ""}
onChange={(e) =>
setSettings((s) => ({
...s,
accessCode: e.target.value,
}))
}
/>
</div>
)}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Label htmlFor="image-generation"> <Label htmlFor="image-generation">
<div>DALL-E Placeholder Image Generation</div> <div>DALL-E Placeholder Image Generation</div>
@ -77,53 +109,115 @@ function SettingsDialog({ settings, setSettings }: Props) {
} }
/> />
<Label htmlFor="screenshot-one-api-key"> {!IS_RUNNING_ON_CLOUD && (
<div> <>
ScreenshotOne API key (optional - only needed if you want to use <Label htmlFor="openai-api-key">
URLs directly instead of taking the screenshot yourself) <div>OpenAI Base URL (optional)</div>
</div> <div className="font-light mt-2 leading-relaxed">
<div className="font-light mt-2 leading-relaxed"> Replace with a proxy URL if you don't want to use the default.
Only stored in your browser. Never stored on servers.{" "} </div>
<a </Label>
href="https://screenshotone.com?via=screenshot-to-code"
className="underline"
target="_blank"
>
Get 100 screenshots/mo for free.
</a>
</div>
</Label>
<Input <Input
id="screenshot-one-api-key" id="openai-base-url"
placeholder="ScreenshotOne API key" placeholder="OpenAI Base URL"
value={settings.screenshotOneApiKey || ""} value={settings.openAiBaseURL || ""}
onChange={(e) => onChange={(e) =>
setSettings((s) => ({ setSettings((s) => ({
...s, ...s,
screenshotOneApiKey: e.target.value, openAiBaseURL: e.target.value,
})) }))
} }
/> />
</>
)}
<Label htmlFor="editor-theme"> <Accordion type="single" collapsible className="w-full">
<div>Editor Theme</div> <AccordionItem value="item-1">
</Label> <AccordionTrigger>Screenshot by URL Config</AccordionTrigger>
<div> <AccordionContent>
<Select // Use the custom Select component here <Label htmlFor="screenshot-one-api-key">
name="editor-theme" <div className="leading-normal font-normal text-xs">
value={settings.editorTheme} If you want to use URLs directly instead of taking the
onValueChange={(value) => handleThemeChange(value as EditorTheme)} screenshot yourself, add a ScreenshotOne API key.{" "}
> <a
<SelectTrigger className="w-[180px]"> href="https://screenshotone.com?via=screenshot-to-code"
{capitalize(settings.editorTheme)} className="underline"
</SelectTrigger> target="_blank"
<SelectContent> >
<SelectItem value="cobalt">Cobalt</SelectItem> Get 100 screenshots/mo for free.
<SelectItem value="espresso">Espresso</SelectItem> </a>
</SelectContent> </div>
</Select> </Label>
</div>
<Input
id="screenshot-one-api-key"
className="mt-2"
placeholder="ScreenshotOne API key"
value={settings.screenshotOneApiKey || ""}
onChange={(e) =>
setSettings((s) => ({
...s,
screenshotOneApiKey: e.target.value,
}))
}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="item-1">
<AccordionTrigger>Theme Settings</AccordionTrigger>
<AccordionContent className="space-y-4 flex flex-col">
<div className="flex items-center justify-between">
<Label htmlFor="app-theme">
<div>App Theme</div>
</Label>
<div>
<button
className="flex rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50t"
onClick={() => {
document
.querySelector("div.mt-2")
?.classList.toggle("dark"); // enable dark mode for sidebar
document.body.classList.toggle("dark");
document
.querySelector('div[role="presentation"]')
?.classList.toggle("dark"); // enable dark mode for upload container
}}
>
Toggle dark mode
</button>
</div>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="editor-theme">
<div>
Code Editor Theme - requires page refresh to update
</div>
</Label>
<div>
<Select // Use the custom Select component here
name="editor-theme"
value={settings.editorTheme}
onValueChange={(value) =>
handleThemeChange(value as EditorTheme)
}
>
<SelectTrigger className="w-[180px]">
{capitalize(settings.editorTheme)}
</SelectTrigger>
<SelectContent>
<SelectItem value="cobalt">Cobalt</SelectItem>
<SelectItem value="espresso">Espresso</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div> </div>
<DialogFooter> <DialogFooter>

View File

@ -0,0 +1,29 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@ -159,4 +159,4 @@ export {
SelectSeparator, SelectSeparator,
SelectScrollUpButton, SelectScrollUpButton,
SelectScrollDownButton, SelectScrollDownButton,
} }

View File

@ -61,9 +61,21 @@
--radius: 0.5rem; --radius: 0.5rem;
} }
body.dark {
background-color: black;
}
div[role="presentation"].dark {
background-color: #09090b !important;
}
iframe {
background-color: white !important;
}
.dark { .dark {
--background: 222.2 84% 4.9%; --background: 222.2 0% 0%;
--foreground: 210 40% 98%; --foreground: 210 40% 98%;
--card: 222.2 84% 4.9%; --card: 222.2 84% 4.9%;

View File

@ -7,6 +7,6 @@ import { Toaster } from "react-hot-toast";
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
<Toaster /> <Toaster toastOptions={{ className:"dark:bg-zinc-950 dark:text-white" }}/>
</React.StrictMode> </React.StrictMode>
); );

View File

@ -9,7 +9,7 @@ export enum CSSOption {
} }
export enum JSFrameworkOption { export enum JSFrameworkOption {
VANILLA = "vanilla", NO_FRAMEWORK = "vanilla",
REACT = "react", REACT = "react",
VUE = "vue", VUE = "vue",
} }
@ -27,10 +27,12 @@ export interface OutputSettings {
export interface Settings { export interface Settings {
openAiApiKey: string | null; openAiApiKey: string | null;
openAiBaseURL: string | null;
screenshotOneApiKey: string | null; screenshotOneApiKey: string | null;
isImageGenerationEnabled: boolean; isImageGenerationEnabled: boolean;
editorTheme: EditorTheme; editorTheme: EditorTheme;
isTermOfServiceAccepted: boolean; // Only relevant for hosted version isTermOfServiceAccepted: boolean; // Only relevant for hosted version
accessCode: string | null; // Only relevant for hosted version
} }
export enum AppState { export enum AppState {

View File

@ -805,6 +805,28 @@
"@babel/runtime" "^7.13.10" "@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "1.0.3" "@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-popover@^1.0.7":
version "1.0.7"
resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.0.7.tgz#23eb7e3327330cb75ec7b4092d685398c1654e3c"
integrity sha512-shtvVnlsxT6faMnK/a7n0wptwBD23xc1Z5mdrtKLwVEfsEMXodS0r5s0/g5P0hX//EKYZS2sxUjqfzlg52ZSnQ==
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/primitive" "1.0.1"
"@radix-ui/react-compose-refs" "1.0.1"
"@radix-ui/react-context" "1.0.1"
"@radix-ui/react-dismissable-layer" "1.0.5"
"@radix-ui/react-focus-guards" "1.0.1"
"@radix-ui/react-focus-scope" "1.0.4"
"@radix-ui/react-id" "1.0.1"
"@radix-ui/react-popper" "1.1.3"
"@radix-ui/react-portal" "1.0.4"
"@radix-ui/react-presence" "1.0.1"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-slot" "1.0.2"
"@radix-ui/react-use-controllable-state" "1.0.1"
aria-hidden "^1.1.1"
react-remove-scroll "2.5.5"
"@radix-ui/react-popper@1.1.3": "@radix-ui/react-popper@1.1.3":
version "1.1.3" version "1.1.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.1.3.tgz#24c03f527e7ac348fabf18c89795d85d21b00b42" resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.1.3.tgz#24c03f527e7ac348fabf18c89795d85d21b00b42"