4 changed files with 258 additions and 56 deletions
@ -0,0 +1,245 @@
@@ -0,0 +1,245 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' |
||||
import { Input } from '@/components/ui/input' |
||||
import { Label } from '@/components/ui/label' |
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' |
||||
import { Textarea } from '@/components/ui/textarea' |
||||
import { Badge } from '@/components/ui/badge' |
||||
import { Hash, X, Users, Code, DollarSign, Newspaper, BookOpen, Scroll, Cpu, Trophy, Film, Heart, TrendingUp, Utensils, MapPin, Home, PawPrint, Shirt } from 'lucide-react' |
||||
import { useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { TDraftEvent } from '@/types' |
||||
import { cn } from '@/lib/utils' |
||||
import dayjs from 'dayjs' |
||||
|
||||
interface CreateThreadDialogProps { |
||||
topic: string |
||||
availableRelays: string[] |
||||
onClose: () => void |
||||
onThreadCreated: () => void |
||||
} |
||||
|
||||
export const DISCUSSION_TOPICS = [ |
||||
{ id: 'general', label: 'General', icon: Hash, color: 'bg-gray-100 text-gray-800' }, |
||||
{ id: 'meetups', label: 'Meetups', icon: Users, color: 'bg-blue-100 text-blue-800' }, |
||||
{ id: 'devs', label: 'Developers', icon: Code, color: 'bg-green-100 text-green-800' }, |
||||
{ id: 'finance', label: 'Bitcoin, Finance & Economics', icon: DollarSign, color: 'bg-yellow-100 text-yellow-800' }, |
||||
{ id: 'politics', label: 'Politics & Breaking News', icon: Newspaper, color: 'bg-red-100 text-red-800' }, |
||||
{ id: 'literature', label: 'Literature & Art', icon: BookOpen, color: 'bg-purple-100 text-purple-800' }, |
||||
{ id: 'philosophy', label: 'Philosophy & Theology', icon: Scroll, color: 'bg-indigo-100 text-indigo-800' }, |
||||
{ id: 'tech', label: 'Technology & Science', icon: Cpu, color: 'bg-cyan-100 text-cyan-800' }, |
||||
{ id: 'sports', label: 'Sports and Gaming', icon: Trophy, color: 'bg-orange-100 text-orange-800' }, |
||||
{ id: 'entertainment', label: 'Entertainment & Pop Culture', icon: Film, color: 'bg-pink-100 text-pink-800' }, |
||||
{ id: 'health', label: 'Health & Wellness', icon: Heart, color: 'bg-red-100 text-red-800' }, |
||||
{ id: 'lifestyle', label: 'Lifestyle & Personal Development', icon: TrendingUp, color: 'bg-emerald-100 text-emerald-800' }, |
||||
{ id: 'food', label: 'Food & Cooking', icon: Utensils, color: 'bg-amber-100 text-amber-800' }, |
||||
{ id: 'travel', label: 'Travel & Adventure', icon: MapPin, color: 'bg-teal-100 text-teal-800' }, |
||||
{ id: 'home', label: 'Home & Garden', icon: Home, color: 'bg-lime-100 text-lime-800' }, |
||||
{ id: 'pets', label: 'Pets & Animals', icon: PawPrint, color: 'bg-rose-100 text-rose-800' }, |
||||
{ id: 'fashion', label: 'Fashion & Beauty', icon: Shirt, color: 'bg-violet-100 text-violet-800' } |
||||
] |
||||
|
||||
export default function CreateThreadDialog({
|
||||
topic: initialTopic,
|
||||
availableRelays,
|
||||
onClose,
|
||||
onThreadCreated
|
||||
}: CreateThreadDialogProps) { |
||||
const { t } = useTranslation() |
||||
const { pubkey, publish } = useNostr() |
||||
const [title, setTitle] = useState('') |
||||
const [content, setContent] = useState('') |
||||
const [selectedTopic] = useState(initialTopic) |
||||
const [selectedRelay, setSelectedRelay] = useState<string>('') |
||||
const [isSubmitting, setIsSubmitting] = useState(false) |
||||
const [errors, setErrors] = useState<{ title?: string; content?: string; relay?: string }>({}) |
||||
|
||||
const validateForm = () => { |
||||
const newErrors: { title?: string; content?: string; relay?: string } = {} |
||||
|
||||
if (!title.trim()) { |
||||
newErrors.title = t('Title is required') |
||||
} else if (title.length > 100) { |
||||
newErrors.title = t('Title must be 100 characters or less') |
||||
} |
||||
|
||||
if (!content.trim()) { |
||||
newErrors.content = t('Content is required') |
||||
} else if (content.length > 5000) { |
||||
newErrors.content = t('Content must be 5000 characters or less') |
||||
} |
||||
|
||||
if (!selectedRelay) { |
||||
newErrors.relay = t('Please select a relay') |
||||
} |
||||
|
||||
setErrors(newErrors) |
||||
return Object.keys(newErrors).length === 0 |
||||
} |
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => { |
||||
e.preventDefault() |
||||
|
||||
if (!pubkey) { |
||||
alert(t('You must be logged in to create a thread')) |
||||
return |
||||
} |
||||
|
||||
if (!validateForm()) { |
||||
return |
||||
} |
||||
|
||||
setIsSubmitting(true) |
||||
|
||||
try { |
||||
// Create the thread event (kind 11)
|
||||
const threadEvent: TDraftEvent = { |
||||
kind: 11, |
||||
content: content.trim(), |
||||
tags: [ |
||||
['title', title.trim()], |
||||
['t', selectedTopic], |
||||
['-'] // Required tag for relay privacy
|
||||
], |
||||
created_at: dayjs().unix() |
||||
} |
||||
|
||||
// Publish to the selected relay only
|
||||
const publishedEvent = await publish(threadEvent, { |
||||
specifiedRelayUrls: [selectedRelay] |
||||
}) |
||||
|
||||
if (publishedEvent) { |
||||
onThreadCreated() |
||||
onClose() |
||||
} else { |
||||
throw new Error(t('Failed to publish thread')) |
||||
} |
||||
} catch (error) { |
||||
console.error('Error creating thread:', error) |
||||
alert(t('Failed to create thread. Please try again.')) |
||||
} finally { |
||||
setIsSubmitting(false) |
||||
} |
||||
} |
||||
|
||||
const selectedTopicInfo = DISCUSSION_TOPICS.find(t => t.id === selectedTopic) || DISCUSSION_TOPICS[0] |
||||
|
||||
return ( |
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> |
||||
<Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto"> |
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4"> |
||||
<CardTitle className="text-xl font-semibold">{t('Create New Thread')}</CardTitle> |
||||
<Button |
||||
variant="ghost" |
||||
size="icon" |
||||
onClick={onClose} |
||||
className="h-8 w-8" |
||||
> |
||||
<X className="h-4 w-4" /> |
||||
</Button> |
||||
</CardHeader> |
||||
|
||||
<CardContent> |
||||
<form onSubmit={handleSubmit} className="space-y-6"> |
||||
{/* Topic Selection */} |
||||
<div className="space-y-2"> |
||||
<Label htmlFor="topic">{t('Topic')}</Label> |
||||
<div className="flex items-center gap-2"> |
||||
<selectedTopicInfo.icon className="w-4 h-4" /> |
||||
<Badge variant="secondary" className={cn('text-sm', selectedTopicInfo.color)}> |
||||
{selectedTopicInfo.label} |
||||
</Badge> |
||||
</div> |
||||
<p className="text-sm text-muted-foreground"> |
||||
{t('Threads are organized by topics. You can change this after creation.')} |
||||
</p> |
||||
</div> |
||||
|
||||
{/* Title Input */} |
||||
<div className="space-y-2"> |
||||
<Label htmlFor="title">{t('Thread Title')}</Label> |
||||
<Input |
||||
id="title" |
||||
value={title} |
||||
onChange={(e) => setTitle(e.target.value)} |
||||
placeholder={t('Enter a descriptive title for your thread')} |
||||
maxLength={100} |
||||
className={errors.title ? 'border-destructive' : ''} |
||||
/> |
||||
{errors.title && ( |
||||
<p className="text-sm text-destructive">{errors.title}</p> |
||||
)} |
||||
<p className="text-sm text-muted-foreground"> |
||||
{title.length}/100 {t('characters')} |
||||
</p> |
||||
</div> |
||||
|
||||
{/* Content Input */} |
||||
<div className="space-y-2"> |
||||
<Label htmlFor="content">{t('Thread Content')}</Label> |
||||
<Textarea |
||||
id="content" |
||||
value={content} |
||||
onChange={(e) => setContent(e.target.value)} |
||||
placeholder={t('Share your thoughts, ask questions, or start a discussion...')} |
||||
rows={8} |
||||
maxLength={5000} |
||||
className={errors.content ? 'border-destructive' : ''} |
||||
/> |
||||
{errors.content && ( |
||||
<p className="text-sm text-destructive">{errors.content}</p> |
||||
)} |
||||
<p className="text-sm text-muted-foreground"> |
||||
{content.length}/5000 {t('characters')} |
||||
</p> |
||||
</div> |
||||
|
||||
{/* Relay Selection */} |
||||
<div className="space-y-2"> |
||||
<Label htmlFor="relay">{t('Publish to Relay')}</Label> |
||||
<Select value={selectedRelay} onValueChange={setSelectedRelay}> |
||||
<SelectTrigger className={errors.relay ? 'border-destructive' : ''}> |
||||
<SelectValue placeholder={t('Select a relay to publish to')} /> |
||||
</SelectTrigger> |
||||
<SelectContent> |
||||
{availableRelays.map(relay => ( |
||||
<SelectItem key={relay} value={relay}> |
||||
{relay.replace('wss://', '').replace('ws://', '')} |
||||
</SelectItem> |
||||
))} |
||||
</SelectContent> |
||||
</Select> |
||||
{errors.relay && ( |
||||
<p className="text-sm text-destructive">{errors.relay}</p> |
||||
)} |
||||
<p className="text-sm text-muted-foreground"> |
||||
{t('Choose the relay where this discussion will be hosted.')} |
||||
</p> |
||||
</div> |
||||
|
||||
{/* Form Actions */} |
||||
<div className="flex gap-3 pt-4"> |
||||
<Button |
||||
type="button" |
||||
variant="outline" |
||||
onClick={onClose} |
||||
className="flex-1" |
||||
> |
||||
{t('Cancel')} |
||||
</Button> |
||||
<Button |
||||
type="submit" |
||||
disabled={isSubmitting} |
||||
className="flex-1" |
||||
> |
||||
{isSubmitting ? t('Creating...') : t('Create Thread')} |
||||
</Button> |
||||
</div> |
||||
</form> |
||||
</CardContent> |
||||
</Card> |
||||
</div> |
||||
) |
||||
} |
||||
Loading…
Reference in new issue