Initial commit: prompt template manager

React + Tailwind frontend, FastAPI backend, settings.json storage.
Supports preset CRUD, live variable filling, and AI rewrite via OpenAI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Space-Banane
2026-06-26 15:38:17 +02:00
commit 20f06d0a57
21 changed files with 3537 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prompt Gen</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2736
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "prompt-gen-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"vite": "^6.3.5"
}
}
+3
View File
@@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} }
}
+129
View File
@@ -0,0 +1,129 @@
import React, { useState, useEffect } from 'react'
import TemplateList from './components/TemplateList.jsx'
import TemplateEditor from './components/TemplateEditor.jsx'
import PromptRunner from './components/PromptRunner.jsx'
import Settings from './components/Settings.jsx'
async function api(method, path, body) {
const res = await fetch(path, {
method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(err.detail)
}
return res.json()
}
export default function App() {
const [presets, setPresets] = useState([])
const [selected, setSelected] = useState(null)
const [mode, setMode] = useState('empty') // 'empty' | 'run' | 'edit' | 'new'
const [apiKeySet, setApiKeySet] = useState(false)
const [showSettings, setShowSettings] = useState(false)
useEffect(() => {
api('GET', '/api/presets').then(d => {
setPresets(d.presets)
if (d.presets.length > 0) { setSelected(d.presets[0]); setMode('run') }
})
api('GET', '/api/settings').then(d => setApiKeySet(d.openai_api_key_set))
}, [])
function selectPreset(p) {
setSelected(p)
setMode('run')
}
function startNew() {
setSelected(null)
setMode('new')
}
async function handleSave(body) {
if (mode === 'new') {
const created = await api('POST', '/api/presets', body)
setPresets(prev => [...prev, created])
setSelected(created)
setMode('run')
} else {
const updated = await api('PUT', `/api/presets/${selected.id}`, body)
setPresets(prev => prev.map(p => p.id === updated.id ? updated : p))
setSelected(updated)
setMode('run')
}
}
async function handleDelete() {
if (!selected) return
await api('DELETE', `/api/presets/${selected.id}`)
const next = presets.filter(p => p.id !== selected.id)
setPresets(next)
if (next.length > 0) { setSelected(next[0]); setMode('run') }
else { setSelected(null); setMode('empty') }
}
function handleCancel() {
if (selected) setMode('run')
else setMode('empty')
}
return (
<div className="flex h-screen bg-zinc-950 text-zinc-100 overflow-hidden">
{/* Sidebar */}
<TemplateList
presets={presets}
selectedId={selected?.id}
onSelect={selectPreset}
onNew={startNew}
/>
{/* Main area */}
<main className="flex-1 flex flex-col min-w-0">
{/* Topbar */}
<header className="flex items-center justify-end px-4 py-2 border-b border-zinc-800 bg-zinc-900">
<button
onClick={() => setShowSettings(true)}
title="Settings"
className="p-1.5 rounded-md text-zinc-400 hover:text-white hover:bg-zinc-700 transition-colors text-lg"
>
</button>
</header>
<div className="flex-1 flex overflow-hidden">
{mode === 'empty' && (
<div className="flex-1 flex items-center justify-center text-zinc-500 text-sm">
Create a preset to get started.
</div>
)}
{mode === 'run' && selected && (
<PromptRunner
preset={selected}
onEdit={() => setMode('edit')}
apiKeySet={apiKeySet}
/>
)}
{(mode === 'edit' || mode === 'new') && (
<TemplateEditor
preset={mode === 'edit' ? selected : null}
onSave={handleSave}
onCancel={handleCancel}
onDelete={mode === 'edit' ? handleDelete : undefined}
/>
)}
</div>
</main>
{showSettings && (
<Settings
apiKeySet={apiKeySet}
onSaved={() => setApiKeySet(true)}
onClose={() => setShowSettings(false)}
/>
)}
</div>
)
}
+115
View File
@@ -0,0 +1,115 @@
import React, { useState, useMemo } from 'react'
import { parseVars, fillTemplate } from '../lib/parseVars.js'
export default function PromptRunner({ preset, onEdit, apiKeySet }) {
const vars = useMemo(() => parseVars(preset.template), [preset.template])
const [values, setValues] = useState({})
const [copied, setCopied] = useState(false)
const [rewriting, setRewriting] = useState(false)
const [rewrittenOutput, setRewrittenOutput] = useState(null)
const [rewriteError, setRewriteError] = useState('')
const baseOutput = fillTemplate(preset.template, values)
const output = rewrittenOutput ?? baseOutput
function setValue(key, val) {
setValues(prev => ({ ...prev, [key]: val }))
setRewrittenOutput(null)
}
async function handleCopy() {
await navigator.clipboard.writeText(output)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
async function handleRewrite() {
setRewriting(true)
setRewriteError('')
try {
const res = await fetch('/api/rewrite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: baseOutput }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.detail)
setRewrittenOutput(data.rewritten)
} catch (e) {
setRewriteError(e.message)
} finally {
setRewriting(false)
}
}
return (
<div className="flex-1 flex flex-col p-6 gap-5 overflow-y-auto">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-white">{preset.name}</h2>
{preset.description && <p className="text-sm text-zinc-400 mt-0.5">{preset.description}</p>}
</div>
<button
onClick={onEdit}
className="px-3 py-1.5 text-sm rounded-md bg-zinc-700 hover:bg-zinc-600 text-zinc-200 transition-colors"
>
Edit
</button>
</div>
{vars.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{vars.map(v => (
<div key={v} className="flex flex-col gap-1">
<label className="text-xs font-medium text-zinc-400 uppercase tracking-wide">{v.replace(/_/g, ' ')}</label>
<input
value={values[v] ?? ''}
onChange={e => setValue(v, e.target.value)}
placeholder={v}
className="bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
))}
</div>
)}
<div className="flex flex-col gap-2 flex-1">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-zinc-400 uppercase tracking-wide">
Output {rewrittenOutput && <span className="text-indigo-400 normal-case">(AI rewritten)</span>}
</label>
<div className="flex gap-2">
{rewrittenOutput && (
<button
onClick={() => setRewrittenOutput(null)}
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
>
Reset
</button>
)}
<button
onClick={handleRewrite}
disabled={rewriting || !apiKeySet || !baseOutput.trim()}
title={!apiKeySet ? 'Add OpenAI key in Settings ⚙' : 'Rewrite with AI'}
className="px-3 py-1 text-xs rounded-md bg-violet-800 hover:bg-violet-700 text-violet-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{rewriting ? 'Rewriting…' : '✦ Rewrite with AI'}
</button>
<button
onClick={handleCopy}
className="px-3 py-1 text-xs rounded-md bg-zinc-700 hover:bg-zinc-600 text-zinc-200 transition-colors"
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</div>
{rewriteError && <p className="text-red-400 text-xs">{rewriteError}</p>}
<textarea
readOnly
value={output}
className="flex-1 min-h-48 bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-zinc-100 resize-none font-mono focus:outline-none"
/>
</div>
</div>
)
}
+72
View File
@@ -0,0 +1,72 @@
import React, { useState } from 'react'
export default function Settings({ apiKeySet, onSaved, onClose }) {
const [key, setKey] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [saved, setSaved] = useState(false)
async function handleSave() {
if (!key.trim()) { setError('Enter an API key'); return }
setSaving(true)
setError('')
try {
const res = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ openai_api_key: key.trim() }),
})
if (!res.ok) { const d = await res.json(); throw new Error(d.detail) }
setSaved(true)
onSaved()
setTimeout(() => setSaved(false), 2000)
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-6 w-full max-w-md shadow-xl">
<div className="flex items-center justify-between mb-4">
<h2 className="text-white font-semibold text-base">Settings</h2>
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-300 text-xl leading-none">×</button>
</div>
<div className="flex flex-col gap-1 mb-4">
<label className="text-xs font-medium text-zinc-400 uppercase tracking-wide">OpenAI API Key</label>
<p className="text-xs text-zinc-500 mb-1.5">
{apiKeySet ? 'A key is already saved. Enter a new one to replace it.' : 'Required for AI rewrite feature.'}
</p>
<input
type="password"
value={key}
onChange={e => setKey(e.target.value)}
placeholder="sk-..."
className="bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
{error && <p className="text-red-400 text-xs mb-3">{error}</p>}
<div className="flex justify-end gap-2">
<button
onClick={onClose}
className="px-3 py-1.5 text-sm rounded-md bg-zinc-700 hover:bg-zinc-600 text-zinc-200 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-3 py-1.5 text-sm rounded-md bg-indigo-600 hover:bg-indigo-500 text-white font-medium transition-colors disabled:opacity-50"
>
{saved ? 'Saved!' : saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
)
}
+110
View File
@@ -0,0 +1,110 @@
import React, { useState, useEffect } from 'react'
import { parseVars } from '../lib/parseVars.js'
export default function TemplateEditor({ preset, onSave, onCancel, onDelete }) {
const isNew = !preset
const [name, setName] = useState(preset?.name ?? '')
const [description, setDescription] = useState(preset?.description ?? '')
const [template, setTemplate] = useState(preset?.template ?? '')
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
setName(preset?.name ?? '')
setDescription(preset?.description ?? '')
setTemplate(preset?.template ?? '')
setError('')
}, [preset])
const detectedVars = parseVars(template)
async function handleSave() {
if (!name.trim()) { setError('Name is required'); return }
if (!template.trim()) { setError('Template is required'); return }
setSaving(true)
setError('')
try {
await onSave({ name: name.trim(), description: description.trim(), template })
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
return (
<div className="flex-1 flex flex-col p-6 gap-4 overflow-y-auto">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">{isNew ? 'New Preset' : 'Edit Preset'}</h2>
<div className="flex gap-2">
{!isNew && (
<button
onClick={onDelete}
className="px-3 py-1.5 text-sm rounded-md bg-red-900 hover:bg-red-700 text-red-200 transition-colors"
>
Delete
</button>
)}
<button
onClick={onCancel}
className="px-3 py-1.5 text-sm rounded-md bg-zinc-700 hover:bg-zinc-600 text-zinc-200 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-3 py-1.5 text-sm rounded-md bg-indigo-600 hover:bg-indigo-500 text-white font-medium transition-colors disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-zinc-400 uppercase tracking-wide">Name</label>
<input
value={name}
onChange={e => setName(e.target.value)}
placeholder="My prompt template"
className="bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-zinc-400 uppercase tracking-wide">Description</label>
<input
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Optional short description"
className="bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div className="flex flex-col gap-1 flex-1">
<label className="text-xs font-medium text-zinc-400 uppercase tracking-wide">
Template use <code className="text-indigo-400">{'{{'+'variable'+'}}'}</code> for inputs
</label>
<textarea
value={template}
onChange={e => setTemplate(e.target.value)}
placeholder="e.g. Resolve {{issue_id}} in {{repo}}. Create the PR as {{author}}."
className="flex-1 min-h-48 bg-zinc-800 border border-zinc-700 rounded-md px-3 py-2 text-sm text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none font-mono"
/>
{detectedVars.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1">
<span className="text-xs text-zinc-500">Detected variables:</span>
{detectedVars.map(v => (
<span key={v} className="text-xs bg-indigo-900 text-indigo-300 rounded px-2 py-0.5">
{v}
</span>
))}
</div>
)}
</div>
</div>
)
}
+46
View File
@@ -0,0 +1,46 @@
import React from 'react'
export default function TemplateList({ presets, selectedId, onSelect, onNew }) {
return (
<aside className="w-64 shrink-0 flex flex-col border-r border-zinc-700 bg-zinc-900 h-full">
<div className="p-3 border-b border-zinc-700">
<button
onClick={onNew}
className="w-full py-2 px-3 rounded-md bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium transition-colors"
>
+ New Preset
</button>
</div>
<div className="flex-1 overflow-y-auto">
{presets.length === 0 && (
<p className="p-4 text-zinc-500 text-sm">No presets yet. Create one!</p>
)}
{presets.map(p => (
<button
key={p.id}
onClick={() => onSelect(p)}
className={`w-full text-left px-4 py-3 border-b border-zinc-800 transition-colors ${
selectedId === p.id
? 'bg-zinc-700 text-white'
: 'text-zinc-300 hover:bg-zinc-800'
}`}
>
<div className="text-sm font-medium truncate">{p.name}</div>
{p.description && (
<div className="text-xs text-zinc-500 truncate mt-0.5">{p.description}</div>
)}
{p.variables.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{p.variables.map(v => (
<span key={v} className="text-xs bg-zinc-700 text-indigo-300 rounded px-1.5 py-0.5">
{v}
</span>
))}
</div>
)}
</button>
))}
</div>
</aside>
)
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+19
View File
@@ -0,0 +1,19 @@
const VAR_RE = /\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g
export function parseVars(template) {
const seen = new Set()
const vars = []
let m
VAR_RE.lastIndex = 0
while ((m = VAR_RE.exec(template)) !== null) {
if (!seen.has(m[1])) {
seen.add(m[1])
vars.push(m[1])
}
}
return vars
}
export function fillTemplate(template, values) {
return template.replace(/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/g, (_, k) => values[k] ?? `{{${k}}}`)
}
+6
View File
@@ -0,0 +1,6 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(<App />)
+5
View File
@@ -0,0 +1,5 @@
export default {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: { extend: {} },
plugins: [],
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8001'
}
},
build: {
outDir: 'dist'
}
})