517 lines
17 KiB
TypeScript
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>
|
|
)
|
|
}
|