security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user