Barazo default frontend barazo.forum
at main 156 lines 5.2 kB view raw
1/** 2 * TopicForm - Complete topic creation/edit form. 3 * Title, category, tags, markdown editor with preview, cross-post options. 4 * Client-side validation matching API Zod schemas. 5 * @see specs/prd-web.md Section 4 (Editor Components) 6 */ 7 8'use client' 9 10import { useState, useCallback } from 'react' 11import type { CreateTopicInput, CategoryTreeNode } from '@/lib/api/types' 12import { cn } from '@/lib/utils' 13import { TopicMetaFields } from '@/components/topic-meta-fields' 14import { TopicContentEditor } from '@/components/topic-content-editor' 15import { TopicCrossPostSection } from '@/components/topic-cross-post-section' 16import { CrossPostAuthDialog } from '@/components/crosspost-auth-dialog' 17import { validateTopicForm } from '@/components/topic-form-validation' 18import type { TopicFormValues, FormErrors } from '@/components/topic-form-validation' 19import { useAuth } from '@/hooks/use-auth' 20 21interface TopicFormProps { 22 onSubmit: (values: CreateTopicInput) => void | Promise<void> 23 initialValues?: Partial<TopicFormValues> 24 mode?: 'create' | 'edit' 25 categories?: CategoryTreeNode[] 26 submitting?: boolean 27 className?: string 28} 29 30const CATEGORIES_FALLBACK: CategoryTreeNode[] = [ 31 { 32 id: 'fallback-general', 33 slug: 'general', 34 name: 'General Discussion', 35 description: null, 36 parentId: null, 37 sortOrder: 0, 38 communityDid: '', 39 maturityRating: 'safe', 40 createdAt: '', 41 updatedAt: '', 42 children: [], 43 }, 44] 45 46export function TopicForm({ 47 onSubmit, 48 initialValues, 49 mode = 'create', 50 categories = CATEGORIES_FALLBACK, 51 submitting = false, 52 className, 53}: TopicFormProps) { 54 const [title, setTitle] = useState(initialValues?.title ?? '') 55 const [content, setContent] = useState(initialValues?.content ?? '') 56 const [category, setCategory] = useState(initialValues?.category ?? '') 57 const [tagInput, setTagInput] = useState(initialValues?.tags?.join(', ') ?? '') 58 const [crossPostBluesky, setCrossPostBluesky] = useState(initialValues?.crossPostBluesky ?? true) 59 const [crossPostFrontpage, setCrossPostFrontpage] = useState( 60 initialValues?.crossPostFrontpage ?? false 61 ) 62 const [errors, setErrors] = useState<FormErrors>({}) 63 const [showCrossPostAuthDialog, setShowCrossPostAuthDialog] = useState(false) 64 const { crossPostScopesGranted, requestCrossPostAuth } = useAuth() 65 66 const handleSubmit = useCallback( 67 (e: React.FormEvent) => { 68 e.preventDefault() 69 const values: TopicFormValues = { 70 title, 71 content, 72 category, 73 tags: tagInput 74 .split(',') 75 .map((t) => t.trim()) 76 .filter(Boolean), 77 crossPostBluesky, 78 crossPostFrontpage, 79 } 80 const validationErrors = validateTopicForm(values) 81 setErrors(validationErrors) 82 if (Object.keys(validationErrors).length > 0) return 83 onSubmit({ 84 title: values.title.trim(), 85 content: values.content.trim(), 86 category: values.category, 87 tags: values.tags?.length ? values.tags : undefined, 88 crossPostBluesky: values.crossPostBluesky, 89 crossPostFrontpage: values.crossPostFrontpage, 90 }) 91 }, 92 [title, content, category, tagInput, crossPostBluesky, crossPostFrontpage, onSubmit] 93 ) 94 95 return ( 96 <form 97 onSubmit={handleSubmit} 98 className={cn('space-y-6', className)} 99 noValidate 100 aria-label={mode === 'create' ? 'Create new topic' : 'Edit topic'} 101 > 102 <TopicMetaFields 103 title={title} 104 category={category} 105 tagInput={tagInput} 106 categories={categories} 107 errors={errors} 108 onTitleChange={setTitle} 109 onCategoryChange={setCategory} 110 onTagInputChange={setTagInput} 111 /> 112 113 <TopicContentEditor content={content} onChange={setContent} error={errors.content} required /> 114 115 {mode === 'create' && ( 116 <TopicCrossPostSection 117 crossPostScopesGranted={crossPostScopesGranted} 118 crossPostBluesky={crossPostBluesky} 119 crossPostFrontpage={crossPostFrontpage} 120 onCrossPostBlueskyChange={setCrossPostBluesky} 121 onCrossPostFrontpageChange={setCrossPostFrontpage} 122 onAuthorizeClick={() => setShowCrossPostAuthDialog(true)} 123 /> 124 )} 125 126 <CrossPostAuthDialog 127 open={showCrossPostAuthDialog} 128 onAuthorize={() => { 129 setShowCrossPostAuthDialog(false) 130 void requestCrossPostAuth() 131 }} 132 onCancel={() => setShowCrossPostAuthDialog(false)} 133 /> 134 135 <div className="flex justify-end"> 136 <button 137 type="submit" 138 disabled={submitting} 139 className={cn( 140 'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors', 141 'hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', 142 'disabled:cursor-not-allowed disabled:opacity-50' 143 )} 144 > 145 {submitting 146 ? mode === 'create' 147 ? 'Creating...' 148 : 'Saving...' 149 : mode === 'create' 150 ? 'Create Topic' 151 : 'Save Changes'} 152 </button> 153 </div> 154 </form> 155 ) 156}