Files
echoboard/packages/web/src/components/PostForm.tsx

517 lines
17 KiB
TypeScript

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<PostType>('FEATURE_REQUEST')
const [title, setTitle] = useState('')
const [category, setCategory] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
const [categories, setCategories] = useState<{ id: string; name: string; slug: string }[]>([])
const [similar, setSimilar] = useState<SimilarPost[]>([])
// templates
const [templates, setTemplates] = useState<Template[]>([])
const [selectedTemplateId, setSelectedTemplateId] = useState('')
const [templateValues, setTemplateValues] = useState<Record<string, string>>({})
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<string[]>([])
// 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<string, string>
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] ? (
<span id={`err-${key}`} role="alert" className="mt-0.5 block" style={{ color: 'var(--error)', fontSize: 'var(--text-xs)' }}>{fieldErrors[key]}</span>
) : null
const label = (text: string, required?: boolean, htmlFor?: string) => (
<label htmlFor={htmlFor} className="block font-medium mb-1 uppercase tracking-wider" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{text}{required && <span style={{ color: 'var(--error)' }}> *</span>}
</label>
)
const handleTemplateChange = (id: string) => {
setSelectedTemplateId(id)
setTemplateValues({})
setFieldErrors({})
}
const renderTemplateFields = () => {
if (!selectedTemplate) return null
return selectedTemplate.fields.map((f) => (
<div key={f.key} className="mb-3">
{label(f.label, f.required)}
{f.type === 'text' && (
<input
className="input w-full"
placeholder={f.placeholder || ''}
value={templateValues[f.key] || ''}
onChange={(e) => 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' && (
<MarkdownEditor
value={templateValues[f.key] || ''}
onChange={(val) => setTemplateValues((prev) => ({ ...prev, [f.key]: val }))}
placeholder={f.placeholder || ''}
rows={3}
ariaRequired={f.required}
ariaLabel={f.label}
/>
)}
{f.type === 'select' && f.options && (
<Dropdown
value={templateValues[f.key] || ''}
onChange={(val) => 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)}
</div>
))
}
return (
<div
className="card card-static p-5"
style={{ animation: 'slideUp var(--duration-normal) var(--ease-out)' }}
>
<div className="flex items-center justify-between mb-4">
<h2
className="font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-base)' }}
>
Share feedback
</h2>
{onCancel && (
<button
onClick={() => { reset(); onCancel() }}
className="btn btn-ghost"
style={{ fontSize: 'var(--text-xs)' }}
>
Cancel
</button>
)}
</div>
{/* Template selector */}
{templates.length > 0 && (
<div className="mb-4">
{label('Template')}
<div className="flex gap-2 flex-wrap">
<button
onClick={() => handleTemplateChange('')}
className="px-3 py-1.5 font-medium"
style={{
background: !selectedTemplateId ? 'var(--accent-subtle)' : 'transparent',
color: !selectedTemplateId ? 'var(--accent)' : 'var(--text-tertiary)',
border: `1px solid ${!selectedTemplateId ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-xs)',
transition: 'all var(--duration-fast) ease-out',
}}
>
Default
</button>
{templates.map((t) => (
<button
key={t.id}
onClick={() => handleTemplateChange(t.id)}
className="px-3 py-1.5 font-medium"
style={{
background: selectedTemplateId === t.id ? 'var(--accent-subtle)' : 'transparent',
color: selectedTemplateId === t.id ? 'var(--accent)' : 'var(--text-tertiary)',
border: `1px solid ${selectedTemplateId === t.id ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-xs)',
transition: 'all var(--duration-fast) ease-out',
}}
>
{t.name}
</button>
))}
</div>
</div>
)}
{/* Type selector - only when not using a template */}
{!selectedTemplate && (
<div className="flex gap-2 mb-4">
{([
['FEATURE_REQUEST', 'Feature Request'],
['BUG_REPORT', 'Bug Report'],
] as const).map(([value, lbl]) => (
<button
key={value}
onClick={() => setType(value)}
className="px-3 py-1.5 font-medium"
style={{
background: type === value ? 'var(--accent-subtle)' : 'transparent',
color: type === value ? 'var(--accent)' : 'var(--text-tertiary)',
border: `1px solid ${type === value ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-xs)',
transition: 'all var(--duration-fast) ease-out',
}}
>
{lbl}
</button>
))}
</div>
)}
{/* Title */}
<div className="mb-3">
{label('Title', true, 'post-title')}
<input
id="post-title"
className="input w-full"
placeholder="Brief summary"
value={title}
onChange={(e) => 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 && (
<div
className="mt-2 rounded-lg overflow-hidden"
style={{ border: '1px solid var(--border-accent)', background: 'var(--surface)' }}
>
<div className="px-3 py-1.5" style={{ background: 'var(--accent-subtle)', color: 'var(--accent)', fontSize: 'var(--text-xs)', fontWeight: 500 }}>
Similar posts already exist - vote instead?
</div>
{similar.map((p) => (
<a
key={p.id}
href={`/b/${boardSlug}/post/${p.id}`}
className="flex items-center justify-between px-3 py-2"
style={{ borderTop: '1px solid var(--border)', fontSize: 'var(--text-xs)', color: 'var(--text)' }}
>
<span className="truncate mr-2">{p.title}</span>
<span className="shrink-0 flex items-center gap-2" style={{ color: 'var(--text-tertiary)' }}>
<span style={{ color: 'var(--accent)' }}>{p.voteCount} votes</span>
<span>{p.similarity}% match</span>
</span>
</a>
))}
</div>
)}
</div>
{/* Category */}
{categories.length > 0 && (
<div className="mb-3">
{label('Category')}
<Dropdown
value={category}
onChange={setCategory}
placeholder="No category"
options={[
{ value: '', label: 'No category' },
...categories.map((c) => ({ value: c.slug, label: c.name })),
]}
/>
</div>
)}
{/* Template fields or default fields */}
{selectedTemplate ? (
renderTemplateFields()
) : type === 'BUG_REPORT' ? (
<>
<div className="mb-3">
{label('Steps to reproduce', true)}
<MarkdownEditor
value={steps}
onChange={setSteps}
placeholder="1. Go to... 2. Click on... 3. See error"
rows={3}
ariaRequired
ariaLabel="Steps to reproduce"
/>
{fieldError('steps')}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
<div>
{label('Expected behavior', true)}
<MarkdownEditor value={expected} onChange={setExpected} placeholder="What should happen?" rows={2} ariaRequired ariaLabel="Expected behavior" />
{fieldError('expected')}
</div>
<div>
{label('Actual behavior', true)}
<MarkdownEditor value={actual} onChange={setActual} placeholder="What actually happens?" rows={2} ariaRequired ariaLabel="Actual behavior" />
{fieldError('actual')}
</div>
</div>
<div className="mb-3">
{label('Environment / version')}
<input
className="input w-full"
placeholder="OS, browser, app version..."
value={environment}
onChange={(e) => setEnvironment(e.target.value)}
/>
</div>
<div className="mb-3">
{label('Additional context')}
<MarkdownEditor value={bugContext} onChange={setBugContext} placeholder="Screenshots, logs, anything else..." rows={2} />
</div>
</>
) : (
<>
<div className="mb-3">
{label('Use case / problem statement', true)}
<MarkdownEditor value={useCase} onChange={setUseCase} placeholder="What are you trying to accomplish?" rows={3} ariaRequired ariaLabel="Use case" />
{fieldError('useCase')}
</div>
<div className="mb-3">
{label('Proposed solution')}
<MarkdownEditor value={proposedSolution} onChange={setProposedSolution} placeholder="How do you think this should work?" rows={2} />
</div>
<div className="mb-3">
{label('Alternatives considered')}
<MarkdownEditor value={alternatives} onChange={setAlternatives} placeholder="Have you tried any workarounds?" rows={2} />
</div>
<div className="mb-3">
{label('Additional context')}
<MarkdownEditor value={featureContext} onChange={setFeatureContext} placeholder="Anything else to add?" rows={2} />
</div>
</>
)}
<div className="mb-3">
{label('Attachments')}
<FileUpload attachmentIds={attachmentIds} onChange={setAttachmentIds} />
</div>
{error && (
<div role="alert" className="text-xs mb-3" style={{ color: 'var(--error)' }}>{error}</div>
)}
<div className="flex items-center justify-between">
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
Your ability to edit this post depends on your browser cookie.
</span>
<button
onClick={submit}
disabled={submitting}
className="btn btn-primary"
style={{ opacity: submitting ? 0.6 : 1 }}
>
{submitting ? 'Submitting...' : 'Submit'}
</button>
</div>
{!auth.isPasskeyUser && (
<div className="mt-3 pt-3" style={{ borderTop: '1px solid var(--border)' }}>
<a
href="/settings"
className="flex items-center gap-2"
style={{ color: 'var(--accent)', fontSize: 'var(--text-xs)' }}
>
<IconFingerprint size={14} stroke={2} />
Save my identity to keep your posts across devices
</a>
</div>
)}
</div>
)
}