import React, { useEffect, useMemo, useRef, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; // Cy Ai Prompt Builder – React Component (pastel-safe, buttons at bottom) // Add: Export / Import of saved prompts (.json) with merge + dedupe // Clipboard-safe: execCommand + manual modal fallbacks // ——— Theme ——— const pastel = { bg: "#F7EDEE", bgAlt: "#F5F1EF", ink: "#2B2B2B", accent: "#E3C5CD", accentDeep: "#D8A7B1", ring: "#E9D7DB", }; // ——— Fallback logo (inline SVG) ——— const DEFAULT_LOGO_DATA_URI = "data:image/svg+xml;utf8," + encodeURIComponent(` `); // ——— Options (East Asian: traditional + modern) ——— const defaultOptions = { style: [ "Fairy Kei", "Harajuku", "Ukiyo-e", "Wabi-sabi", "Kintsugi", "Ink wash (sumi-e)", "Woodblock print", "Anime", "Manga", "K-drama cinematic", "Joseon hanbok", "Peking Opera", "Minimal Zen", "J-Idol Pop", "Cyberpunk Neon Tokyo", "Hanfu Court", "Ghibli-esque", "Vaporwave Kowloon", "Taiko Festival", "Shinto Shrine Aesthetic", "Taisho Romance", "K-pop comeback stage", "Hanbok street fashion", "Neo-Noir Seoul", "Fanart", "Wuxia", "Xianxia", "Webtoon", "Kawaii", "Palace drama" ], subject: { "People & Roles": [ "oppa", "noona", "chaebol", "sensei", "gyaru", "tea master", "calligrapher", "idol singer", "hanbok model", "shamisen player" ], "Objects / Food & Drink": [ "ramyeon", "boba tea", "soju", "bento still life", "paper cranes", "koi fish", "cherry blossom tree", "kimono cat", "geisha portrait", "banchan spread", "street food stall", "oshibori (hot towel) service" ], }, action: [ "reading in a sunbeam", "walking under umbrellas", "pouring tea", "practicing calligraphy", "posing for album cover", "standing in snowfall", "spinning in hanbok", "waiting at crosswalk in rain", "confessing under cherry blossoms", "playing shamisen on a street corner", "aegyo", "singing karaoke", // Umbrella variants (used in UI); randomizer prefers romantic via weighted pick "sharing an umbrella (romantic)", "sharing an umbrella (melancholy)", "sharing an umbrella (comedic)" ], setting: [ "Seoul rooftop at night", "Shibuya crossing", "Kyoto alley with lanterns", "traditional tea house", "bamboo forest", "neon arcade", "Peking Opera stage", "overgrown shrine", "hanok courtyard", "Busan seaside pier", "Nara deer park", "Ikseon-dong hanok lanes", "Namsan at blue hour", "Nakamise market at dawn", "Lantern Festival", "First snow", "Noraebang" ], color: [ "pastel sakura", "dusty rose", "matcha green and cream", "indigo and gold", "sepia tones", "vintage washi neutrals", "soft peach and beige", "neon magenta and cyan", "sakura blush + charcoal", "celadon + warm cream" ], lighting: [ "soft natural light", "paper lantern glow", "golden hour", "rainy night bokeh", "overcast diffused", "moonlit blue hour", "cinematic backlight", "volumetric sunbeams", "studio softbox", "stage spotlights + haze", "paper door rim light" ] }; // ——— Utils ——— const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]; const flattenOptions = (opts) => (Array.isArray(opts) ? opts : Object.values(opts).flat()); const resolved = (selectVal, customVal) => (customVal?.trim() ? customVal.trim() : selectVal?.trim()); function buildPrompt({ style, subject, action, setting, color, lighting, extras }) { const parts = []; if (style) parts.push(`${style} aesthetic`); if (subject && action) parts.push(`${subject}, ${action}`); else if (subject) parts.push(subject); else if (action) parts.push(action); if (setting) parts.push(`set in ${setting}`); if (color) parts.push(`color palette: ${color}`); if (lighting) parts.push(`lighting: ${lighting}`); if (extras?.trim()) parts.push(extras.trim()); return parts.join(", "); } // Weighted umbrella variant selection: 60% romantic, 25% melancholy, 15% comedic. function weightedUmbrellaVariant() { const r = Math.random(); if (r < 0.60) return "sharing an umbrella (romantic)"; if (r < 0.85) return "sharing an umbrella (melancholy)"; return "sharing an umbrella (comedic)"; } // Random action with umbrella weighting but SAME overall umbrella frequency as before function randomActionWithUmbrellaWeight() { const actions = defaultOptions.action; const umbrellaVariants = actions.filter(a => a.startsWith("sharing an umbrella")); const nonUmbrella = actions.filter(a => !a.startsWith("sharing an umbrella")); const umbrellaChance = umbrellaVariants.length / actions.length; if (Math.random() < umbrellaChance) { return weightedUmbrellaVariant(); } return pick(nonUmbrella); } // ——— Lightweight self-tests (non-fatal) ——— (function selfTests() { try { const p1 = buildPrompt({ style: "Ukiyo-e", subject: "geisha", action: "pouring tea", setting: "Kyoto", color: "indigo and gold", lighting: "paper lantern glow", extras: "85mm" }); console.assert(p1.includes("Ukiyo-e aesthetic"), "Test: style inclusion failed"); console.assert(p1.includes("geisha, pouring tea"), "Test: subject+action ordering failed"); const p2 = buildPrompt({ style: "", subject: "koi fish" }); console.assert(p2.startsWith("koi fish"), "Test: subject-first when no style/action failed"); const p3 = buildPrompt({ style: "Wabi-sabi", subject: "", action: "walking under umbrellas" }); console.assert(p3.includes("Wabi-sabi aesthetic") && p3.includes("walking under umbrellas"), "Test: action with style failed"); const flat = flattenOptions(defaultOptions.subject); console.assert(Array.isArray(flat) && flat.includes("oppa") && flat.includes("ramyeon"), "Test: subject groups flattening failed"); const p4 = buildPrompt({ style: "K-drama cinematic", subject: "oppa", action: "sharing an umbrella (romantic)", setting: "Lantern Festival" }); console.assert(/umbrella/.test(p4) && /Lantern Festival/.test(p4), "Test: umbrella trope with setting failed"); const p5 = buildPrompt({ style: "Kawaii", subject: "boba tea", action: "aegyo", color: "pastel sakura", lighting: "paper lantern glow" }); console.assert(p5.includes("color palette: pastel sakura") && p5.includes("lighting: paper lantern glow"), "Test: color+lighting formatting failed"); const uv = weightedUmbrellaVariant(); console.assert(["sharing an umbrella (romantic)", "sharing an umbrella (melancholy)", "sharing an umbrella (comedic)"].includes(uv), "Test: weighted umbrella variant failed"); } catch (_) { /* ignore in UI */ } })(); export default function PromptBuilder() { const queryLogo = (() => { try { return new URLSearchParams(window.location.search).get("logo") || ""; } catch { return ""; } })(); const [logoUrl] = useState(() => localStorage.getItem("cyai_logo_url") || queryLogo || DEFAULT_LOGO_DATA_URI); // Form state const [style, setStyle] = useState(""); const [styleCustom, setStyleCustom] = useState(""); const [subject, setSubject] = useState(""); const [subjectCustom, setSubjectCustom] = useState(""); const [action, setAction] = useState(""); const [actionCustom, setActionCustom] = useState(""); const [setting, setSetting] = useState(""); const [settingCustom, setSettingCustom] = useState(""); const [color, setColor] = useState(""); const [colorCustom, setColorCustom] = useState(""); const [lighting, setLighting] = useState(""); const [lightingCustom, setLightingCustom] = useState(""); const [extras, setExtras] = useState(""); const [copied, setCopied] = useState(false); const [copyError, setCopyError] = useState(""); const [manualCopyText, setManualCopyText] = useState(""); const manualTextareaRef = useRef(null); const [randomPrompts, setRandomPrompts] = useState([]); const [savedPrompts, setSavedPrompts] = useState(() => { try { return JSON.parse(localStorage.getItem("cyai_saved_prompts_v1") || "[]"); } catch { return []; } }); useEffect(() => { try { localStorage.setItem("cyai_saved_prompts_v1", JSON.stringify(savedPrompts)); } catch {} }, [savedPrompts]); // Import/export helpers const importInputRef = useRef(null); const [importMessage, setImportMessage] = useState(""); const prompt = useMemo(() => buildPrompt({ style: resolved(style, styleCustom), subject: resolved(subject, subjectCustom), action: resolved(action, actionCustom), setting: resolved(setting, settingCustom), color: resolved(color, colorCustom), lighting: resolved(lighting, lightingCustom), extras, }), [style, styleCustom, subject, subjectCustom, action, actionCustom, setting, settingCustom, color, colorCustom, lighting, lightingCustom, extras]); const chatEnhanceUrl = useMemo(() => { const message = `Enhance this text-to-image prompt. Keep the aesthetic and intent, improve clarity, add 2–3 vivid descriptors, and return a single refined prompt only. Prompt: ${prompt}`; return `https://chat.openai.com/?temporary-chat=true&message=${encodeURIComponent(message)}`; }, [prompt]); // ——— Clipboard helpers with fallbacks ——— async function safeCopy(text) { setCopyError(""); if (!text) return false; try { if (window.isSecureContext && navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; } } catch {} try { const el = document.createElement("textarea"); el.value = text; el.setAttribute("readonly", ""); el.style.position = "fixed"; el.style.opacity = "0"; document.body.appendChild(el); el.select(); const ok = document.execCommand("copy"); document.body.removeChild(el); if (ok) return true; } catch {} setManualCopyText(text); setCopyError("Clipboard blocked by this environment. Use manual copy."); return false; } async function handleCopyPrompt() { const ok = await safeCopy(prompt); setCopied(ok); if (ok) setTimeout(() => setCopied(false), 1200); } function randomizePrompt() { const subjects = flattenOptions(defaultOptions.subject); setStyle(pick(defaultOptions.style)); setSubject(pick(subjects)); setAction(randomActionWithUmbrellaWeight()); setSetting(pick(defaultOptions.setting)); setColor(pick(defaultOptions.color)); setLighting(pick(defaultOptions.lighting)); setStyleCustom(""); setSubjectCustom(""); setActionCustom(""); setSettingCustom(""); setColorCustom(""); setLightingCustom(""); setExtras(""); } function generateRandomBatch(n = 6) { const subjects = flattenOptions(defaultOptions.subject); const arr = Array.from({ length: n }, () => buildPrompt({ style: pick(defaultOptions.style), subject: pick(subjects), action: randomActionWithUmbrellaWeight(), setting: pick(defaultOptions.setting), color: pick(defaultOptions.color), lighting: pick(defaultOptions.lighting), extras: "", })); setRandomPrompts(arr); } function useRandomPrompt(text) { setExtras(text); } function saveCurrentPrompt() { if (!prompt) return; const item = { id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()), text: prompt, ts: Date.now() }; setSavedPrompts(prev => [item, ...prev].slice(0, 200)); } function loadSavedPrompt(text) { setExtras(text); } function deleteSavedPrompt(id) { setSavedPrompts(prev => prev.filter(p => p.id !== id)); } // —— Export / Import —— function exportSavedPrompts() { try { const data = JSON.stringify(savedPrompts, null, 2); const blob = new Blob([data], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); const stamp = new Date().toISOString().replace(/[:.]/g, "-"); a.href = url; a.download = `cyai-prompts-${stamp}.json`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } catch (e) { setImportMessage("Export failed."); } } function mergeDedupePrompts(existing, incoming) { const byText = new Map(); [...existing, ...incoming].forEach((it) => { const text = typeof it === "string" ? it : (it?.text || ""); if (!text.trim()) return; const ts = typeof it === "string" ? Date.now() : (Number(it.ts) || Date.now()); const id = (it && it.id) ? it.id : (crypto.randomUUID ? crypto.randomUUID() : String(ts + Math.random())); if (!byText.has(text)) byText.set(text, { id, text, ts }); else { const prev = byText.get(text); if (ts > prev.ts) byText.set(text, { id, text, ts }); } }); return Array.from(byText.values()).sort((a,b) => b.ts - a.ts).slice(0, 200); } function handleImportClick() { importInputRef.current?.click(); } function handleImportFile(e) { setImportMessage(""); const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const json = JSON.parse(String(reader.result || "[]")); const arr = Array.isArray(json) ? json : (json?.items || []); if (!Array.isArray(arr)) throw new Error("Invalid file"); const merged = mergeDedupePrompts(savedPrompts, arr); setSavedPrompts(merged); setImportMessage(`Imported ${Math.max(0, merged.length - savedPrompts.length)} new / updated prompts.`); } catch (err) { setImportMessage("Import failed. Ensure it's a JSON array of strings or {text, ts, id} objects."); } finally { e.target.value = ""; } }; reader.onerror = () => { setImportMessage("Import failed: read error."); e.target.value = ""; }; reader.readAsText(file); } // ——— UI ——— const fields = { Style: [style, setStyle, styleCustom, setStyleCustom, defaultOptions.style], Subject: [subject, setSubject, subjectCustom, setSubjectCustom, defaultOptions.subject], Action: [action, setAction, actionCustom, setActionCustom, defaultOptions.action], Setting: [setting, setSetting, settingCustom, setSettingCustom, defaultOptions.setting], Color: [color, setColor, colorCustom, setColorCustom, defaultOptions.color], Lighting: [lighting, setLighting, lightingCustom, setLightingCustom, defaultOptions.lighting], }; return (
Select options below and generate your unique text-to-image prompt.
Nothing saved yet. Click “Save Prompt” to keep one.
) : (Clipboard is blocked by this environment. Press Ctrl/Cmd + C after selecting the text below.