import { useState, useEffect } from 'react' import { api } from '../lib/api' import { useAuth } from '../hooks/useAuth' import { solveAltcha } from '../lib/altcha' import { IconFingerprint } from '@tabler/icons-react' import Dropdown from './Dropdown' import MarkdownEditor from './MarkdownEditor' import FileUpload from './FileUpload' interface SimilarPost { id: string title: string status: string voteCount: number similarity: number } interface TemplateField { key: string label: string type: 'text' | 'textarea' | 'select' required: boolean placeholder?: string options?: string[] } interface Template { id: string name: string fields: TemplateField[] isDefault: boolean } interface Props { boardSlug: string boardId?: string onSubmit?: () => void onCancel?: () => void } type PostType = 'FEATURE_REQUEST' | 'BUG_REPORT' interface FieldErrors { [key: string]: string } export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Props) { const auth = useAuth() const [type, setType] = useState('FEATURE_REQUEST') const [title, setTitle] = useState('') const [category, setCategory] = useState('') const [submitting, setSubmitting] = useState(false) const [error, setError] = useState('') const [fieldErrors, setFieldErrors] = useState({}) const [categories, setCategories] = useState<{ id: string; name: string; slug: string }[]>([]) const [similar, setSimilar] = useState([]) // templates const [templates, setTemplates] = useState([]) const [selectedTemplateId, setSelectedTemplateId] = useState('') const [templateValues, setTemplateValues] = useState>({}) useEffect(() => { api.get<{ id: string; name: string; slug: string }[]>('/categories') .then(setCategories) .catch(() => {}) }, []) // fetch templates for this board useEffect(() => { if (!boardSlug) return api.get<{ templates: Template[] }>(`/boards/${boardSlug}/templates`) .then((r) => { setTemplates(r.templates) const def = r.templates.find((t) => t.isDefault) if (def) setSelectedTemplateId(def.id) }) .catch(() => {}) }, [boardSlug]) // debounced duplicate detection useEffect(() => { if (!boardId || title.trim().length < 5) { setSimilar([]); return } const t = setTimeout(() => { api.get<{ posts: SimilarPost[] }>(`/similar?title=${encodeURIComponent(title)}&boardId=${encodeURIComponent(boardId)}`) .then((r) => setSimilar(r.posts)) .catch(() => setSimilar([])) }, 400) return () => clearTimeout(t) }, [title, boardId]) // bug report fields const [steps, setSteps] = useState('') const [expected, setExpected] = useState('') const [actual, setActual] = useState('') const [environment, setEnvironment] = useState('') const [bugContext, setBugContext] = useState('') const [attachmentIds, setAttachmentIds] = useState([]) // feature request fields const [useCase, setUseCase] = useState('') const [proposedSolution, setProposedSolution] = useState('') const [alternatives, setAlternatives] = useState('') const [featureContext, setFeatureContext] = useState('') const selectedTemplate = templates.find((t) => t.id === selectedTemplateId) || null const reset = () => { setTitle('') setCategory('') setSteps('') setExpected('') setActual('') setEnvironment('') setBugContext('') setUseCase('') setProposedSolution('') setAlternatives('') setFeatureContext('') setAttachmentIds([]) setTemplateValues({}) setError('') setFieldErrors({}) } const validate = (): boolean => { const errors: FieldErrors = {} if (title.trim().length < 5) { errors.title = 'Title must be at least 5 characters' } if (selectedTemplate) { for (const f of selectedTemplate.fields) { if (f.required && !templateValues[f.key]?.trim()) { errors[f.key] = `${f.label} is required` } } } else if (type === 'BUG_REPORT') { if (!steps.trim()) errors.steps = 'Steps to reproduce are required' if (!expected.trim()) errors.expected = 'Expected behavior is required' if (!actual.trim()) errors.actual = 'Actual behavior is required' } else { if (!useCase.trim()) errors.useCase = 'Use case is required' } setFieldErrors(errors) return Object.keys(errors).length === 0 } const submit = async () => { if (!validate()) return setSubmitting(true) setError('') let altcha: string try { altcha = await solveAltcha() } catch { setError('Verification failed. Please try again.') setSubmitting(false) return } let description: Record if (selectedTemplate) { description = {} for (const f of selectedTemplate.fields) { description[f.key] = templateValues[f.key] || '' } } else if (type === 'BUG_REPORT') { description = { stepsToReproduce: steps, expectedBehavior: expected, actualBehavior: actual, environment: environment || '', additionalContext: bugContext || '', } } else { description = { useCase, proposedSolution: proposedSolution || '', alternativesConsidered: alternatives || '', additionalContext: featureContext || '', } } try { await api.post(`/boards/${boardSlug}/posts`, { title, type, description, category: category || undefined, templateId: selectedTemplate ? selectedTemplate.id : undefined, attachmentIds: attachmentIds.length ? attachmentIds : undefined, altcha, }) reset() onSubmit?.() } catch { setError('Failed to submit. Please try again.') } finally { setSubmitting(false) } } const fieldError = (key: string) => fieldErrors[key] ? ( {fieldErrors[key]} ) : null const label = (text: string, required?: boolean, htmlFor?: string) => ( ) const handleTemplateChange = (id: string) => { setSelectedTemplateId(id) setTemplateValues({}) setFieldErrors({}) } const renderTemplateFields = () => { if (!selectedTemplate) return null return selectedTemplate.fields.map((f) => (
{label(f.label, f.required)} {f.type === 'text' && ( setTemplateValues((prev) => ({ ...prev, [f.key]: e.target.value }))} aria-required={f.required || undefined} aria-invalid={!!fieldErrors[f.key]} aria-describedby={fieldErrors[f.key] ? `err-${f.key}` : undefined} /> )} {f.type === 'textarea' && ( setTemplateValues((prev) => ({ ...prev, [f.key]: val }))} placeholder={f.placeholder || ''} rows={3} ariaRequired={f.required} ariaLabel={f.label} /> )} {f.type === 'select' && f.options && ( setTemplateValues((prev) => ({ ...prev, [f.key]: val }))} placeholder={f.placeholder || 'Select...'} options={[ { value: '', label: f.placeholder || 'Select...' }, ...f.options.map((o) => ({ value: o, label: o })), ]} /> )} {fieldError(f.key)}
)) } return (

Share feedback

{onCancel && ( )}
{/* Template selector */} {templates.length > 0 && (
{label('Template')}
{templates.map((t) => ( ))}
)} {/* Type selector - only when not using a template */} {!selectedTemplate && (
{([ ['FEATURE_REQUEST', 'Feature Request'], ['BUG_REPORT', 'Bug Report'], ] as const).map(([value, lbl]) => ( ))}
)} {/* Title */}
{label('Title', true, 'post-title')} setTitle(e.target.value)} maxLength={200} aria-required="true" aria-invalid={!!fieldErrors.title} aria-describedby={fieldErrors.title ? 'err-title' : undefined} /> {fieldError('title')} {similar.length > 0 && (
Similar posts already exist - vote instead?
{similar.map((p) => ( {p.title} {p.voteCount} votes {p.similarity}% match ))}
)}
{/* Category */} {categories.length > 0 && (
{label('Category')} ({ value: c.slug, label: c.name })), ]} />
)} {/* Template fields or default fields */} {selectedTemplate ? ( renderTemplateFields() ) : type === 'BUG_REPORT' ? ( <>
{label('Steps to reproduce', true)} {fieldError('steps')}
{label('Expected behavior', true)} {fieldError('expected')}
{label('Actual behavior', true)} {fieldError('actual')}
{label('Environment / version')} setEnvironment(e.target.value)} />
{label('Additional context')}
) : ( <>
{label('Use case / problem statement', true)} {fieldError('useCase')}
{label('Proposed solution')}
{label('Alternatives considered')}
{label('Additional context')}
)}
{label('Attachments')}
{error && (
{error}
)}
Your ability to edit this post depends on your browser cookie.
{!auth.isPasskeyUser && ( )}
) }