Initial
This commit is contained in:
+261
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user