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