9 changed files with 408 additions and 9 deletions
@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card' |
||||
import { Button } from '@/components/ui/button' |
||||
import { Badge } from '@/components/ui/badge' |
||||
import { MessageCircle, User, Clock, Hash } from 'lucide-react' |
||||
import { NostrEvent } from 'nostr-tools' |
||||
import { formatDistanceToNow } from 'date-fns' |
||||
import { useTranslation } from 'react-i18next' |
||||
import { cn } from '@/lib/utils' |
||||
import { truncateText } from '@/lib/utils' |
||||
|
||||
interface ThreadCardProps { |
||||
thread: NostrEvent |
||||
onThreadClick: () => void |
||||
className?: string |
||||
} |
||||
|
||||
export default function ThreadCard({ thread, onThreadClick, className }: ThreadCardProps) { |
||||
const { t } = useTranslation() |
||||
|
||||
// Extract title from tags
|
||||
const titleTag = thread.tags.find(tag => tag[0] === 'title' && tag[1]) |
||||
const title = titleTag?.[1] || t('Untitled') |
||||
|
||||
// Extract topic from tags
|
||||
const topicTag = thread.tags.find(tag => tag[0] === 't' && tag[1]) |
||||
const topic = topicTag?.[1] || 'general' |
||||
|
||||
// Get first 250 words of content
|
||||
const contentPreview = truncateText(thread.content, 250) |
||||
|
||||
// Format creation time
|
||||
const createdAt = new Date(thread.created_at * 1000) |
||||
const timeAgo = formatDistanceToNow(createdAt, { addSuffix: true }) |
||||
|
||||
// Get topic display info
|
||||
const getTopicInfo = (topicId: string) => { |
||||
const topicMap: Record<string, { label: string; color: string }> = { |
||||
general: { label: 'General', color: 'bg-gray-100 text-gray-800' }, |
||||
meetups: { label: 'Meetups', color: 'bg-blue-100 text-blue-800' }, |
||||
devs: { label: 'Developers', color: 'bg-green-100 text-green-800' }, |
||||
finance: { label: 'Finance', color: 'bg-yellow-100 text-yellow-800' }, |
||||
politics: { label: 'Politics', color: 'bg-red-100 text-red-800' }, |
||||
literature: { label: 'Literature', color: 'bg-purple-100 text-purple-800' }, |
||||
philosophy: { label: 'Philosophy', color: 'bg-indigo-100 text-indigo-800' }, |
||||
tech: { label: 'Technology', color: 'bg-cyan-100 text-cyan-800' }, |
||||
sports: { label: 'Sports', color: 'bg-orange-100 text-orange-800' } |
||||
} |
||||
return topicMap[topicId] || { label: topicId, color: 'bg-gray-100 text-gray-800' } |
||||
} |
||||
|
||||
const topicInfo = getTopicInfo(topic) |
||||
|
||||
return ( |
||||
<Card
|
||||
className={cn( |
||||
'clickable hover:shadow-md transition-shadow cursor-pointer', |
||||
className |
||||
)} |
||||
onClick={onThreadClick} |
||||
> |
||||
<CardHeader className="pb-3"> |
||||
<div className="flex items-start justify-between gap-2"> |
||||
<div className="flex-1 min-w-0"> |
||||
<h3 className="font-semibold text-lg leading-tight mb-2 line-clamp-2"> |
||||
{title} |
||||
</h3> |
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground"> |
||||
<Badge variant="secondary" className={cn('text-xs', topicInfo.color)}> |
||||
<Hash className="w-3 h-3 mr-1" /> |
||||
{topicInfo.label} |
||||
</Badge> |
||||
<div className="flex items-center gap-1"> |
||||
<Clock className="w-3 h-3" /> |
||||
{timeAgo} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground shrink-0"> |
||||
<MessageCircle className="w-4 h-4" /> |
||||
<span>0</span> {/* TODO: Add reply count */} |
||||
</div> |
||||
</div> |
||||
</CardHeader> |
||||
|
||||
<CardContent className="pt-0"> |
||||
<div className="text-sm text-muted-foreground leading-relaxed"> |
||||
{contentPreview} |
||||
</div> |
||||
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t"> |
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground"> |
||||
<User className="w-4 h-4" /> |
||||
<span className="truncate"> |
||||
{thread.pubkey.slice(0, 8)}...{thread.pubkey.slice(-8)} |
||||
</span> |
||||
</div> |
||||
<Button variant="ghost" size="sm" className="h-8 px-2"> |
||||
{t('Read more')} |
||||
</Button> |
||||
</div> |
||||
</CardContent> |
||||
</Card> |
||||
) |
||||
} |
||||
@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' |
||||
import { Hash, ChevronDown } from 'lucide-react' |
||||
|
||||
interface Topic { |
||||
id: string |
||||
label: string |
||||
icon: any |
||||
} |
||||
|
||||
interface TopicFilterProps { |
||||
topics: Topic[] |
||||
selectedTopic: string |
||||
onTopicChange: (topicId: string) => void |
||||
} |
||||
|
||||
export default function TopicFilter({ topics, selectedTopic, onTopicChange }: TopicFilterProps) { |
||||
const selectedTopicInfo = topics.find(topic => topic.id === selectedTopic) || topics[0] |
||||
|
||||
return ( |
||||
<DropdownMenu> |
||||
<DropdownMenuTrigger asChild> |
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 h-10 px-3" |
||||
> |
||||
<Hash className="w-4 h-4" /> |
||||
<span className="hidden sm:inline">{selectedTopicInfo.label}</span> |
||||
<span className="sm:hidden">{selectedTopicInfo.label.slice(0, 8)}</span> |
||||
<ChevronDown className="w-4 h-4" /> |
||||
</Button> |
||||
</DropdownMenuTrigger> |
||||
<DropdownMenuContent align="start" className="w-56"> |
||||
{topics.map(topic => ( |
||||
<DropdownMenuItem |
||||
key={topic.id} |
||||
onClick={() => onTopicChange(topic.id)} |
||||
className="flex items-center gap-2" |
||||
> |
||||
<Hash className="w-4 h-4" /> |
||||
<span>{topic.label}</span> |
||||
{topic.id === selectedTopic && ( |
||||
<span className="ml-auto text-primary">✓</span> |
||||
)} |
||||
</DropdownMenuItem> |
||||
))} |
||||
</DropdownMenuContent> |
||||
</DropdownMenu> |
||||
) |
||||
} |
||||
@ -0,0 +1,210 @@
@@ -0,0 +1,210 @@
|
||||
import { Button } from '@/components/ui/button' |
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' |
||||
import { DEFAULT_FAVORITE_RELAYS } from '@/constants' |
||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
||||
import { useNostr } from '@/providers/NostrProvider' |
||||
import { forwardRef, useEffect, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' |
||||
import { MessageSquarePlus, Hash } from 'lucide-react' |
||||
import ThreadCard from '@/pages/primary/DiscussionsPage/ThreadCard' |
||||
import TopicFilter from '@/pages/primary/DiscussionsPage/TopicFilter' |
||||
import { NostrEvent } from 'nostr-tools' |
||||
import client from '@/services/client.service' |
||||
|
||||
const DISCUSSION_TOPICS = [ |
||||
{ id: 'general', label: 'General', icon: Hash }, |
||||
{ id: 'meetups', label: 'Meetups', icon: Hash }, |
||||
{ id: 'devs', label: 'Developers', icon: Hash }, |
||||
{ id: 'finance', label: 'Finance & Economics', icon: Hash }, |
||||
{ id: 'politics', label: 'Politics & Breaking News', icon: Hash }, |
||||
{ id: 'literature', label: 'Literature & Art', icon: Hash }, |
||||
{ id: 'philosophy', label: 'Philosophy & Theology', icon: Hash }, |
||||
{ id: 'tech', label: 'Technology & Science', icon: Hash }, |
||||
{ id: 'sports', label: 'Sports', icon: Hash } |
||||
] |
||||
|
||||
const DiscussionsPage = forwardRef((_, ref) => { |
||||
const { t } = useTranslation() |
||||
const { favoriteRelays } = useFavoriteRelays() |
||||
const { pubkey } = useNostr() |
||||
const [selectedTopic, setSelectedTopic] = useState('general') |
||||
const [selectedRelay, setSelectedRelay] = useState<string | null>(null) |
||||
const [threads, setThreads] = useState<NostrEvent[]>([]) |
||||
const [loading, setLoading] = useState(false) |
||||
const [showCreateThread, setShowCreateThread] = useState(false) |
||||
|
||||
// Use DEFAULT_FAVORITE_RELAYS for logged-out users, or user's favorite relays for logged-in users
|
||||
const availableRelays = pubkey && favoriteRelays.length > 0 ? favoriteRelays : DEFAULT_FAVORITE_RELAYS |
||||
|
||||
useEffect(() => { |
||||
fetchThreads() |
||||
}, [selectedTopic, selectedRelay]) |
||||
|
||||
const fetchThreads = async () => { |
||||
setLoading(true) |
||||
try { |
||||
// Filter by relay if selected, otherwise use all available relays
|
||||
const relayUrls = selectedRelay ? [selectedRelay] : availableRelays |
||||
|
||||
const events = await client.fetchEvents(relayUrls, [ |
||||
{ |
||||
kinds: [11], // Thread events
|
||||
'#t': [selectedTopic], |
||||
'#-': ['-'], // Must have the "-" tag for relay privacy
|
||||
limit: 50 |
||||
} |
||||
]) |
||||
|
||||
// Filter and sort threads
|
||||
const filteredThreads = events |
||||
.filter(event => { |
||||
// Ensure it has a title tag
|
||||
const titleTag = event.tags.find(tag => tag[0] === 'title' && tag[1]) |
||||
return titleTag && event.content.trim().length > 0 |
||||
}) |
||||
.sort((a, b) => b.created_at - a.created_at) |
||||
|
||||
setThreads(filteredThreads) |
||||
} catch (error) { |
||||
console.error('Error fetching threads:', error) |
||||
setThreads([]) |
||||
} finally { |
||||
setLoading(false) |
||||
} |
||||
} |
||||
|
||||
const handleCreateThread = () => { |
||||
setShowCreateThread(true) |
||||
} |
||||
|
||||
const handleThreadCreated = () => { |
||||
setShowCreateThread(false) |
||||
fetchThreads() // Refresh the list
|
||||
} |
||||
|
||||
return ( |
||||
<PrimaryPageLayout |
||||
ref={ref} |
||||
pageName="discussions" |
||||
titlebar={ |
||||
<div className="flex gap-1 items-center h-full justify-between"> |
||||
<div className="flex gap-1 items-center"> |
||||
<TopicFilter |
||||
topics={DISCUSSION_TOPICS} |
||||
selectedTopic={selectedTopic} |
||||
onTopicChange={setSelectedTopic} |
||||
/> |
||||
{availableRelays.length > 1 && ( |
||||
<select |
||||
value={selectedRelay || ''} |
||||
onChange={(e) => setSelectedRelay(e.target.value || null)} |
||||
className="px-2 py-1 rounded border bg-background text-sm" |
||||
> |
||||
<option value="">All Relays</option> |
||||
{availableRelays.map(relay => ( |
||||
<option key={relay} value={relay}> |
||||
{relay.replace('wss://', '').replace('ws://', '')} |
||||
</option> |
||||
))} |
||||
</select> |
||||
)} |
||||
</div> |
||||
<div className="flex gap-1 items-center"> |
||||
<Button |
||||
variant="ghost" |
||||
size="titlebar-icon" |
||||
onClick={handleCreateThread} |
||||
title={t('Create new thread')} |
||||
> |
||||
<MessageSquarePlus /> |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
} |
||||
displayScrollToTopButton |
||||
> |
||||
<div className="p-4 space-y-4"> |
||||
<div className="flex items-center justify-between"> |
||||
<h1 className="text-2xl font-bold"> |
||||
{t('Discussions')} - {DISCUSSION_TOPICS.find(t => t.id === selectedTopic)?.label} |
||||
</h1> |
||||
</div> |
||||
|
||||
{loading ? ( |
||||
<div className="flex justify-center py-8"> |
||||
<div className="text-muted-foreground">{t('Loading threads...')}</div> |
||||
</div> |
||||
) : threads.length === 0 ? ( |
||||
<Card> |
||||
<CardContent className="p-8 text-center"> |
||||
<MessageSquarePlus className="w-12 h-12 mx-auto mb-4 text-muted-foreground" /> |
||||
<h3 className="text-lg font-semibold mb-2">{t('No threads yet')}</h3> |
||||
<p className="text-muted-foreground mb-4"> |
||||
{t('Be the first to start a discussion in this topic!')} |
||||
</p> |
||||
<Button onClick={handleCreateThread}> |
||||
<MessageSquarePlus className="w-4 h-4 mr-2" /> |
||||
{t('Create Thread')} |
||||
</Button> |
||||
</CardContent> |
||||
</Card> |
||||
) : ( |
||||
<div className="space-y-3"> |
||||
{threads.map(thread => ( |
||||
<ThreadCard |
||||
key={thread.id} |
||||
thread={thread} |
||||
onThreadClick={() => { |
||||
// TODO: Navigate to thread detail view
|
||||
console.log('Open thread:', thread.id) |
||||
}} |
||||
/> |
||||
))} |
||||
</div> |
||||
)} |
||||
</div> |
||||
|
||||
{showCreateThread && ( |
||||
<CreateThreadDialog |
||||
topic={selectedTopic} |
||||
availableRelays={availableRelays} |
||||
onClose={() => setShowCreateThread(false)} |
||||
onThreadCreated={handleThreadCreated} |
||||
/> |
||||
)} |
||||
</PrimaryPageLayout> |
||||
) |
||||
}) |
||||
|
||||
DiscussionsPage.displayName = 'DiscussionsPage' |
||||
export default DiscussionsPage |
||||
|
||||
// Placeholder components - to be implemented
|
||||
function CreateThreadDialog({
|
||||
onClose,
|
||||
onThreadCreated
|
||||
}: { |
||||
topic: string |
||||
availableRelays: string[] |
||||
onClose: () => void |
||||
onThreadCreated: () => void |
||||
}) { |
||||
// TODO: Implement thread creation dialog
|
||||
return ( |
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> |
||||
<Card className="w-full max-w-md mx-4"> |
||||
<CardHeader> |
||||
<CardTitle>Create New Thread</CardTitle> |
||||
</CardHeader> |
||||
<CardContent> |
||||
<p>Thread creation will be implemented here...</p> |
||||
<div className="flex gap-2 mt-4"> |
||||
<Button onClick={onClose} variant="outline">Cancel</Button> |
||||
<Button onClick={onThreadCreated}>Create</Button> |
||||
</div> |
||||
</CardContent> |
||||
</Card> |
||||
</div> |
||||
) |
||||
} |
||||
Loading…
Reference in new issue