From efbb1323b8c0293003e3c0dda2cae5287729e40f Mon Sep 17 00:00:00 2001 From: arcgod-design Date: Tue, 23 Jun 2026 23:09:51 +0530 Subject: [PATCH 1/4] feat: add zod validation and character counters for forum posts (closes #19) - app/src/lib/validators.ts: CreatePostSchema with title (10-150), content (20-10000), tags validation - CommunityForum.tsx: Replace raw useState with react-hook-form + zodResolver, add inline errors and character counters - forumController.ts: Add server-side validation for title, content, and tags with proper 400 responses --- app/src/lib/validators.ts | 22 +++++ app/src/sections/CommunityForum.tsx | 106 +++++++++++++++------ backend/src/controllers/forumController.ts | 41 +++++++- 3 files changed, 134 insertions(+), 35 deletions(-) create mode 100644 app/src/lib/validators.ts diff --git a/app/src/lib/validators.ts b/app/src/lib/validators.ts new file mode 100644 index 0000000..96a0241 --- /dev/null +++ b/app/src/lib/validators.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +export const createPostSchema = z.object({ + title: z + .string() + .trim() + .min(1, 'Title is required') + .min(10, 'Title must be at least 10 characters') + .max(150, 'Title must be less than 150 characters'), + content: z + .string() + .trim() + .min(1, 'Content is required') + .min(20, 'Content must be at least 20 characters') + .max(10000, 'Content must be less than 10,000 characters'), + category: z.string().default('general'), +}); + +export type CreatePostFormData = z.infer; + +export const TITLE_MAX = 150; +export const CONTENT_MAX = 10000; diff --git a/app/src/sections/CommunityForum.tsx b/app/src/sections/CommunityForum.tsx index 167c2ed..82f5c14 100644 --- a/app/src/sections/CommunityForum.tsx +++ b/app/src/sections/CommunityForum.tsx @@ -1,4 +1,6 @@ import { useState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; import { motion, AnimatePresence } from 'framer-motion'; import { ArrowLeft, @@ -24,6 +26,7 @@ import { } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useAuth } from '@/contexts/AuthContext'; +import { createPostSchema, TITLE_MAX, CONTENT_MAX } from '@/lib/validators'; import { getPosts, getPost, @@ -95,11 +98,27 @@ export function CommunityForum({ onBack, onAuthClick }: CommunityForumProps) { // Create post state const [showCreateForm, setShowCreateForm] = useState(false); - const [newTitle, setNewTitle] = useState(''); - const [newContent, setNewContent] = useState(''); - const [newCategory, setNewCategory] = useState('general'); const [createSubmitting, setCreateSubmitting] = useState(false); + const { + register, + handleSubmit, + watch, + reset, + formState: { errors, isValid }, + } = useForm({ + resolver: zodResolver(createPostSchema), + defaultValues: { + title: '', + content: '', + category: 'general', + }, + mode: 'onChange', + }); + + const watchTitle = watch('title'); + const watchContent = watch('content'); + // Fetch posts useEffect(() => { fetchPosts(); @@ -130,23 +149,20 @@ export function CommunityForum({ onBack, onAuthClick }: CommunityForumProps) { } }; - const handleCreatePost = async () => { + const handleCreatePost = async (data: { title: string; content: string; category: string }) => { if (!user) { onAuthClick('login'); return; } - if (!newTitle.trim() || !newContent.trim()) return; setCreateSubmitting(true); try { await createPost({ - title: newTitle.trim(), - content: newContent.trim(), - category: newCategory + title: data.title.trim(), + content: data.content.trim(), + category: data.category, }); - setNewTitle(''); - setNewContent(''); - setNewCategory('general'); + reset(); setShowCreateForm(false); fetchPosts(); } catch (err) { @@ -488,34 +504,64 @@ export function CommunityForum({ onBack, onAuthClick }: CommunityForumProps) { exit={{ opacity: 0, height: 0 }} className="overflow-hidden mb-6" > -
+

Create New Post

-
- setNewTitle(e.target.value)} - placeholder="Post title..." - className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#a088ff]/50 mb-3" - /> +
+ +
+ {errors.title ? ( + {errors.title.message} + ) : ( + + )} + = TITLE_MAX ? 'text-red-400' : 'text-white/30'}`}> + {watchTitle.length} / {TITLE_MAX} + +
+
-