9 changed files with 408 additions and 9 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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