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:
2026-03-19 18:05:16 +02:00
commit f07eddf29e
77 changed files with 7031 additions and 0 deletions

View 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>
)
}