| 1 | import React, { useState, useRef } from 'react'
|
|---|
| 2 | import { useNavigate } from 'react-router-dom'
|
|---|
| 3 | import { Feather, ArrowLeft, X } from 'lucide-react'
|
|---|
| 4 | import { useAuthStore } from '../../store/authStore'
|
|---|
| 5 | import { useStoryStore } from '../../store/storyStore'
|
|---|
| 6 | import { useUIStore } from '../../store/uiStore'
|
|---|
| 7 | import { Button } from '../../components/ui/Button'
|
|---|
| 8 | import { StoryCreationAIPanel, StoryCreationAIPanelRef } from '../../components/writer/StoryCreationAIPanel'
|
|---|
| 9 | import { Story } from '../../types'
|
|---|
| 10 |
|
|---|
| 11 | const ALL_GENRES = ['Fantasy', 'Sci-Fi', 'Romance', 'Historical Fiction', 'Adventure', 'Thriller', 'Mystery', 'Horror', 'Contemporary', 'Poetry']
|
|---|
| 12 |
|
|---|
| 13 | export const CreateStoryPage: React.FC = () => {
|
|---|
| 14 | const navigate = useNavigate()
|
|---|
| 15 | const { currentUser } = useAuthStore()
|
|---|
| 16 | const { addStory, addSuggestion } = useStoryStore()
|
|---|
| 17 | const { addToast } = useUIStore()
|
|---|
| 18 | const aiPanelRef = useRef<StoryCreationAIPanelRef>(null)
|
|---|
| 19 | const [form, setForm] = useState({
|
|---|
| 20 | title: '',
|
|---|
| 21 | short_description: '',
|
|---|
| 22 | content: '',
|
|---|
| 23 | genres: [] as string[],
|
|---|
| 24 | mature_content: false,
|
|---|
| 25 | })
|
|---|
| 26 | const [errors, setErrors] = useState<Record<string, string>>({})
|
|---|
| 27 | const [loading, setLoading] = useState(false)
|
|---|
| 28 |
|
|---|
| 29 | const validate = () => {
|
|---|
| 30 | const e: Record<string, string> = {}
|
|---|
| 31 | if (!form.title.trim()) e.title = 'Title is required'
|
|---|
| 32 | if (!form.short_description.trim()) e.short_description = 'Description is required'
|
|---|
| 33 | if (form.short_description.length > 280) e.short_description = 'Max 280 characters'
|
|---|
| 34 | if (!form.content.trim()) e.content = 'Story content is required'
|
|---|
| 35 | if (form.genres.length === 0) e.genres = 'Select at least one genre'
|
|---|
| 36 | setErrors(e)
|
|---|
| 37 | return Object.keys(e).length === 0
|
|---|
| 38 | }
|
|---|
| 39 |
|
|---|
| 40 | const handleSubmit = async (status: 'draft' | 'published') => {
|
|---|
| 41 | if (!validate() || !currentUser) return
|
|---|
| 42 | setLoading(true)
|
|---|
| 43 | await new Promise(r => setTimeout(r, 500))
|
|---|
| 44 | const story: Story = {
|
|---|
| 45 | story_id: Date.now(),
|
|---|
| 46 | ...form,
|
|---|
| 47 | user_id: currentUser.user_id,
|
|---|
| 48 | author_username: currentUser.username,
|
|---|
| 49 | status,
|
|---|
| 50 | created_at: new Date().toISOString(),
|
|---|
| 51 | updated_at: new Date().toISOString(),
|
|---|
| 52 | total_likes: 0,
|
|---|
| 53 | total_comments: 0,
|
|---|
| 54 | total_chapters: 0,
|
|---|
| 55 | total_views: 0,
|
|---|
| 56 | }
|
|---|
| 57 | let realId: number
|
|---|
| 58 | try {
|
|---|
| 59 | realId = await addStory(story)
|
|---|
| 60 | } catch (err: any) {
|
|---|
| 61 | const msg = err?.response?.data?.message || err?.message || 'Failed to save story.'
|
|---|
| 62 | addToast(msg, 'error')
|
|---|
| 63 | setLoading(false)
|
|---|
| 64 | return
|
|---|
| 65 | }
|
|---|
| 66 |
|
|---|
| 67 | addToast(status === 'published' ? 'Story published!' : 'Draft saved!')
|
|---|
| 68 | navigate(`/writer/edit-story/${realId}`)
|
|---|
| 69 | setLoading(false)
|
|---|
| 70 | }
|
|---|
| 71 |
|
|---|
| 72 | const toggleGenre = (g: string) => {
|
|---|
| 73 | setForm(f => ({
|
|---|
| 74 | ...f,
|
|---|
| 75 | genres: f.genres.includes(g) ? f.genres.filter(x => x !== g) : [...f.genres, g],
|
|---|
| 76 | }))
|
|---|
| 77 | setErrors(e => ({ ...e, genres: '' }))
|
|---|
| 78 | }
|
|---|
| 79 |
|
|---|
| 80 | const setField = (field: string, value: string | boolean) => {
|
|---|
| 81 | setForm(f => ({ ...f, [field]: value }))
|
|---|
| 82 | setErrors(e => ({ ...e, [field]: '' }))
|
|---|
| 83 | }
|
|---|
| 84 |
|
|---|
| 85 | return (
|
|---|
| 86 | <div className="max-w-6xl mx-auto px-4 py-8">
|
|---|
| 87 | <div className="flex items-center gap-3 mb-8">
|
|---|
| 88 | <button onClick={() => navigate('/writer')} className="text-slate-400 hover:text-white transition-colors">
|
|---|
| 89 | <ArrowLeft size={20} />
|
|---|
| 90 | </button>
|
|---|
| 91 | <div>
|
|---|
| 92 | <h1 className="font-serif text-2xl font-bold text-white">Create New Story</h1>
|
|---|
| 93 | <p className="text-slate-400 text-sm mt-0.5">Tell your story to the world</p>
|
|---|
| 94 | </div>
|
|---|
| 95 | </div>
|
|---|
| 96 |
|
|---|
| 97 | <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|---|
| 98 | <div className="lg:col-span-2 space-y-6">
|
|---|
| 99 | {/* Title */}
|
|---|
| 100 | <div>
|
|---|
| 101 | <label className="block text-sm font-medium text-slate-300 mb-1.5">Story Title *</label>
|
|---|
| 102 | <input
|
|---|
| 103 | value={form.title}
|
|---|
| 104 | onChange={e => setField('title', e.target.value)}
|
|---|
| 105 | placeholder="The Chronicles of Eldoria..."
|
|---|
| 106 | className={`w-full px-4 py-3 bg-slate-800 border rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 text-lg font-serif ${errors.title ? 'border-rose-500' : 'border-slate-700'}`}
|
|---|
| 107 | />
|
|---|
| 108 | {errors.title && <p className="text-rose-400 text-xs mt-1">{errors.title}</p>}
|
|---|
| 109 | </div>
|
|---|
| 110 |
|
|---|
| 111 | {/* Short description */}
|
|---|
| 112 | <div>
|
|---|
| 113 | <label className="block text-sm font-medium text-slate-300 mb-1.5">Short Description * <span className="text-slate-500 font-normal">(shown on story cards)</span></label>
|
|---|
| 114 | <textarea
|
|---|
| 115 | value={form.short_description}
|
|---|
| 116 | onChange={e => setField('short_description', e.target.value)}
|
|---|
| 117 | placeholder="When the last dragon awakens..."
|
|---|
| 118 | rows={3}
|
|---|
| 119 | maxLength={280}
|
|---|
| 120 | className={`w-full px-4 py-3 bg-slate-800 border rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 resize-none ${errors.short_description ? 'border-rose-500' : 'border-slate-700'}`}
|
|---|
| 121 | />
|
|---|
| 122 | <div className="flex justify-between items-center mt-1">
|
|---|
| 123 | {errors.short_description ? <p className="text-rose-400 text-xs">{errors.short_description}</p> : <span />}
|
|---|
| 124 | <span className="text-slate-600 text-xs">{form.short_description.length}/280</span>
|
|---|
| 125 | </div>
|
|---|
| 126 | </div>
|
|---|
| 127 |
|
|---|
| 128 | {/* Content */}
|
|---|
| 129 | <div>
|
|---|
| 130 | <label className="block text-sm font-medium text-slate-300 mb-1.5">Story Content *</label>
|
|---|
| 131 | <textarea
|
|---|
| 132 | value={form.content}
|
|---|
| 133 | onChange={e => setField('content', e.target.value)}
|
|---|
| 134 | placeholder="Begin your story here. This is the introduction that readers will see..."
|
|---|
| 135 | rows={8}
|
|---|
| 136 | className={`w-full px-4 py-3 bg-slate-800 border rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 resize-y font-serif ${errors.content ? 'border-rose-500' : 'border-slate-700'}`}
|
|---|
| 137 | />
|
|---|
| 138 | {errors.content && <p className="text-rose-400 text-xs mt-1">{errors.content}</p>}
|
|---|
| 139 | </div>
|
|---|
| 140 |
|
|---|
| 141 | {/* Genres */}
|
|---|
| 142 | <div>
|
|---|
| 143 | <label className="block text-sm font-medium text-slate-300 mb-2">Genres * <span className="text-slate-500 font-normal">(select 1-3)</span></label>
|
|---|
| 144 | <div className="flex flex-wrap gap-2 mb-2">
|
|---|
| 145 | {ALL_GENRES.map(g => (
|
|---|
| 146 | <button
|
|---|
| 147 | key={g}
|
|---|
| 148 | type="button"
|
|---|
| 149 | onClick={() => toggleGenre(g)}
|
|---|
| 150 | className={`px-3 py-1.5 rounded-full text-sm border transition-all ${
|
|---|
| 151 | form.genres.includes(g)
|
|---|
| 152 | ? 'bg-indigo-500/30 text-indigo-300 border-indigo-500/50'
|
|---|
| 153 | : 'bg-slate-800 text-slate-400 border-slate-700 hover:border-slate-500'
|
|---|
| 154 | }`}
|
|---|
| 155 | >
|
|---|
| 156 | {form.genres.includes(g) && <span className="mr-1">✓</span>}
|
|---|
| 157 | {g}
|
|---|
| 158 | </button>
|
|---|
| 159 | ))}
|
|---|
| 160 | </div>
|
|---|
| 161 | {form.genres.length > 0 && (
|
|---|
| 162 | <div className="flex flex-wrap gap-1 mt-1">
|
|---|
| 163 | {form.genres.map(g => (
|
|---|
| 164 | <span key={g} className="flex items-center gap-1 text-xs px-2 py-0.5 bg-indigo-500/20 text-indigo-300 rounded-full">
|
|---|
| 165 | {g}
|
|---|
| 166 | <button onClick={() => toggleGenre(g)}><X size={10} /></button>
|
|---|
| 167 | </span>
|
|---|
| 168 | ))}
|
|---|
| 169 | </div>
|
|---|
| 170 | )}
|
|---|
| 171 | {errors.genres && <p className="text-rose-400 text-xs mt-1">{errors.genres}</p>}
|
|---|
| 172 | </div>
|
|---|
| 173 |
|
|---|
| 174 | {/* Mature content */}
|
|---|
| 175 | <div className="flex items-center gap-3">
|
|---|
| 176 | <button
|
|---|
| 177 | type="button"
|
|---|
| 178 | onClick={() => setField('mature_content', !form.mature_content)}
|
|---|
| 179 | className={`w-11 h-6 rounded-full transition-colors ${form.mature_content ? 'bg-rose-500' : 'bg-slate-600'} relative flex-shrink-0`}
|
|---|
| 180 | >
|
|---|
| 181 | <div className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${form.mature_content ? 'translate-x-6' : 'translate-x-1'}`} />
|
|---|
| 182 | </button>
|
|---|
| 183 | <div>
|
|---|
| 184 | <p className="text-white text-sm font-medium">Mature Content (18+)</p>
|
|---|
| 185 | <p className="text-slate-500 text-xs">Enable for stories with adult themes</p>
|
|---|
| 186 | </div>
|
|---|
| 187 | </div>
|
|---|
| 188 |
|
|---|
| 189 | {/* Actions */}
|
|---|
| 190 | <div className="flex gap-3 pt-4 border-t border-slate-700">
|
|---|
| 191 | <Button variant="secondary" className="flex-1" onClick={() => handleSubmit('draft')} loading={loading}>
|
|---|
| 192 | Save as Draft
|
|---|
| 193 | </Button>
|
|---|
| 194 | <Button className="flex-1" onClick={() => handleSubmit('published')} loading={loading}>
|
|---|
| 195 | <Feather size={16} />
|
|---|
| 196 | Publish Story
|
|---|
| 197 | </Button>
|
|---|
| 198 | </div>
|
|---|
| 199 | </div>
|
|---|
| 200 |
|
|---|
| 201 | {/* AI Suggestions Sidebar */}
|
|---|
| 202 | <div className="lg:col-span-1">
|
|---|
| 203 | <StoryCreationAIPanel
|
|---|
| 204 | ref={aiPanelRef}
|
|---|
| 205 | title={form.title}
|
|---|
| 206 | description={form.short_description}
|
|---|
| 207 | content={form.content}
|
|---|
| 208 | genres={form.genres}
|
|---|
| 209 | />
|
|---|
| 210 | </div>
|
|---|
| 211 | </div>
|
|---|
| 212 | </div>
|
|---|
| 213 | )
|
|---|
| 214 | }
|
|---|