Component Architecture & Reusable Patterns
🏗️ Universal Component System
MyStoryFlow’s component architecture maximizes code reuse and consistency across all tools. Every tool follows identical component patterns with 95% shared code and minimal tool-specific customization.
📋 Component Hierarchy Standard
Core Tool Components Structure
Every tool implements this exact component hierarchy:
src/components/\{tool\}/
├── \{Tool\}Landing.tsx # Main tool page (generator + hero)
├── \{Tool\}Browse.tsx # Browse/discover existing content
├── \{Tool\}Writer.tsx # User response writing interface
├── \{Tool\}Card.tsx # Content display card
├── \{Tool\}PublicView.tsx # SEO-optimized shared content view
└── shared/ # Tool-specific shared components
├── \{Tool\}FilterPanel.tsx
├── \{Tool\}AnalysisPanel.tsx
└── \{Tool\}ExportOptions.tsxUniversal Page Structure
// Universal page component pattern
export default async function ToolPage({
params,
searchParams
}: {
params: Promise<{ [key: string]: string }>
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
const resolvedParams = await params
const resolvedSearchParams = await searchParams
return (
<div className="min-h-screen bg-background">
<ToolHeader />
<main className="container mx-auto px-4 py-8">
<ToolLanding
params={resolvedParams}
searchParams={resolvedSearchParams}
/>
</main>
<ConversionFooter />
</div>
)
}🎨 Core Landing Component Pattern
{Tool}Landing.tsx - Master Template
'use client'
import { useState, useEffect, useCallback } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@mystoryflow/ui/select'
import { useToast } from '@mystoryflow/ui/use-toast'
import { Loader2, Download, Share2, BookOpen, Sparkles } from 'lucide-react'
// Universal interfaces
interface ToolLandingProps {
params?: { [key: string]: string }
searchParams?: { [key: string]: string | string[] | undefined }
}
interface ToolOptions {
// Tool-specific options interface
// Each tool extends this base interface
}
interface ToolResult {
id: string
shareCode: string
title: string
content: any
analysis: any
seoMetadata: {
title: string
description: string
keywords: string[]
}
aiMetadata: {
model: string
tokensUsed: number
processingTime: number
confidenceScore: number
}
}
export function ToolLanding({ params, searchParams }: ToolLandingProps) {
// ===========================================
// STATE MANAGEMENT (UNIVERSAL PATTERN)
// ===========================================
const [options, setOptions] = useState<ToolOptions>({
// Tool-specific default options
})
const [result, setResult] = useState<ToolResult | null>(null)
const [isGenerating, setIsGenerating] = useState(false)
const [sessionId, setSessionId] = useState<string>('')
const [generationCount, setGenerationCount] = useState(0)
const [showConversionCTA, setShowConversionCTA] = useState(false)
const { toast } = useToast()
// ===========================================
// SESSION MANAGEMENT (UNIVERSAL)
// ===========================================
useEffect(() => {
// Initialize or retrieve session ID
let currentSessionId = localStorage.getItem('mystoryflow_session_id')
if (!currentSessionId) {
currentSessionId = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
localStorage.setItem('mystoryflow_session_id', currentSessionId)
}
setSessionId(currentSessionId)
// Load generation count for conversion tracking
const savedCount = localStorage.getItem(`${tool}_generation_count`)
setGenerationCount(parseInt(savedCount || '0'))
}, [])
// ===========================================
// GENERATION HANDLER (UNIVERSAL PATTERN)
// ===========================================
const handleGenerate = useCallback(async () => {
if (!sessionId) return
setIsGenerating(true)
setResult(null)
try {
const response = await fetch(`/api/{tool}/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Session-ID': sessionId
},
body: JSON.stringify({
sessionId,
options
})
})
const data = await response.json()
if (!response.ok) {
// Handle rate limiting with conversion opportunity
if (response.status === 429) {
setShowConversionCTA(true)
toast({
title: "Generation Limit Reached",
description: data.error.message,
variant: "destructive"
})
return
}
throw new Error(data.error?.message || 'Generation failed')
}
setResult(data.data)
setGenerationCount(prev => {
const newCount = prev + 1
localStorage.setItem(`${tool}_generation_count`, newCount.toString())
return newCount
})
// Show conversion CTA based on usage
if (data.meta?.conversionOpportunity) {
setShowConversionCTA(true)
}
toast({
title: "✨ Content Generated!",
description: "Your content is ready. You can now write a response or share it."
})
} catch (error) {
console.error('Generation error:', error)
toast({
title: "Generation Failed",
description: error.message || "Please try again in a moment.",
variant: "destructive"
})
} finally {
setIsGenerating(false)
}
}, [sessionId, options, toast])
// ===========================================
// TOOL-SPECIFIC OPTION HANDLERS
// ===========================================
const updateOption = useCallback((key: keyof ToolOptions, value: any) => {
setOptions(prev => ({ ...prev, [key]: value }))
}, [])
// ===========================================
// RENDER: HERO SECTION (UNIVERSAL)
// ===========================================
const renderHeroSection = () => (
<div className="text-center mb-12">
<div className="flex items-center justify-center gap-2 mb-4">
<Sparkles className="h-8 w-8 text-primary" />
<h1 className="text-4xl md:text-5xl font-bold bg-gradient-to-r from-primary via-purple-500 to-pink-500 bg-clip-text text-transparent">
{toolConfig.name}
</h1>
</div>
<p className="text-xl text-muted-foreground max-w-3xl mx-auto mb-8">
{toolConfig.description}
</p>
<div className="flex items-center justify-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<BookOpen className="h-4 w-4" />
Free to use
</span>
<span className="flex items-center gap-1">
<Share2 className="h-4 w-4" />
Easy sharing
</span>
<span className="flex items-center gap-1">
<Download className="h-4 w-4" />
Multiple formats
</span>
</div>
</div>
)
// ===========================================
// RENDER: OPTIONS FORM (TOOL-SPECIFIC)
// ===========================================
const renderOptionsForm = () => (
<Card className="p-6 mb-8">
<h2 className="text-xl font-semibold mb-4">Customize Your {toolConfig.outputName}</h2>
<div className="grid gap-4">
{/* Tool-specific form fields */}
{toolConfig.fields.map((field) => (
<div key={field.key} className="space-y-2">
<label className="text-sm font-medium">{field.label}</label>
{field.type === 'select' && (
<Select
value={options[field.key] || ''}
onValueChange={(value) => updateOption(field.key, value)}
>
<SelectTrigger size="sm">
<SelectValue placeholder={field.placeholder} />
</SelectTrigger>
<SelectContent>
{field.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{field.type === 'input' && (
<Input
size="sm"
placeholder={field.placeholder}
value={options[field.key] || ''}
onChange={(e) => updateOption(field.key, e.target.value)}
/>
)}
{field.type === 'textarea' && (
<Textarea
placeholder={field.placeholder}
value={options[field.key] || ''}
onChange={(e) => updateOption(field.key, e.target.value)}
rows={3}
/>
)}
{field.description && (
<p className="text-xs text-muted-foreground">{field.description}</p>
)}
</div>
))}
</div>
</Card>
)
// ===========================================
// RENDER: GENERATION BUTTON (UNIVERSAL)
// ===========================================
const renderGenerateButton = () => (
<div className="text-center mb-8">
<Button
size="lg"
onClick={handleGenerate}
disabled={isGenerating || !isFormValid()}
className="px-8 py-3 text-lg font-semibold"
>
{isGenerating ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Generating...
</>
) : (
<>
<Sparkles className="mr-2 h-5 w-5" />
Generate {toolConfig.outputName}
</>
)}
</Button>
{generationCount > 0 && (
<p className="text-sm text-muted-foreground mt-2">
You've generated {generationCount} {toolConfig.outputName.toLowerCase()}{generationCount !== 1 ? 's' : ''} today
</p>
)}
</div>
)
// ===========================================
// RENDER: RESULTS SECTION (UNIVERSAL PATTERN)
// ===========================================
const renderResults = () => {
if (!result) return null
return (
<div className="space-y-6">
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold">{result.title}</h2>
<div className="flex items-center gap-2">
<ShareButton result={result} />
<ExportButton result={result} />
</div>
</div>
{/* Tool-specific content display */}
<ToolContentDisplay content={result.content} />
{/* Analysis panel if available */}
{result.analysis && (
<ToolAnalysisPanel analysis={result.analysis} />
)}
{/* AI metadata */}
<div className="mt-6 p-4 bg-muted rounded-lg">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>Generated with {result.aiMetadata.model}</span>
<span>{result.aiMetadata.processingTime}ms • {result.aiMetadata.tokensUsed} tokens</span>
</div>
</div>
</Card>
{/* Writer component for user responses */}
<ToolWriter
parentId={result.id}
sessionId={sessionId}
onResponseCreated={handleResponseCreated}
/>
</div>
)
}
// ===========================================
// RENDER: CONVERSION CTA (UNIVERSAL)
// ===========================================
const renderConversionCTA = () => {
if (!showConversionCTA) return null
return (
<Card className="p-6 border-primary bg-primary/5">
<div className="text-center">
<h3 className="text-xl font-semibold mb-2">Love using our {toolConfig.name}?</h3>
<p className="text-muted-foreground mb-4">
Join MyStoryFlow for unlimited generations, saved content, and premium features.
</p>
<div className="flex items-center justify-center gap-3">
<Button asChild>
<a href="https://mystoryflow.com/signup?source=tools_{tool}">
Get Started Free
</a>
</Button>
<Button variant="outline" onClick={() => setShowConversionCTA(false)}>
Maybe Later
</Button>
</div>
</div>
</Card>
)
}
// ===========================================
// MAIN RENDER
// ===========================================
return (
<div className="max-w-4xl mx-auto">
{renderHeroSection()}
{renderOptionsForm()}
{renderGenerateButton()}
{renderResults()}
{renderConversionCTA()}
<FeaturedContent toolName="{tool}" />
</div>
)
}
// ===========================================
// TOOL-SPECIFIC CONFIGURATION
// ===========================================
const toolConfig = {
name: "Tool Name",
description: "Tool description for SEO and user understanding",
outputName: "Generated Content",
fields: [
{
key: "fieldName",
label: "Field Label",
type: "select" | "input" | "textarea",
placeholder: "Placeholder text",
description: "Optional field description",
options: [{ value: "value", label: "Label" }] // For select fields
}
]
}
// ===========================================
// VALIDATION HELPER
// ===========================================
function isFormValid(): boolean {
// Tool-specific validation logic
return true
}🎯 Browse Component Pattern
{Tool}Browse.tsx - Discovery Interface
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@mystoryflow/ui/button'
import { Card } from '@mystoryflow/ui/card'
import { Input } from '@mystoryflow/ui/input'
import { Badge } from '@mystoryflow/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@mystoryflow/ui/select'
import { Search, Filter, Sparkles, Eye, Share2, MessageSquare } from 'lucide-react'
interface BrowseFilters {
search: string
sort: 'newest' | 'popular' | 'featured' | 'rating'
public: boolean
featured: boolean
page: number
}
interface BrowseResult {
id: string
shareCode: string
title: string
content: any
seoTitle: string
seoDescription: string
keywords: string[]
viewCount: number
shareCount: number
responseCount: number
averageRating: number
createdAt: string
}
export function ToolBrowse() {
// ===========================================
// STATE MANAGEMENT
// ===========================================
const [results, setResults] = useState<BrowseResult[]>([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [filters, setFilters] = useState<BrowseFilters>({
search: '',
sort: 'newest',
public: true,
featured: false,
page: 1
})
// ===========================================
// DATA FETCHING
// ===========================================
const fetchResults = useCallback(async (resetPage = false) => {
setLoading(true)
try {
const currentPage = resetPage ? 1 : filters.page
const searchParams = new URLSearchParams({
page: currentPage.toString(),
limit: '20',
sort: filters.sort,
public: filters.public.toString(),
featured: filters.featured.toString(),
...(filters.search && { search: filters.search })
})
const response = await fetch(`/api/{tool}?${searchParams}`)
const data = await response.json()
if (data.success) {
if (resetPage) {
setResults(data.data)
} else {
setResults(prev => [...prev, ...data.data])
}
setHasMore(data.meta.pagination.hasNext)
}
} catch (error) {
console.error('Browse fetch error:', error)
} finally {
setLoading(false)
}
}, [filters])
// ===========================================
// FILTER HANDLERS
// ===========================================
const updateFilter = useCallback((key: keyof BrowseFilters, value: any) => {
setFilters(prev => ({ ...prev, [key]: value, page: 1 }))
}, [])
// Debounced search
useEffect(() => {
const debounceTimer = setTimeout(() => {
fetchResults(true)
}, 300)
return () => clearTimeout(debounceTimer)
}, [filters.search, filters.sort, filters.public, filters.featured])
// ===========================================
// RENDER: FILTER BAR
// ===========================================
const renderFilterBar = () => (
<div className="flex flex-col md:flex-row gap-4 mb-6">
<div className="flex-1 relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
size="sm"
placeholder="Search content..."
value={filters.search}
onChange={(e) => updateFilter('search', e.target.value)}
className="pl-10"
/>
</div>
<Select value={filters.sort} onValueChange={(value) => updateFilter('sort', value)}>
<SelectTrigger size="sm" className="w-full md:w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="newest">Newest First</SelectItem>
<SelectItem value="popular">Most Popular</SelectItem>
<SelectItem value="featured">Featured</SelectItem>
<SelectItem value="rating">Highest Rated</SelectItem>
</SelectContent>
</Select>
<div className="flex gap-2">
<Button
variant={filters.featured ? "default" : "outline"}
size="sm"
onClick={() => updateFilter('featured', !filters.featured)}
className="flex items-center gap-1"
>
<Sparkles className="h-4 w-4" />
Featured
</Button>
<Button
variant="outline"
size="sm"
className="flex items-center gap-1"
>
<Filter className="h-4 w-4" />
More Filters
</Button>
</div>
</div>
)
// ===========================================
// RENDER: CONTENT GRID
// ===========================================
const renderContentGrid = () => (
<div className="grid gap-4 md:gap-6">
{results.map((item) => (
<ToolCard
key={item.id}
item={item}
onView={handleItemView}
onShare={handleItemShare}
/>
))}
{hasMore && (
<div className="text-center">
<Button
variant="outline"
onClick={() => fetchResults()}
disabled={loading}
>
{loading ? "Loading..." : "Load More"}
</Button>
</div>
)}
</div>
)
return (
<div className="max-w-6xl mx-auto">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold mb-2">Browse {toolConfig.name}</h1>
<p className="text-muted-foreground">
Discover and get inspired by content created by our community
</p>
</div>
{renderFilterBar()}
{renderContentGrid()}
</div>
)
}✍️ Writer Component Pattern
{Tool}Writer.tsx - Response Creation
'use client'
import { useState, useEffect, useCallback } 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 } from 'lucide-react'
interface WriterProps {
parentId: string
sessionId: string
onResponseCreated?: (response: any) => void
}
interface WriterState {
title: string
content: string
isPublic: boolean
lastSaved: Date | null
wordCount: number
isSaving: boolean
autoSaveEnabled: boolean
}
export function ToolWriter({ parentId, sessionId, onResponseCreated }: WriterProps) {
// ===========================================
// STATE MANAGEMENT
// ===========================================
const [state, setState] = useState<WriterState>({
title: '',
content: '',
isPublic: false,
lastSaved: null,
wordCount: 0,
isSaving: false,
autoSaveEnabled: true
})
const { toast } = useToast()
// ===========================================
// AUTO-SAVE FUNCTIONALITY
// ===========================================
const saveContent = useCallback(async (showToast = false) => {
if (!state.title.trim() || !state.content.trim()) return
setState(prev => ({ ...prev, isSaving: true }))
try {
const response = await fetch(`/api/{tool}/${parentId}/responses`, {
method: 'POST',
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) {
setState(prev => ({ ...prev, lastSaved: new Date() }))
if (showToast) {
toast({
title: "Response Saved!",
description: "Your response has been saved successfully."
})
}
onResponseCreated?.(data.data)
} else {
throw new Error(data.error?.message || 'Failed to save')
}
} catch (error) {
console.error('Save error:', error)
if (showToast) {
toast({
title: "Save Failed",
description: error.message,
variant: "destructive"
})
}
} finally {
setState(prev => ({ ...prev, isSaving: false }))
}
}, [state.title, state.content, state.isPublic, parentId, sessionId, toast, onResponseCreated])
// Auto-save every 30 seconds
useEffect(() => {
if (!state.autoSaveEnabled || !state.title.trim() || !state.content.trim()) return
const autoSaveTimer = setTimeout(() => {
saveContent(false)
}, 30000)
return () => clearTimeout(autoSaveTimer)
}, [state.title, state.content, state.autoSaveEnabled, saveContent])
// Keyboard shortcut for manual save (Cmd/Ctrl + S)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault()
saveContent(true)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [saveContent])
// Update word count
useEffect(() => {
const words = state.content.trim().split(/\s+/).filter(word => word.length > 0)
setState(prev => ({ ...prev, wordCount: words.length }))
}, [state.content])
// ===========================================
// EVENT HANDLERS
// ===========================================
const updateField = useCallback((field: keyof WriterState, value: any) => {
setState(prev => ({ ...prev, [field]: value }))
}, [])
const handleManualSave = useCallback(() => {
saveContent(true)
}, [saveContent])
const handlePublicToggle = useCallback(() => {
setState(prev => ({ ...prev, isPublic: !prev.isPublic }))
}, [])
// ===========================================
// RENDER: WRITER INTERFACE
// ===========================================
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Write Your Response</h3>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{state.lastSaved && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
Saved {formatTimeAgo(state.lastSaved)}
</span>
)}
{state.isSaving && (
<Badge variant="secondary">Saving...</Badge>
)}
</div>
</div>
<div className="space-y-4">
{/* Title Input */}
<div>
<Input
size="sm"
placeholder="Give your response a title..."
value={state.title}
onChange={(e) => updateField('title', e.target.value)}
className="text-lg font-medium"
/>
</div>
{/* Content Textarea */}
<div>
<Textarea
placeholder="Share your thoughts, story, or response here..."
value={state.content}
onChange={(e) => updateField('content', e.target.value)}
rows={12}
className="resize-none"
/>
<div className="flex items-center justify-between mt-2 text-sm text-muted-foreground">
<span>{state.wordCount} words</span>
<span>Tip: Press Cmd/Ctrl + S to save manually</span>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center justify-between pt-4 border-t">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handlePublicToggle}
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>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleManualSave}
disabled={state.isSaving || !state.title.trim() || !state.content.trim()}
className="flex items-center gap-1"
>
<Save className="h-4 w-4" />
Save
</Button>
{state.lastSaved && (
<>
<Button
variant="outline"
size="sm"
className="flex items-center gap-1"
>
<Share2 className="h-4 w-4" />
Share
</Button>
<Button
variant="outline"
size="sm"
className="flex items-center gap-1"
>
<Download className="h-4 w-4" />
Export
</Button>
</>
)}
</div>
</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`
}🎴 Card Component Pattern
{Tool}Card.tsx - Content Display
import { Card } from '@mystoryflow/ui/card'
import { Button } from '@mystoryflow/ui/button'
import { Badge } from '@mystoryflow/ui/badge'
import { Eye, Share2, MessageSquare, Star, Calendar, Sparkles } from 'lucide-react'
interface ToolCardProps {
item: BrowseResult
onView: (item: BrowseResult) => void
onShare: (item: BrowseResult) => void
compact?: boolean
}
export function ToolCard({ item, onView, onShare, compact = false }: ToolCardProps) {
// ===========================================
// RENDER: COMPACT CARD
// ===========================================
if (compact) {
return (
<Card className="p-4 hover:shadow-md transition-shadow cursor-pointer" onClick={() => onView(item)}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="font-medium truncate">{item.title}</h3>
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
{item.seoDescription}
</p>
</div>
{item.averageRating > 0 && (
<div className="flex items-center gap-1 ml-3">
<Star className="h-3 w-3 text-yellow-500 fill-yellow-500" />
<span className="text-xs">{item.averageRating.toFixed(1)}</span>
</div>
)}
</div>
<div className="flex items-center justify-between mt-3 text-xs text-muted-foreground">
<span>{formatDate(item.createdAt)}</span>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<Eye className="h-3 w-3" />
{item.viewCount}
</span>
<span className="flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{item.responseCount}
</span>
</div>
</div>
</Card>
)
}
// ===========================================
// RENDER: FULL CARD
// ===========================================
return (
<Card className="p-6 hover:shadow-lg transition-all duration-200">
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
{item.featured && (
<Badge variant="secondary" className="flex items-center gap-1">
<Sparkles className="h-3 w-3" />
Featured
</Badge>
)}
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatDate(item.createdAt)}
</span>
</div>
<h3 className="text-xl font-semibold mb-2 line-clamp-2">{item.title}</h3>
<p className="text-muted-foreground line-clamp-3">{item.seoDescription}</p>
</div>
{item.averageRating > 0 && (
<div className="flex items-center gap-1 ml-4">
<Star className="h-4 w-4 text-yellow-500 fill-yellow-500" />
<span className="font-medium">{item.averageRating.toFixed(1)}</span>
</div>
)}
</div>
{/* Keywords */}
{item.keywords.length > 0 && (
<div className="flex flex-wrap gap-1 mb-4">
{item.keywords.slice(0, 5).map((keyword) => (
<Badge key={keyword} variant="outline" className="text-xs">
{keyword}
</Badge>
))}
{item.keywords.length > 5 && (
<Badge variant="outline" className="text-xs">
+{item.keywords.length - 5} more
</Badge>
)}
</div>
)}
{/* Tool-specific content preview */}
<div className="mb-4 p-3 bg-muted rounded-lg">
<ToolContentPreview content={item.content} />
</div>
{/* Footer */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Eye className="h-4 w-4" />
{formatNumber(item.viewCount)} views
</span>
<span className="flex items-center gap-1">
<Share2 className="h-4 w-4" />
{formatNumber(item.shareCount)} shares
</span>
<span className="flex items-center gap-1">
<MessageSquare className="h-4 w-4" />
{formatNumber(item.responseCount)} responses
</span>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={(e) => {
e.stopPropagation()
onShare(item)
}}>
<Share2 className="h-4 w-4 mr-1" />
Share
</Button>
<Button size="sm" onClick={() => onView(item)}>
View Full
</Button>
</div>
</div>
</Card>
)
}
// ===========================================
// HELPER FUNCTIONS
// ===========================================
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
}
function formatNumber(num: number): string {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`
return num.toString()
}🌐 Public View Component Pattern
{Tool}PublicView.tsx - SEO-Optimized Sharing
import { Metadata } from 'next'
import { Card } from '@mystoryflow/ui/card'
import { Button } from '@mystoryflow/ui/button'
import { Badge } from '@mystoryflow/ui/badge'
import { Eye, Share2, Calendar, Sparkles } from 'lucide-react'
interface PublicViewProps {
shareCode: string
content: BrowseResult
responses: any[]
}
// ===========================================
// SEO METADATA GENERATION
// ===========================================
export async function generateMetadata({
params
}: {
params: Promise<{ shareCode: string }>
}): Promise<Metadata> {
const { shareCode } = await params
// Fetch content for metadata
const content = await fetchSharedContent(shareCode)
if (!content) {
return {
title: 'Content Not Found | MyStoryFlow Tools',
description: 'The shared content you are looking for could not be found.'
}
}
const toolName = toolConfig.name
const baseUrl = process.env.NEXT_PUBLIC_TOOLS_APP_URL || 'https://tools.mystoryflow.com'
return {
title: content.seoTitle || `${content.title} | ${toolName}`,
description: content.seoDescription || `Check out this ${toolName.toLowerCase()} created with MyStoryFlow's AI-powered tools.`,
keywords: content.keywords.join(', '),
openGraph: {
title: content.seoTitle || content.title,
description: content.seoDescription || `AI-generated ${toolName.toLowerCase()} from MyStoryFlow`,
type: 'article',
url: `${baseUrl}/{tool}/share/${shareCode}`,
images: [
{
url: `${baseUrl}/api/og/{tool}/${shareCode}`,
width: 1200,
height: 630,
alt: content.title
}
]
},
twitter: {
card: 'summary_large_image',
title: content.seoTitle || content.title,
description: content.seoDescription,
images: [`${baseUrl}/api/og/{tool}/${shareCode}`]
},
alternates: {
canonical: `${baseUrl}/{tool}/share/${shareCode}`
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1
}
}
}
}
// ===========================================
// PUBLIC VIEW COMPONENT
// ===========================================
export function ToolPublicView({ shareCode, content, responses }: PublicViewProps) {
return (
<div className="min-h-screen bg-background">
{/* Structured Data for SEO */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'CreativeWork',
name: content.title,
description: content.seoDescription,
author: {
'@type': 'Organization',
name: 'MyStoryFlow'
},
publisher: {
'@type': 'Organization',
name: 'MyStoryFlow',
url: 'https://mystoryflow.com'
},
url: `https://tools.mystoryflow.com/{tool}/share/${shareCode}`,
dateCreated: content.createdAt,
genre: toolConfig.category,
keywords: content.keywords.join(', ')
})
}}
/>
<div className="container mx-auto px-4 py-8 max-w-4xl">
{/* Header */}
<div className="text-center mb-8">
<div className="flex items-center justify-center gap-2 mb-4">
<Sparkles className="h-6 w-6 text-primary" />
<h1 className="text-3xl md:text-4xl font-bold">{content.title}</h1>
</div>
<div className="flex items-center justify-center gap-4 text-sm text-muted-foreground mb-6">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{formatDate(content.createdAt)}
</span>
<span className="flex items-center gap-1">
<Eye className="h-4 w-4" />
{formatNumber(content.viewCount)} views
</span>
<span className="flex items-center gap-1">
<Share2 className="h-4 w-4" />
{formatNumber(content.shareCount)} shares
</span>
</div>
{content.keywords.length > 0 && (
<div className="flex flex-wrap justify-center gap-2 mb-6">
{content.keywords.map((keyword) => (
<Badge key={keyword} variant="outline">
{keyword}
</Badge>
))}
</div>
)}
</div>
{/* Main Content */}
<Card className="p-6 md:p-8 mb-8">
<ToolContentDisplay content={content.content} isPublicView={true} />
</Card>
{/* Community Responses */}
{responses.length > 0 && (
<div className="mb-8">
<h2 className="text-2xl font-bold mb-6">Community Responses</h2>
<div className="space-y-4">
{responses.map((response) => (
<ResponseCard key={response.id} response={response} />
))}
</div>
</div>
)}
{/* Call to Action */}
<Card className="p-6 text-center bg-primary/5 border-primary">
<h3 className="text-xl font-semibold mb-2">Create Your Own {toolConfig.name}</h3>
<p className="text-muted-foreground mb-4">
Use our AI-powered {toolConfig.name.toLowerCase()} to generate your own creative content.
</p>
<div className="flex items-center justify-center gap-3">
<Button asChild>
<a href={`/{tool}`}>
Try {toolConfig.name}
</a>
</Button>
<Button variant="outline" asChild>
<a href="https://mystoryflow.com">
Explore MyStoryFlow
</a>
</Button>
</div>
</Card>
</div>
</div>
)
}This comprehensive component architecture ensures every MyStoryFlow tool maintains consistency, performance, and user experience excellence while allowing for tool-specific customization where needed. The patterns are designed for maximum code reuse and minimal maintenance overhead.