initial project setup
Fastify + Prisma backend, React + Vite frontend, Docker deployment. Multi-board feedback platform with anonymous cookie auth, passkey upgrade path, ALTCHA spam protection, plugin system, and full privacy-first architecture.
This commit is contained in:
183
packages/web/src/components/PostForm.tsx
Normal file
183
packages/web/src/components/PostForm.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { api } from '../lib/api'
|
||||
|
||||
interface Props {
|
||||
boardSlug: string
|
||||
onSubmit?: () => void
|
||||
}
|
||||
|
||||
type PostType = 'feature' | 'bug' | 'general'
|
||||
|
||||
export default function PostForm({ boardSlug, onSubmit }: Props) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [type, setType] = useState<PostType>('feature')
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [expected, setExpected] = useState('')
|
||||
const [actual, setActual] = useState('')
|
||||
const [steps, setSteps] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const formRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const reset = () => {
|
||||
setTitle('')
|
||||
setBody('')
|
||||
setExpected('')
|
||||
setActual('')
|
||||
setSteps('')
|
||||
setError('')
|
||||
setExpanded(false)
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!title.trim()) {
|
||||
setError('Title is required')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
|
||||
const payload: Record<string, string> = { title, type, body }
|
||||
if (type === 'bug') {
|
||||
payload.stepsToReproduce = steps
|
||||
payload.expected = expected
|
||||
payload.actual = actual
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`/boards/${boardSlug}/posts`, payload)
|
||||
reset()
|
||||
onSubmit?.()
|
||||
} catch (e) {
|
||||
setError('Failed to submit. Please try again.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="card w-full px-4 py-3 text-left flex items-center gap-3"
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
|
||||
style={{ background: 'var(--accent-subtle)', color: 'var(--accent)' }}
|
||||
>
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Share feedback...
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={formRef} className="card p-4 slide-up">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)' }}>
|
||||
New Post
|
||||
</h3>
|
||||
<button onClick={reset} className="btn btn-ghost text-xs">Cancel</button>
|
||||
</div>
|
||||
|
||||
{/* Type selector */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
{(['feature', 'bug', 'general'] as PostType[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setType(t)}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-medium capitalize"
|
||||
style={{
|
||||
background: type === t ? 'var(--accent-subtle)' : 'transparent',
|
||||
color: type === t ? 'var(--accent)' : 'var(--text-tertiary)',
|
||||
border: `1px solid ${type === t ? 'var(--accent)' : 'var(--border)'}`,
|
||||
transition: 'all 200ms ease-out',
|
||||
}}
|
||||
>
|
||||
{t === 'feature' ? 'Feature Request' : t === 'bug' ? 'Bug Report' : 'General'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<input
|
||||
className="input mb-3"
|
||||
placeholder="Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
className="input mb-3"
|
||||
placeholder={type === 'bug' ? 'Describe the bug...' : type === 'feature' ? 'Describe the feature...' : 'What is on your mind?'}
|
||||
rows={3}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
style={{ resize: 'vertical' }}
|
||||
/>
|
||||
|
||||
{type === 'bug' && (
|
||||
<>
|
||||
<textarea
|
||||
className="input mb-3"
|
||||
placeholder="Steps to reproduce"
|
||||
rows={2}
|
||||
value={steps}
|
||||
onChange={(e) => setSteps(e.target.value)}
|
||||
style={{ resize: 'vertical' }}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder="Expected behavior"
|
||||
rows={2}
|
||||
value={expected}
|
||||
onChange={(e) => setExpected(e.target.value)}
|
||||
style={{ resize: 'vertical' }}
|
||||
/>
|
||||
<textarea
|
||||
className="input"
|
||||
placeholder="Actual behavior"
|
||||
rows={2}
|
||||
value={actual}
|
||||
onChange={(e) => setActual(e.target.value)}
|
||||
style={{ resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ALTCHA widget placeholder */}
|
||||
<div
|
||||
className="mb-4 p-3 rounded-lg text-xs flex items-center gap-2"
|
||||
style={{ background: 'var(--border)', color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
ALTCHA verification
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs mb-3" style={{ color: 'var(--error)' }}>{error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={submitting}
|
||||
className="btn btn-primary"
|
||||
style={{ opacity: submitting ? 0.6 : 1 }}
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user