security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states

This commit is contained in:
2026-03-21 17:37:01 +02:00
parent f07eddf29e
commit 5ba25fb956
142 changed files with 30397 additions and 2287 deletions

View File

@@ -1,174 +1,495 @@
import { useState, useRef } from 'react'
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' | 'bug' | 'general'
type PostType = 'FEATURE_REQUEST' | 'BUG_REPORT'
export default function PostForm({ boardSlug, onSubmit }: Props) {
const [expanded, setExpanded] = useState(false)
const [type, setType] = useState<PostType>('feature')
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 [body, setBody] = useState('')
const [expected, setExpected] = useState('')
const [actual, setActual] = useState('')
const [steps, setSteps] = useState('')
const [category, setCategory] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const formRef = useRef<HTMLDivElement>(null)
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('')
setBody('')
setCategory('')
setSteps('')
setExpected('')
setActual('')
setSteps('')
setEnvironment('')
setBugContext('')
setUseCase('')
setProposedSolution('')
setAlternatives('')
setFeatureContext('')
setAttachmentIds([])
setTemplateValues({})
setError('')
setExpanded(false)
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 (!title.trim()) {
setError('Title is required')
return
}
if (!validate()) return
setSubmitting(true)
setError('')
const payload: Record<string, string> = { title, type, body }
if (type === 'bug') {
payload.stepsToReproduce = steps
payload.expected = expected
payload.actual = actual
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`, payload)
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 (e) {
} catch {
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>
)
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 ref={formRef} className="card p-4 slide-up">
<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">
<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) => (
<h2
className="font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--text)', fontSize: 'var(--text-base)' }}
>
Share feedback
</h2>
{onCancel && (
<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',
}}
onClick={() => { reset(); onCancel() }}
className="btn btn-ghost"
style={{ fontSize: 'var(--text-xs)' }}
>
{t === 'feature' ? 'Feature Request' : t === 'bug' ? 'Bug Report' : 'General'}
Cancel
</button>
))}
)}
</div>
<input
className="input mb-3"
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
{/* 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>
)}
<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 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>
)}
{type === 'bug' && (
<>
<textarea
className="input mb-3"
placeholder="Steps to reproduce"
rows={2}
value={steps}
onChange={(e) => setSteps(e.target.value)}
style={{ resize: 'vertical' }}
{/* 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 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' }}
</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"
/>
<textarea
className="input"
placeholder="Actual behavior"
rows={2}
value={actual}
onChange={(e) => setActual(e.target.value)}
style={{ resize: 'vertical' }}
{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>
</>
)}
{/* 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 className="mb-3">
{label('Attachments')}
<FileUpload attachmentIds={attachmentIds} onChange={setAttachmentIds} />
</div>
{error && (
<div className="text-xs mb-3" style={{ color: 'var(--error)' }}>{error}</div>
<div role="alert" className="text-xs mb-3" style={{ color: 'var(--error)' }}>{error}</div>
)}
<div className="flex justify-end">
<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}
@@ -178,6 +499,18 @@ export default function PostForm({ boardSlug, onSubmit }: Props) {
{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>
)
}