This commit is contained in:
2026-05-11 22:27:54 -04:00
commit ea05c521c3
14 changed files with 3791 additions and 0 deletions
+261
View File
@@ -0,0 +1,261 @@
import { useState, useRef, useEffect } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
const URL_REGEX =
/^(https?:\/\/)?([\w-]+\.)+[\w-]{2,}(\/[\w\-._~:/?#[\]@!$&'()*+,;=%]*)?$/i
const initialForm = {
userName: '',
userEmail: '',
stationName: '',
stationLocation: '',
stationWebsite: '',
}
export default function App() {
const [form, setForm] = useState(initialForm)
const [errors, setErrors] = useState({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [isThinking, setIsThinking] = useState(false)
const [isStreaming, setIsStreaming] = useState(false)
const [result, setResult] = useState('')
const [streamError, setStreamError] = useState(null)
const resultRef = useRef(null)
useEffect(() => {
if (resultRef.current) {
resultRef.current.scrollTop = resultRef.current.scrollHeight
}
}, [result])
const handleChange = (e) => {
const { name, value } = e.target
setForm((prev) => ({ ...prev, [name]: value }))
}
const validate = () => {
const next = {}
if (!form.userName.trim()) next.userName = 'Required'
if (!form.userEmail.trim() || !/^\S+@\S+\.\S+$/.test(form.userEmail))
next.userEmail = 'Valid email required'
if (!form.stationName.trim()) next.stationName = 'Required'
if (!form.stationLocation.trim()) next.stationLocation = 'Required'
if (!form.stationWebsite.trim() || !URL_REGEX.test(form.stationWebsite))
next.stationWebsite = 'Valid URL required'
setErrors(next)
return Object.keys(next).length === 0
}
const readStream = async (response) => {
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let firstChunk = true
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
if (firstChunk) {
// Once data starts flowing, flip 'thinking' off and 'streaming' on.
setIsThinking(false)
setIsStreaming(true)
firstChunk = false
}
setResult((prev) => prev + chunk)
}
// Flush any remaining buffered bytes
const tail = decoder.decode()
if (tail) setResult((prev) => prev + tail)
setIsStreaming(false)
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!validate()) return
setResult('')
setStreamError(null)
setIsSubmitting(true)
setIsThinking(true)
setIsStreaming(false)
try {
const response = await fetch('/.netlify/functions/agent-proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
if (!response.ok || !response.body) {
throw new Error(
`Request failed: ${response.status} ${response.statusText}`,
)
}
await readStream(response)
} catch (err) {
console.error(err)
setStreamError(err.message || 'Something went wrong while streaming.')
} finally {
setIsSubmitting(false)
setIsThinking(false)
setIsStreaming(false)
}
}
return (
<div className="min-h-screen w-full font-serif">
<div className="mx-auto max-w-3xl px-6 py-12">
<header className="mb-10">
<h1 className="text-4xl font-semibold tracking-tight">
PMC Funder Discovery Tool
</h1>
<p className="mt-2 text-stone-600">
Tell us about your public media station and we'll have an
Anthropic-powered agent draft a tailored funding outlook.
</p>
</header>
<form
onSubmit={handleSubmit}
className="space-y-5 rounded-xl border border-stone-300 bg-white/70 p-6 shadow-sm"
>
<Field
label="Your Name"
name="userName"
value={form.userName}
onChange={handleChange}
error={errors.userName}
/>
<Field
label="Your Email"
name="userEmail"
type="email"
value={form.userEmail}
onChange={handleChange}
error={errors.userEmail}
/>
<Field
label="Station Name"
name="stationName"
value={form.stationName}
onChange={handleChange}
error={errors.stationName}
/>
<Field
label="Station Location"
name="stationLocation"
value={form.stationLocation}
onChange={handleChange}
error={errors.stationLocation}
/>
<Field
label="Station Website"
name="stationWebsite"
type="url"
placeholder="https://example.org"
value={form.stationWebsite}
onChange={handleChange}
error={errors.stationWebsite}
/>
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-md bg-stone-900 px-4 py-3 text-white transition hover:bg-stone-700 disabled:cursor-not-allowed disabled:bg-stone-400"
>
{isSubmitting ? 'Working' : 'Generate Funding Brief'}
</button>
</form>
<section className="mt-10">
{isSubmitting && !isStreaming && (
<StatusBanner>
{isThinking ? (
<>
<Spinner /> Claude is thinking…
</>
) : (
<>
<Spinner /> Connecting to Agent…
</>
)}
</StatusBanner>
)}
{streamError && (
<div className="rounded-md border border-red-300 bg-red-50 p-4 text-red-800">
{streamError}
</div>
)}
{(result || isStreaming) && (
<article
ref={resultRef}
className="markdown max-h-[60vh] overflow-y-auto rounded-xl border border-stone-300 bg-white p-6 leading-relaxed shadow-sm"
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{result}
</ReactMarkdown>
{isStreaming && <span className="cursor-pulse" />}
</article>
)}
</section>
</div>
</div>
)
}
function Field({ label, name, type = 'text', value, onChange, error, placeholder }) {
return (
<label className="block">
<span className="mb-1 block text-sm font-medium text-stone-800">
{label}
</span>
<input
type={type}
name={name}
value={value}
onChange={onChange}
placeholder={placeholder}
className={`w-full rounded-md border bg-white px-3 py-2 outline-none transition focus:ring-2 focus:ring-stone-700 ${
error ? 'border-red-400' : 'border-stone-300'
}`}
/>
{error && <span className="mt-1 block text-sm text-red-600">{error}</span>}
</label>
)
}
function StatusBanner({ children }) {
return (
<div className="mb-4 flex items-center gap-3 rounded-md border border-stone-300 bg-stone-100 px-4 py-3 text-stone-700">
{children}
</div>
)
}
function Spinner() {
return (
<svg
className="h-4 w-4 animate-spin text-stone-700"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
/>
</svg>
)
}
+126
View File
@@ -0,0 +1,126 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
font-family: 'Noto Serif', serif;
background-color: #f7f4ee;
color: #1f1b16;
}
@keyframes pulse-cursor {
0%,
100% {
opacity: 0.2;
}
50% {
opacity: 1;
}
}
.cursor-pulse {
display: inline-block;
width: 0.5rem;
height: 1.1rem;
margin-left: 0.25rem;
background: currentColor;
vertical-align: middle;
animation: pulse-cursor 1s infinite ease-in-out;
}
/* ---- Markdown rendering for streamed agent output ---- */
.markdown {
font-family: 'Noto Serif', serif;
color: #1f1b16;
word-wrap: break-word;
}
.markdown h1,
.markdown h2,
.markdown h3,
.markdown h4,
.markdown h5,
.markdown h6 {
font-weight: 600;
line-height: 1.25;
margin-top: 1.5em;
margin-bottom: 0.5em;
}
.markdown h1 { font-size: 1.875rem; }
.markdown h2 { font-size: 1.5rem; }
.markdown h3 { font-size: 1.25rem; }
.markdown h4 { font-size: 1.1rem; }
.markdown h1:first-child,
.markdown h2:first-child,
.markdown h3:first-child {
margin-top: 0;
}
.markdown p {
margin: 0.75em 0;
}
.markdown ul,
.markdown ol {
margin: 0.75em 0;
padding-left: 1.5em;
}
.markdown ul { list-style: disc; }
.markdown ol { list-style: decimal; }
.markdown li {
margin: 0.25em 0;
}
.markdown li > p { margin: 0.25em 0; }
.markdown a {
color: #1d4ed8;
text-decoration: underline;
}
.markdown a:hover { color: #1e40af; }
.markdown strong { font-weight: 600; }
.markdown em { font-style: italic; }
.markdown blockquote {
border-left: 3px solid #d6d3d1;
padding-left: 1em;
color: #57534e;
margin: 1em 0;
font-style: italic;
}
.markdown code {
background: #f5f5f4;
padding: 0.1em 0.35em;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.9em;
}
.markdown pre {
background: #1f1b16;
color: #f5f5f4;
padding: 1em;
border-radius: 8px;
overflow-x: auto;
margin: 1em 0;
}
.markdown pre code {
background: transparent;
color: inherit;
padding: 0;
font-size: 0.875em;
}
.markdown hr {
border: none;
border-top: 1px solid #d6d3d1;
margin: 1.5em 0;
}
.markdown table {
border-collapse: collapse;
margin: 1em 0;
width: 100%;
}
.markdown th,
.markdown td {
border: 1px solid #d6d3d1;
padding: 0.5em 0.75em;
text-align: left;
}
.markdown th {
background: #f5f5f4;
font-weight: 600;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)