User Response System Implementation
✍️ Complete Write → Save → Share Workflow
MyStoryFlow’s user response system enables users to create, save, and share their own content based on AI-generated prompts. This system follows the exact patterns established in the story-prompts implementation and provides a complete user-generated content workflow.
🏗️ System Architecture Overview
Response System Components
User Response Flow:
AI Content → User Inspiration → Response Creation → Auto-Save → Manual Save → Public Sharing → Community Discovery
Technical Components:
├── Response Database Tables
├── Writer Component (Rich text editor)
├── Auto-save System (Background persistence)
├── Manual Save (User-triggered with feedback)
├── Public/Private Toggle
├── Response Analytics
├── Moderation Queue
├── SEO-Optimized Public Pages
└── Community DiscoveryDatabase Architecture
The response system extends the main tool tables with dedicated response tables following this exact pattern:
-- ===========================================
-- USER RESPONSES TABLE (UNIVERSAL PATTERN)
-- ===========================================
CREATE TABLE IF NOT EXISTS tools_{tool_name}_responses (
-- Identity & Relationship
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
{tool_name}_id UUID REFERENCES tools_{tool_name}(id) ON DELETE CASCADE,
-- Ownership (Session-based + User-based)
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
session_id TEXT NOT NULL,
-- Content Data
title TEXT NOT NULL,
content TEXT NOT NULL,
content_type TEXT DEFAULT 'text/plain',
-- Automated Metrics
word_count INTEGER GENERATED ALWAYS AS (
array_length(string_to_array(trim(content), ' '), 1)
) STORED,
character_count INTEGER GENERATED ALWAYS AS (
length(trim(content))
) STORED,
-- Visibility & Moderation
is_public BOOLEAN DEFAULT FALSE,
is_featured BOOLEAN DEFAULT FALSE,
is_flagged BOOLEAN DEFAULT FALSE,
moderation_status TEXT DEFAULT 'pending' CHECK (
moderation_status IN ('pending', 'approved', 'rejected', 'review_required')
),
moderated_at TIMESTAMPTZ,
moderated_by UUID REFERENCES auth.users(id),
-- Engagement Analytics
view_count INTEGER DEFAULT 0,
like_count INTEGER DEFAULT 0,
share_count INTEGER DEFAULT 0,
flag_count INTEGER DEFAULT 0,
-- Quality Scoring
quality_score DECIMAL(3,2) DEFAULT 0.0,
engagement_score DECIMAL(5,2) GENERATED ALWAYS AS (
(like_count * 1.0) + (share_count * 2.0) + (view_count * 0.1) - (flag_count * 3.0)
) STORED,
-- SEO for Public Responses
seo_title TEXT,
seo_description TEXT,
slug TEXT UNIQUE,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
published_at TIMESTAMPTZ, -- When made public
last_edited_at TIMESTAMPTZ DEFAULT NOW(),
-- Constraints
CONSTRAINT valid_response_content CHECK (
length(trim(title)) >= 1 AND length(trim(content)) >= 1
),
CONSTRAINT valid_response_counts CHECK (
view_count >= 0 AND like_count >= 0 AND share_count >= 0 AND
flag_count >= 0 AND quality_score >= 0 AND quality_score <= 1
),
CONSTRAINT valid_response_ownership CHECK (
(user_id IS NULL AND session_id IS NOT NULL) OR
(user_id IS NOT NULL AND session_id IS NOT NULL)
)
);🎨 Writer Component Implementation
Universal Writer Component Pattern
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Button } from '@mystoryflow/ui/button'
import { Card } from '@mystoryflow/ui/card'
import { Input } from '@mystoryflow/ui/input'
import { Textarea } from '@mystoryflow/ui/textarea'
import { Badge } from '@mystoryflow/ui/badge'
import { useToast } from '@mystoryflow/ui/use-toast'
import {
Save, Share2, Download, Eye, EyeOff, Clock,
CheckCircle, AlertCircle, Loader2
} from 'lucide-react'
// ===========================================
// INTERFACES (UNIVERSAL)
// ===========================================
interface WriterProps {
parentId: string // ID of the AI-generated content
toolName: string // Tool identifier (e.g., 'plot-twists')
sessionId: string // User session ID
initialData?: ResponseData // For editing existing responses
onResponseCreated?: (response: ResponseData) => void
onResponseUpdated?: (response: ResponseData) => void
}
interface ResponseData {
id?: string
title: string
content: string
wordCount: number
characterCount: number
isPublic: boolean
createdAt?: string
updatedAt?: string
lastSavedAt?: string
}
interface WriterState extends ResponseData {
isSaving: boolean
lastSaved: Date | null
hasUnsavedChanges: boolean
saveStatus: 'idle' | 'saving' | 'saved' | 'error'
autoSaveEnabled: boolean
}
// ===========================================
// WRITER COMPONENT (UNIVERSAL PATTERN)
// ===========================================
export function UniversalWriter({
parentId,
toolName,
sessionId,
initialData,
onResponseCreated,
onResponseUpdated
}: WriterProps) {
// ===========================================
// STATE MANAGEMENT
// ===========================================
const [state, setState] = useState<WriterState>({
title: initialData?.title || '',
content: initialData?.content || '',
wordCount: 0,
characterCount: 0,
isPublic: initialData?.isPublic || false,
isSaving: false,
lastSaved: null,
hasUnsavedChanges: false,
saveStatus: 'idle',
autoSaveEnabled: true
})
const autoSaveTimeoutRef = useRef<NodeJS.Timeout>()
const { toast } = useToast()
// ===========================================
// AUTO-SAVE FUNCTIONALITY
// ===========================================
const saveContent = useCallback(async (showToast = false, isAutoSave = false) => {
if (!state.title.trim() && !state.content.trim()) {
if (showToast) {
toast({
title: "Nothing to Save",
description: "Please add a title and content before saving.",
variant: "destructive"
})
}
return null
}
if (!state.title.trim() || !state.content.trim()) {
if (showToast) {
toast({
title: "Incomplete Content",
description: "Both title and content are required.",
variant: "destructive"
})
}
return null
}
setState(prev => ({
...prev,
isSaving: true,
saveStatus: 'saving'
}))
try {
const endpoint = initialData?.id
? `/api/${toolName}/responses/${initialData.id}`
: `/api/${toolName}/${parentId}/responses`
const method = initialData?.id ? 'PATCH' : 'POST'
const response = await fetch(endpoint, {
method,
headers: {
'Content-Type': 'application/json',
'X-Session-ID': sessionId
},
body: JSON.stringify({
sessionId,
title: state.title.trim(),
content: state.content.trim(),
isPublic: state.isPublic
})
})
const data = await response.json()
if (!data.success) {
throw new Error(data.error?.message || 'Failed to save response')
}
const now = new Date()
setState(prev => ({
...prev,
lastSaved: now,
hasUnsavedChanges: false,
saveStatus: 'saved'
}))
// Call appropriate callback
if (initialData?.id) {
onResponseUpdated?.(data.data)
} else {
onResponseCreated?.(data.data)
}
if (showToast) {
toast({
title: "✅ Response Saved!",
description: isAutoSave
? "Your response has been auto-saved."
: "Your response has been saved successfully."
})
}
// Reset save status after 2 seconds
setTimeout(() => {
setState(prev => ({ ...prev, saveStatus: 'idle' }))
}, 2000)
return data.data
} catch (error) {
console.error('Save error:', error)
setState(prev => ({ ...prev, saveStatus: 'error' }))
if (showToast) {
toast({
title: "Save Failed",
description: error.message || "Please try again in a moment.",
variant: "destructive"
})
}
return null
} finally {
setState(prev => ({ ...prev, isSaving: false }))
}
}, [state.title, state.content, state.isPublic, parentId, toolName, sessionId, initialData?.id, onResponseCreated, onResponseUpdated, toast])
// ===========================================
// AUTO-SAVE TIMER MANAGEMENT
// ===========================================
useEffect(() => {
if (!state.autoSaveEnabled || !state.hasUnsavedChanges) return
// Clear existing timeout
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current)
}
// Set new auto-save timeout (30 seconds)
autoSaveTimeoutRef.current = setTimeout(() => {
if (state.hasUnsavedChanges && (state.title.trim() || state.content.trim())) {
saveContent(false, true) // Auto-save without toast
}
}, 30000)
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current)
}
}
}, [state.hasUnsavedChanges, state.autoSaveEnabled, saveContent])
// ===========================================
// KEYBOARD SHORTCUTS
// ===========================================
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Cmd/Ctrl + S for manual save
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault()
saveContent(true) // Manual save with toast
}
// Escape to toggle auto-save
if (e.key === 'Escape' && (e.metaKey || e.ctrlKey)) {
setState(prev => ({
...prev,
autoSaveEnabled: !prev.autoSaveEnabled
}))
toast({
title: state.autoSaveEnabled ? "Auto-save Disabled" : "Auto-save Enabled",
description: state.autoSaveEnabled
? "Your changes will not be saved automatically."
: "Your changes will be saved every 30 seconds."
})
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [saveContent, state.autoSaveEnabled, toast])
// ===========================================
// CONTENT METRICS UPDATE
// ===========================================
useEffect(() => {
const content = state.content.trim()
const words = content ? content.split(/\s+/).filter(word => word.length > 0) : []
setState(prev => ({
...prev,
wordCount: words.length,
characterCount: content.length
}))
}, [state.content])
// ===========================================
// EVENT HANDLERS
// ===========================================
const updateField = useCallback((field: keyof WriterState, value: any) => {
setState(prev => ({
...prev,
[field]: value,
hasUnsavedChanges: true,
lastEditedAt: new Date()
}))
}, [])
const handleManualSave = useCallback(() => {
saveContent(true) // Manual save with toast
}, [saveContent])
const handlePublicToggle = useCallback(() => {
setState(prev => ({
...prev,
isPublic: !prev.isPublic,
hasUnsavedChanges: true
}))
}, [])
const handleShare = useCallback(async () => {
// First save if there are unsaved changes
const savedResponse = state.hasUnsavedChanges
? await saveContent(false)
: null
// Implement sharing logic
if (savedResponse || !state.hasUnsavedChanges) {
toast({
title: "Share Feature",
description: "Sharing functionality will be implemented here."
})
}
}, [saveContent, state.hasUnsavedChanges, toast])
const handleExport = useCallback(async () => {
// First save if there are unsaved changes
const savedResponse = state.hasUnsavedChanges
? await saveContent(false)
: null
// Implement export logic
if (savedResponse || !state.hasUnsavedChanges) {
toast({
title: "Export Feature",
description: "Export functionality will be implemented here."
})
}
}, [saveContent, state.hasUnsavedChanges, toast])
// ===========================================
// RENDER STATUS INDICATOR
// ===========================================
const renderStatusIndicator = () => {
const getStatusIcon = () => {
switch (state.saveStatus) {
case 'saving':
return <Loader2 className="h-3 w-3 animate-spin" />
case 'saved':
return <CheckCircle className="h-3 w-3 text-green-500" />
case 'error':
return <AlertCircle className="h-3 w-3 text-red-500" />
default:
return <Clock className="h-3 w-3" />
}
}
const getStatusText = () => {
switch (state.saveStatus) {
case 'saving':
return 'Saving...'
case 'saved':
return 'Saved'
case 'error':
return 'Save failed'
default:
return state.lastSaved ? `Saved ${formatTimeAgo(state.lastSaved)}` : 'Not saved'
}
}
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{getStatusIcon()}
<span>{getStatusText()}</span>
{state.hasUnsavedChanges && (
<Badge variant="outline" className="text-xs">
Unsaved changes
</Badge>
)}
</div>
)
}
// ===========================================
// MAIN RENDER
// ===========================================
return (
<Card className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">
{initialData?.id ? 'Edit Your Response' : 'Write Your Response'}
</h3>
<div className="flex items-center gap-3">
{renderStatusIndicator()}
<Badge
variant={state.autoSaveEnabled ? "secondary" : "outline"}
className="text-xs cursor-pointer"
onClick={() => updateField('autoSaveEnabled', !state.autoSaveEnabled)}
>
Auto-save {state.autoSaveEnabled ? 'ON' : 'OFF'}
</Badge>
</div>
</div>
<div className="space-y-4">
{/* Title Input */}
<div>
<Input
size="sm"
placeholder="Give your response a compelling title..."
value={state.title}
onChange={(e) => updateField('title', e.target.value)}
className="text-lg font-medium"
disabled={state.isSaving}
/>
</div>
{/* Content Textarea */}
<div>
<Textarea
placeholder="Share your thoughts, story, creative response, or ideas here. Let your creativity flow..."
value={state.content}
onChange={(e) => updateField('content', e.target.value)}
rows={12}
className="resize-none"
disabled={state.isSaving}
/>
{/* Content Metrics */}
<div className="flex items-center justify-between mt-2 text-sm text-muted-foreground">
<div className="flex items-center gap-4">
<span>{state.wordCount} words</span>
<span>{state.characterCount} characters</span>
</div>
<span className="text-xs">
💡 Tip: Press Cmd/Ctrl + S to save manually
</span>
</div>
</div>
{/* Action Bar */}
<div className="flex items-center justify-between pt-4 border-t">
{/* Left side - Visibility toggle */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handlePublicToggle}
disabled={state.isSaving}
className="flex items-center gap-1"
>
{state.isPublic ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
{state.isPublic ? 'Public' : 'Private'}
</Button>
{state.isPublic && (
<Badge variant="outline" className="text-xs">
Will be visible to others
</Badge>
)}
</div>
{/* Right side - Action buttons */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleManualSave}
disabled={
state.isSaving ||
(!state.title.trim() && !state.content.trim()) ||
!state.title.trim() ||
!state.content.trim()
}
className="flex items-center gap-1"
>
{state.isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
{state.isSaving ? 'Saving...' : 'Save'}
</Button>
{state.lastSaved && (
<>
<Button
variant="outline"
size="sm"
onClick={handleShare}
disabled={state.isSaving}
className="flex items-center gap-1"
>
<Share2 className="h-4 w-4" />
Share
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExport}
disabled={state.isSaving}
className="flex items-center gap-1"
>
<Download className="h-4 w-4" />
Export
</Button>
</>
)}
</div>
</div>
{/* Help Text */}
<div className="text-xs text-muted-foreground bg-muted/50 rounded p-3">
<strong>Writing Tips:</strong>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>Auto-save happens every 30 seconds when enabled</li>
<li>Use Cmd/Ctrl + S to save manually anytime</li>
<li>Toggle visibility to share with the community</li>
<li>Private responses are only visible to you</li>
</ul>
</div>
</div>
</Card>
)
}
// ===========================================
// HELPER FUNCTIONS
// ===========================================
function formatTimeAgo(date: Date): string {
const now = new Date()
const diff = now.getTime() - date.getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return 'just now'
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}📡 API Implementation Patterns
Create Response Endpoint
// /api/{tool}/[id]/responses/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { checkRateLimit } from '@/lib/rate-limiter'
interface CreateResponseBody {
sessionId: string
title: string
content: string
isPublic?: boolean
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const startTime = Date.now()
const requestId = `resp_create_${Date.now()}`
const { id } = await params
try {
const body: CreateResponseBody = await request.json()
// Validation
if (!body.sessionId?.trim() || !body.title?.trim() || !body.content?.trim()) {
return NextResponse.json({
success: false,
error: {
code: 'INVALID_INPUT',
message: 'Session ID, title, and content are all required',
timestamp: new Date().toISOString()
}
}, { status: 400 })
}
// Content length validation
if (body.title.length > 200) {
return NextResponse.json({
success: false,
error: {
code: 'TITLE_TOO_LONG',
message: 'Title must be 200 characters or less',
field: 'title',
timestamp: new Date().toISOString()
}
}, { status: 400 })
}
if (body.content.length > 50000) {
return NextResponse.json({
success: false,
error: {
code: 'CONTENT_TOO_LONG',
message: 'Content must be 50,000 characters or less',
field: 'content',
timestamp: new Date().toISOString()
}
}, { status: 400 })
}
// Rate limiting
const rateLimitResult = await checkRateLimit(request, '{tool}_response_creation')
if (!rateLimitResult.allowed) {
return NextResponse.json({
success: false,
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: 'Too many responses created. Please wait before adding another.',
timestamp: new Date().toISOString()
},
meta: {
rateLimit: {
limit: rateLimitResult.limit,
remaining: rateLimitResult.remaining,
resetTime: rateLimitResult.resetTime,
retryAfter: rateLimitResult.retryAfter
}
}
}, { status: 429 })
}
const supabase = await createClient()
// Verify parent content exists and user has access
const { data: parentContent, error: parentError } = await supabase
.from('tools_{tool}')
.select('id, title, session_id, user_id')
.eq('id', id)
.single()
if (parentError || !parentContent) {
return NextResponse.json({
success: false,
error: {
code: 'PARENT_NOT_FOUND',
message: 'The original content was not found or you do not have access to it',
timestamp: new Date().toISOString()
}
}, { status: 404 })
}
// Create response with moderation status
const { data: response, error: createError } = await supabase
.from('tools_{tool}_responses')
.insert({
{tool}_id: id,
session_id: body.sessionId,
title: body.title.trim(),
content: body.content.trim(),
is_public: body.isPublic || false,
moderation_status: body.isPublic ? 'pending' : 'approved', // Public content needs moderation
published_at: body.isPublic ? new Date().toISOString() : null
})
.select(`
id,
title,
content,
word_count,
character_count,
is_public,
moderation_status,
created_at,
updated_at
`)
.single()
if (createError) {
console.error('Response creation error:', createError)
return NextResponse.json({
success: false,
error: {
code: 'CREATE_FAILED',
message: 'Failed to save your response. Please try again.',
timestamp: new Date().toISOString()
}
}, { status: 500 })
}
// Update parent content response count
await supabase
.from('tools_{tool}')
.update({
response_count: parentContent.response_count + 1,
updated_at: new Date().toISOString()
})
.eq('id', id)
return NextResponse.json({
success: true,
data: {
id: response.id,
title: response.title,
content: response.content,
wordCount: response.word_count,
characterCount: response.character_count,
isPublic: response.is_public,
moderationStatus: response.moderation_status,
createdAt: response.created_at,
updatedAt: response.updated_at
},
meta: {
timestamp: new Date().toISOString(),
requestId,
processingTime: Date.now() - startTime
}
})
} catch (error) {
console.error(`{tool} response creation error:`, error)
return NextResponse.json({
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred while saving your response',
timestamp: new Date().toISOString()
},
meta: {
requestId,
processingTime: Date.now() - startTime
}
}, { status: 500 })
}
}
// Get responses for a piece of content
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
try {
const { searchParams } = new URL(request.url)
const page = Math.max(1, parseInt(searchParams.get('page') || '1'))
const limit = Math.min(50, Math.max(1, parseInt(searchParams.get('limit') || '10')))
const publicOnly = searchParams.get('public') === 'true'
const sessionId = searchParams.get('sessionId')
const offset = (page - 1) * limit
const supabase = await createClient()
let query = supabase
.from('tools_{tool}_responses')
.select(`
id,
title,
content,
word_count,
character_count,
is_public,
view_count,
like_count,
share_count,
quality_score,
created_at,
updated_at
`, { count: 'exact' })
.eq('{tool}_id', id)
// Apply visibility filters
if (publicOnly) {
query = query.eq('is_public', true).eq('moderation_status', 'approved')
} else if (sessionId) {
// Show user's own responses (public and private) plus public approved responses
query = query.or(`session_id.eq.${sessionId},and(is_public.eq.true,moderation_status.eq.approved)`)
} else {
// Default: only public approved responses
query = query.eq('is_public', true).eq('moderation_status', 'approved')
}
// Sort by creation date (newest first)
query = query.order('created_at', { ascending: false })
// Apply pagination
query = query.range(offset, offset + limit - 1)
const { data: responses, error, count } = await query
if (error) {
throw new Error(`Failed to fetch responses: ${error.message}`)
}
const totalPages = Math.ceil((count || 0) / limit)
return NextResponse.json({
success: true,
data: responses || [],
meta: {
timestamp: new Date().toISOString(),
pagination: {
page,
limit,
total: count || 0,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1
}
}
})
} catch (error) {
console.error(`{tool} responses fetch error:`, error)
return NextResponse.json({
success: false,
error: {
code: 'FETCH_FAILED',
message: 'Failed to load responses',
timestamp: new Date().toISOString()
}
}, { status: 500 })
}
}Update Response Endpoint
// /api/{tool}/responses/[responseId]/route.ts
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ responseId: string }> }
) {
const startTime = Date.now()
const { responseId } = await params
try {
const body = await request.json()
// Validation
if (!body.sessionId) {
return NextResponse.json({
success: false,
error: {
code: 'MISSING_SESSION_ID',
message: 'Session ID is required',
timestamp: new Date().toISOString()
}
}, { status: 400 })
}
const supabase = await createClient()
// Verify ownership and get current response
const { data: currentResponse, error: fetchError } = await supabase
.from('tools_{tool}_responses')
.select('*')
.eq('id', responseId)
.single()
if (fetchError || !currentResponse) {
return NextResponse.json({
success: false,
error: {
code: 'RESPONSE_NOT_FOUND',
message: 'Response not found or access denied',
timestamp: new Date().toISOString()
}
}, { status: 404 })
}
// Check ownership
if (currentResponse.session_id !== body.sessionId) {
return NextResponse.json({
success: false,
error: {
code: 'ACCESS_DENIED',
message: 'You can only edit your own responses',
timestamp: new Date().toISOString()
}
}, { status: 403 })
}
// Build update object
const updates: any = {
updated_at: new Date().toISOString()
}
if (body.title !== undefined) {
if (!body.title.trim() || body.title.length > 200) {
return NextResponse.json({
success: false,
error: {
code: 'INVALID_TITLE',
message: 'Title must be between 1 and 200 characters',
timestamp: new Date().toISOString()
}
}, { status: 400 })
}
updates.title = body.title.trim()
}
if (body.content !== undefined) {
if (!body.content.trim() || body.content.length > 50000) {
return NextResponse.json({
success: false,
error: {
code: 'INVALID_CONTENT',
message: 'Content must be between 1 and 50,000 characters',
timestamp: new Date().toISOString()
}
}, { status: 400 })
}
updates.content = body.content.trim()
}
if (body.isPublic !== undefined) {
updates.is_public = body.isPublic
// If making public, set moderation status and published date
if (body.isPublic && !currentResponse.is_public) {
updates.moderation_status = 'pending'
updates.published_at = new Date().toISOString()
}
// If making private, clear published date
if (!body.isPublic && currentResponse.is_public) {
updates.published_at = null
}
}
// Update response
const { data: updatedResponse, error: updateError } = await supabase
.from('tools_{tool}_responses')
.update(updates)
.eq('id', responseId)
.select(`
id,
title,
content,
word_count,
character_count,
is_public,
moderation_status,
created_at,
updated_at
`)
.single()
if (updateError) {
throw new Error(`Update failed: ${updateError.message}`)
}
return NextResponse.json({
success: true,
data: {
id: updatedResponse.id,
title: updatedResponse.title,
content: updatedResponse.content,
wordCount: updatedResponse.word_count,
characterCount: updatedResponse.character_count,
isPublic: updatedResponse.is_public,
moderationStatus: updatedResponse.moderation_status,
createdAt: updatedResponse.created_at,
updatedAt: updatedResponse.updated_at
},
meta: {
timestamp: new Date().toISOString(),
processingTime: Date.now() - startTime
}
})
} catch (error) {
console.error(`{tool} response update error:`, error)
return NextResponse.json({
success: false,
error: {
code: 'UPDATE_FAILED',
message: 'Failed to update response',
timestamp: new Date().toISOString()
}
}, { status: 500 })
}
}🌟 Integration with Main Tool Component
Adding Writer to Tool Landing
// In your main tool landing component
import { UniversalWriter } from '@/components/shared/UniversalWriter'
export function ToolLanding() {
const [result, setResult] = useState<ToolResult | null>(null)
const [responses, setResponses] = useState<ResponseData[]>([])
const handleResponseCreated = useCallback((newResponse: ResponseData) => {
setResponses(prev => [newResponse, ...prev])
toast({
title: "Response Created!",
description: "Your response has been saved and can be shared with others."
})
}, [toast])
const handleResponseUpdated = useCallback((updatedResponse: ResponseData) => {
setResponses(prev =>
prev.map(response =>
response.id === updatedResponse.id ? updatedResponse : response
)
)
toast({
title: "Response Updated!",
description: "Your changes have been saved."
})
}, [toast])
return (
<div className="max-w-4xl mx-auto">
{/* Tool generation UI */}
{/* Results display */}
{result && (
<div className="space-y-6">
{/* Generated content display */}
<Card className="p-6">
{/* Tool-specific content display */}
</Card>
{/* Writer component */}
<UniversalWriter
parentId={result.id}
toolName="your-tool-name"
sessionId={sessionId}
onResponseCreated={handleResponseCreated}
onResponseUpdated={handleResponseUpdated}
/>
{/* Community responses */}
{responses.length > 0 && (
<div className="space-y-4">
<h3 className="text-xl font-semibold">Community Responses</h3>
{responses.map(response => (
<ResponseCard
key={response.id}
response={response}
/>
))}
</div>
)}
</div>
)}
</div>
)
}🎯 Best Practices & Guidelines
Content Moderation
- Auto-approve private content - No moderation needed
- Queue public content - Require approval before visibility
- Implement flagging system - Community reporting
- Quality scoring - Automated quality assessment
Performance Optimization
- Debounced auto-save - Prevent excessive API calls
- Optimistic updates - Immediate UI feedback
- Lazy loading - Load responses on demand
- Caching strategy - Cache frequently accessed responses
User Experience
- Clear save status - Visual feedback on save state
- Keyboard shortcuts - Power user efficiency
- Auto-save toggle - User control over auto-save
- Unsaved changes warning - Prevent data loss
This comprehensive user response system provides a complete workflow for user-generated content, maintaining consistency with MyStoryFlow’s quality standards while enabling rich community interaction.