feat: relay reviews
This commit is contained in:
@@ -61,7 +61,8 @@ export default function ContentPreview({
|
||||
kinds.ShortTextNote,
|
||||
ExtendedKind.COMMENT,
|
||||
ExtendedKind.VOICE,
|
||||
ExtendedKind.VOICE_COMMENT
|
||||
ExtendedKind.VOICE_COMMENT,
|
||||
ExtendedKind.RELAY_REVIEW
|
||||
].includes(event.kind)
|
||||
) {
|
||||
return <NormalContentPreview event={event} className={className} />
|
||||
|
||||
16
src/components/Note/RelayReview.tsx
Normal file
16
src/components/Note/RelayReview.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getStarsFromRelayReviewEvent } from '@/lib/event-metadata'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import Content from '../Content'
|
||||
import Stars from '../Stars'
|
||||
|
||||
export default function RelayReview({ event, className }: { event: Event; className?: string }) {
|
||||
const stars = useMemo(() => getStarsFromRelayReviewEvent(event), [event])
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Stars stars={stars} className="mt-2" />
|
||||
<Content event={event} className="mt-2" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import PictureNote from './PictureNote'
|
||||
import Poll from './Poll'
|
||||
import UnknownNote from './UnknownNote'
|
||||
import VideoNote from './VideoNote'
|
||||
import RelayReview from './RelayReview'
|
||||
|
||||
export default function Note({
|
||||
event,
|
||||
@@ -98,6 +99,8 @@ export default function Note({
|
||||
content = <PictureNote className="mt-2" event={event} />
|
||||
} else if (event.kind === ExtendedKind.VIDEO || event.kind === ExtendedKind.SHORT_VIDEO) {
|
||||
content = <VideoNote className="mt-2" event={event} />
|
||||
} else if (event.kind === ExtendedKind.RELAY_REVIEW) {
|
||||
content = <RelayReview className="mt-2" event={event} />
|
||||
} else {
|
||||
content = <Content className="mt-2" event={event} />
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
isReplaceableEvent,
|
||||
isReplyNoteEvent
|
||||
} from '@/lib/event'
|
||||
import { isTouchDevice } from '@/lib/utils'
|
||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
@@ -27,7 +28,6 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PullToRefresh from 'react-simple-pull-to-refresh'
|
||||
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
|
||||
import { isTouchDevice } from '@/lib/utils'
|
||||
|
||||
const LIMIT = 200
|
||||
const ALGO_LIMIT = 500
|
||||
|
||||
@@ -34,7 +34,7 @@ function RelayItem({ relay }: { relay: TMailboxRelay }) {
|
||||
|
||||
return (
|
||||
<div className="p-4 rounded-lg border clickable space-y-1" onClick={() => push(toRelay(url))}>
|
||||
<RelaySimpleInfo relayInfo={relayInfo} hideBadge />
|
||||
<RelaySimpleInfo relayInfo={relayInfo} />
|
||||
<div className="flex gap-2">
|
||||
{['both', 'read'].includes(scope) && (
|
||||
<Badge className="bg-blue-400 hover:bg-blue-400/80">{t('Read')}</Badge>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { TRelayInfo } from '@/types'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function RelayBadges({ relayInfo }: { relayInfo: TRelayInfo }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const badges = useMemo(() => {
|
||||
const b: string[] = []
|
||||
if (relayInfo.limitation?.payment_required) {
|
||||
b.push('Payment')
|
||||
}
|
||||
return b
|
||||
}, [relayInfo])
|
||||
|
||||
if (!badges.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{badges.includes('Payment') && (
|
||||
<Badge className="bg-orange-400 hover:bg-orange-400/80">{t('relayInfoBadgePayment')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
57
src/components/RelayInfo/RelayReviewCard.tsx
Normal file
57
src/components/RelayInfo/RelayReviewCard.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { getStarsFromRelayReviewEvent } from '@/lib/event-metadata'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import ClientTag from '../ClientTag'
|
||||
import ContentPreview from '../ContentPreview'
|
||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import Nip05 from '../Nip05'
|
||||
import Stars from '../Stars'
|
||||
import TranslateButton from '../TranslateButton'
|
||||
import { SimpleUserAvatar } from '../UserAvatar'
|
||||
import { SimpleUsername } from '../Username'
|
||||
|
||||
export default function RelayReviewCard({
|
||||
event,
|
||||
className
|
||||
}: {
|
||||
event: NostrEvent
|
||||
className?: string
|
||||
}) {
|
||||
const { push } = useSecondaryPage()
|
||||
const stars = useMemo(() => getStarsFromRelayReviewEvent(event), [event])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('clickable border rounded-lg bg-muted/20 p-3 h-full', className)}
|
||||
onClick={() => push(toNote(event))}
|
||||
>
|
||||
<div className="flex justify-between items-start gap-2">
|
||||
<div className="flex items-center space-x-2 flex-1">
|
||||
<SimpleUserAvatar userId={event.pubkey} size="medium" />
|
||||
<div className="flex-1 w-0">
|
||||
<div className="flex gap-2 items-center">
|
||||
<SimpleUsername
|
||||
userId={event.pubkey}
|
||||
className="font-semibold flex truncate text-sm"
|
||||
skeletonClassName="h-3"
|
||||
/>
|
||||
<ClientTag event={event} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Nip05 pubkey={event.pubkey} append="·" />
|
||||
<FormattedTimestamp timestamp={event.created_at} className="shrink-0" short />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<TranslateButton event={event} className="pr-0" />
|
||||
</div>
|
||||
</div>
|
||||
<Stars stars={stars} className="mt-2 gap-0.5 [&_svg]:size-3" />
|
||||
<ContentPreview className="mt-2 line-clamp-4" event={event} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
200
src/components/RelayInfo/RelayReviewsPreview.tsx
Normal file
200
src/components/RelayInfo/RelayReviewsPreview.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious
|
||||
} from '@/components/ui/carousel'
|
||||
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
|
||||
import { compareEvents } from '@/lib/event'
|
||||
import { getStarsFromRelayReviewEvent } from '@/lib/event-metadata'
|
||||
import { toRelayReviews } from '@/lib/link'
|
||||
import { cn, isTouchDevice } from '@/lib/utils'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'
|
||||
import { Filter, NostrEvent } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Stars from '../Stars'
|
||||
import RelayReviewCard from './RelayReviewCard'
|
||||
import ReviewEditor from './ReviewEditor'
|
||||
|
||||
export default function RelayReviewsPreview({ relayUrl }: { relayUrl: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
const { pubkey, checkLogin } = useNostr()
|
||||
const { hideUntrustedNotes, isUserTrusted } = useUserTrust()
|
||||
const { mutePubkeySet } = useMuteList()
|
||||
const [showEditor, setShowEditor] = useState(false)
|
||||
const [myReview, setMyReview] = useState<NostrEvent | null>(null)
|
||||
const [reviews, setReviews] = useState<NostrEvent[]>([])
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const { stars, count } = useMemo(() => {
|
||||
let totalStars = 0
|
||||
let totalCount = 0
|
||||
;[myReview, ...reviews].forEach((evt) => {
|
||||
if (!evt) return
|
||||
const stars = getStarsFromRelayReviewEvent(evt)
|
||||
if (stars) {
|
||||
totalStars += stars
|
||||
totalCount += 1
|
||||
}
|
||||
})
|
||||
return {
|
||||
stars: totalCount > 0 ? +(totalStars / totalCount).toFixed(1) : 0,
|
||||
count: totalCount
|
||||
}
|
||||
}, [myReview, reviews])
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const filters: Filter[] = [
|
||||
{ kinds: [ExtendedKind.RELAY_REVIEW], '#d': [relayUrl], limit: 100 }
|
||||
]
|
||||
if (pubkey) {
|
||||
filters.push({ kinds: [ExtendedKind.RELAY_REVIEW], authors: [pubkey], '#d': [relayUrl] })
|
||||
}
|
||||
const events = await client.fetchEvents([relayUrl, ...BIG_RELAY_URLS], filters, {
|
||||
cache: true
|
||||
})
|
||||
|
||||
const pubkeySet = new Set<string>()
|
||||
const reviews: NostrEvent[] = []
|
||||
let myReview: NostrEvent | null = null
|
||||
|
||||
events.sort((a, b) => compareEvents(b, a))
|
||||
for (const evt of events) {
|
||||
if (
|
||||
mutePubkeySet.has(evt.pubkey) ||
|
||||
pubkeySet.has(evt.pubkey) ||
|
||||
(hideUntrustedNotes && !isUserTrusted(evt.pubkey))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const stars = getStarsFromRelayReviewEvent(evt)
|
||||
if (!stars) {
|
||||
continue
|
||||
}
|
||||
|
||||
pubkeySet.add(evt.pubkey)
|
||||
if (evt.pubkey === pubkey) {
|
||||
myReview = evt
|
||||
} else {
|
||||
reviews.push(evt)
|
||||
}
|
||||
}
|
||||
|
||||
setMyReview(myReview)
|
||||
setReviews(reviews)
|
||||
setInitialized(true)
|
||||
}
|
||||
init()
|
||||
}, [relayUrl, pubkey, mutePubkeySet, hideUntrustedNotes, isUserTrusted])
|
||||
|
||||
const handleReviewed = (evt: NostrEvent) => {
|
||||
setMyReview(evt)
|
||||
setShowEditor(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="px-4 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-lg font-semibold">{stars}</div>
|
||||
<Stars stars={stars} />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-sm text-muted-foreground',
|
||||
count > 0 && 'underline cursor-pointer hover:text-foreground'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (count > 0) {
|
||||
push(toRelayReviews(relayUrl))
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('{{count}} reviews', { count })}
|
||||
</div>
|
||||
</div>
|
||||
{!showEditor && !myReview && (
|
||||
<Button variant="outline" onClick={() => checkLogin(() => setShowEditor(true))}>
|
||||
{t('Write a review')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showEditor && <ReviewEditor relayUrl={relayUrl} onReviewed={handleReviewed} />}
|
||||
|
||||
{myReview || reviews.length > 0 ? (
|
||||
<ReviewCarousel relayUrl={relayUrl} myReview={myReview} reviews={reviews} />
|
||||
) : !showEditor ? (
|
||||
<div className="flex items-center justify-center text-sm text-muted-foreground p-4">
|
||||
{initialized ? t('No reviews yet. Be the first to write one!') : t('Loading...')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewCarousel({
|
||||
relayUrl,
|
||||
myReview,
|
||||
reviews
|
||||
}: {
|
||||
relayUrl: string
|
||||
myReview: NostrEvent | null
|
||||
reviews: NostrEvent[]
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
const showPreviousAndNext = useMemo(() => !isTouchDevice(), [])
|
||||
|
||||
return (
|
||||
<Carousel
|
||||
opts={{
|
||||
skipSnaps: true
|
||||
}}
|
||||
plugins={[WheelGesturesPlugin()]}
|
||||
>
|
||||
<CarouselContent className="ml-4 mr-2">
|
||||
{myReview && (
|
||||
<Item key={myReview.id}>
|
||||
<RelayReviewCard event={myReview} className="border-primary/60 bg-primary/5" />
|
||||
</Item>
|
||||
)}
|
||||
{reviews.slice(0, 10).map((evt) => (
|
||||
<Item key={evt.id}>
|
||||
<RelayReviewCard event={evt} />
|
||||
</Item>
|
||||
))}
|
||||
{reviews.length > 10 && (
|
||||
<Item>
|
||||
<div
|
||||
className="border rounded-lg bg-muted/20 p-3 flex items-center justify-center h-full hover:bg-muted cursor-pointer"
|
||||
onClick={() => push(toRelayReviews(relayUrl))}
|
||||
>
|
||||
<div className="text-sm text-muted-foreground">{t('View more reviews')}</div>
|
||||
</div>
|
||||
</Item>
|
||||
)}
|
||||
</CarouselContent>
|
||||
{showPreviousAndNext && <CarouselPrevious />}
|
||||
{showPreviousAndNext && <CarouselNext />}
|
||||
</Carousel>
|
||||
)
|
||||
}
|
||||
|
||||
function Item({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<CarouselItem className="basis-11/12 lg:basis-2/3 2xl:basis-5/12 pl-0 pr-2">
|
||||
{children}
|
||||
</CarouselItem>
|
||||
)
|
||||
}
|
||||
90
src/components/RelayInfo/ReviewEditor.tsx
Normal file
90
src/components/RelayInfo/ReviewEditor.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { BIG_RELAY_URLS } from '@/constants'
|
||||
import { createRelayReviewDraftEvent } from '@/lib/draft-event'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { Loader2, Star } from 'lucide-react'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export default function ReviewEditor({
|
||||
relayUrl,
|
||||
onReviewed
|
||||
}: {
|
||||
relayUrl: string
|
||||
onReviewed: (evt: NostrEvent) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { publish } = useNostr()
|
||||
const [stars, setStars] = useState(0)
|
||||
const [hoverStars, setHoverStars] = useState(0)
|
||||
const [review, setReview] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const canSubmit = useMemo(() => stars > 0 && !!review.trim(), [stars, review])
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const draftEvent = createRelayReviewDraftEvent(relayUrl, review, stars)
|
||||
const evt = await publish(draftEvent, { specifiedRelayUrls: [relayUrl, ...BIG_RELAY_URLS] })
|
||||
onReviewed(evt)
|
||||
} catch (error) {
|
||||
if (error instanceof AggregateError) {
|
||||
error.errors.forEach((e) => toast.error(`${t('Failed to review')}: ${e.message}`))
|
||||
} else if (error instanceof Error) {
|
||||
toast.error(`${t('Failed to review')}: ${error.message}`)
|
||||
}
|
||||
console.error(error)
|
||||
return
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 space-y-2">
|
||||
<Textarea
|
||||
className="min-h-36"
|
||||
placeholder={t('Write a review and pick a star rating')}
|
||||
value={review}
|
||||
onChange={(e) => setReview(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="pr-2 cursor-pointer"
|
||||
onMouseEnter={() => setHoverStars(index + 1)}
|
||||
onMouseLeave={() => setHoverStars(0)}
|
||||
>
|
||||
{index < (hoverStars || stars) ? (
|
||||
<Star
|
||||
className="size-6 text-yellow-400 fill-yellow-400"
|
||||
onClick={() => setStars(index + 1)}
|
||||
/>
|
||||
) : (
|
||||
<Star
|
||||
className="size-6 text-muted-foreground"
|
||||
onClick={() => setStars(index + 1)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
disabled={!canSubmit}
|
||||
variant={canSubmit ? 'default' : 'secondary'}
|
||||
onClick={submit}
|
||||
>
|
||||
{submitting && <Loader2 className="animate-spin" />}
|
||||
{t('Submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +1,24 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
import { useFetchRelayInfo } from '@/hooks'
|
||||
import { normalizeHttpUrl } from '@/lib/url'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { Check, Copy, GitBranch, Link, Mail, SquareCode } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import PostEditor from '../PostEditor'
|
||||
import RelayBadges from '../RelayBadges'
|
||||
import RelayIcon from '../RelayIcon'
|
||||
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
import RelayReviewsPreview from './RelayReviewsPreview'
|
||||
|
||||
export default function RelayInfo({ url, className }: { url: string; className?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { checkLogin } = useNostr()
|
||||
const { relayInfo, isFetching } = useFetchRelayInfo(url)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
@@ -24,97 +27,94 @@ export default function RelayInfo({ url, className }: { url: string; className?:
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('px-4 space-y-4 mb-2', className)}>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<div className="flex gap-2 items-center truncate">
|
||||
<RelayIcon url={url} className="w-8 h-8" />
|
||||
<div className="text-2xl font-semibold truncate select-text">
|
||||
{relayInfo.name || relayInfo.shortUrl}
|
||||
</div>
|
||||
</div>
|
||||
<RelayControls url={relayInfo.url} />
|
||||
</div>
|
||||
<RelayBadges relayInfo={relayInfo} />
|
||||
{!!relayInfo.tags?.length && (
|
||||
<div className="flex gap-2">
|
||||
{relayInfo.tags.map((tag) => (
|
||||
<Badge variant="secondary">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{relayInfo.description && (
|
||||
<div className="text-wrap break-words whitespace-pre-wrap mt-2 select-text">
|
||||
{relayInfo.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-semibold text-muted-foreground">{t('Homepage')}:</div>
|
||||
<a
|
||||
href={normalizeHttpUrl(relayInfo.url)}
|
||||
target="_blank"
|
||||
className="hover:underline text-primary select-text"
|
||||
>
|
||||
{normalizeHttpUrl(relayInfo.url)}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{relayInfo.payments_url && (
|
||||
<div className={cn('space-y-4 mb-2', className)}>
|
||||
<div className="px-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-semibold text-muted-foreground">{t('Payment page')}:</div>
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<div className="flex gap-2 items-center truncate">
|
||||
<RelayIcon url={url} className="w-8 h-8" />
|
||||
<div className="text-2xl font-semibold truncate select-text">
|
||||
{relayInfo.name || relayInfo.shortUrl}
|
||||
</div>
|
||||
</div>
|
||||
<RelayControls url={relayInfo.url} />
|
||||
</div>
|
||||
{!!relayInfo.tags?.length && (
|
||||
<div className="flex gap-2">
|
||||
{relayInfo.tags.map((tag) => (
|
||||
<Badge variant="secondary">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{relayInfo.description && (
|
||||
<div className="text-wrap break-words whitespace-pre-wrap mt-2 select-text">
|
||||
{relayInfo.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-semibold text-muted-foreground">{t('Homepage')}</div>
|
||||
<a
|
||||
href={normalizeHttpUrl(relayInfo.payments_url)}
|
||||
href={normalizeHttpUrl(relayInfo.url)}
|
||||
target="_blank"
|
||||
className="hover:underline text-primary select-text"
|
||||
>
|
||||
{relayInfo.payments_url}
|
||||
{normalizeHttpUrl(relayInfo.url)}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{relayInfo.pubkey && (
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="text-sm font-semibold text-muted-foreground">{t('Operator')}</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<UserAvatar userId={relayInfo.pubkey} size="small" />
|
||||
<Username userId={relayInfo.pubkey} className="font-semibold" />
|
||||
</div>
|
||||
|
||||
<ScrollArea className="overflow-x-auto">
|
||||
<div className="flex gap-8 pb-2">
|
||||
{relayInfo.pubkey && (
|
||||
<div className="space-y-2 w-fit">
|
||||
<div className="text-sm font-semibold text-muted-foreground">{t('Operator')}</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<UserAvatar userId={relayInfo.pubkey} size="small" />
|
||||
<Username userId={relayInfo.pubkey} className="font-semibold text-nowrap" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{relayInfo.contact && (
|
||||
<div className="space-y-2 w-fit">
|
||||
<div className="text-sm font-semibold text-muted-foreground">{t('Contact')}</div>
|
||||
<div className="flex gap-2 items-center font-semibold select-text text-nowrap">
|
||||
<Mail />
|
||||
{relayInfo.contact}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{relayInfo.software && (
|
||||
<div className="space-y-2 w-fit">
|
||||
<div className="text-sm font-semibold text-muted-foreground">{t('Software')}</div>
|
||||
<div className="flex gap-2 items-center font-semibold select-text text-nowrap">
|
||||
<SquareCode />
|
||||
{formatSoftware(relayInfo.software)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{relayInfo.version && (
|
||||
<div className="space-y-2 w-fit">
|
||||
<div className="text-sm font-semibold text-muted-foreground">{t('Version')}</div>
|
||||
<div className="flex gap-2 items-center font-semibold select-text text-nowrap">
|
||||
<GitBranch />
|
||||
{relayInfo.version}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{relayInfo.contact && (
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="text-sm font-semibold text-muted-foreground">{t('Contact')}</div>
|
||||
<div className="flex gap-2 items-center font-semibold select-text">
|
||||
<Mail />
|
||||
{relayInfo.contact}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{relayInfo.software && (
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="text-sm font-semibold text-muted-foreground">{t('Software')}</div>
|
||||
<div className="flex gap-2 items-center font-semibold select-text">
|
||||
<SquareCode />
|
||||
{formatSoftware(relayInfo.software)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{relayInfo.version && (
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="text-sm font-semibold text-muted-foreground">{t('Version')}</div>
|
||||
<div className="flex gap-2 items-center font-semibold select-text">
|
||||
<GitBranch />
|
||||
{relayInfo.version}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => checkLogin(() => setOpen(true))}
|
||||
>
|
||||
{t('Share something on this Relay')}
|
||||
</Button>
|
||||
<PostEditor open={open} setOpen={setOpen} openFrom={[relayInfo.url]} />
|
||||
</div>
|
||||
<Button variant="secondary" className="w-full" onClick={() => setOpen(true)}>
|
||||
{t('Share something on this Relay')}
|
||||
</Button>
|
||||
<PostEditor open={open} setOpen={setOpen} openFrom={[relayInfo.url]} />
|
||||
<RelayReviewsPreview relayUrl={url} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { cn } from '@/lib/utils'
|
||||
import { TRelayInfo } from '@/types'
|
||||
import { HTMLProps } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RelayBadges from '../RelayBadges'
|
||||
import RelayIcon from '../RelayIcon'
|
||||
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu'
|
||||
import { SimpleUserAvatar } from '../UserAvatar'
|
||||
@@ -11,13 +10,11 @@ import { SimpleUserAvatar } from '../UserAvatar'
|
||||
export default function RelaySimpleInfo({
|
||||
relayInfo,
|
||||
users,
|
||||
hideBadge = false,
|
||||
className,
|
||||
...props
|
||||
}: HTMLProps<HTMLDivElement> & {
|
||||
relayInfo?: TRelayInfo
|
||||
users?: string[]
|
||||
hideBadge?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -35,7 +32,6 @@ export default function RelaySimpleInfo({
|
||||
</div>
|
||||
{relayInfo && <SaveRelayDropdownMenu urls={[relayInfo.url]} />}
|
||||
</div>
|
||||
{!hideBadge && relayInfo && <RelayBadges relayInfo={relayInfo} />}
|
||||
{!!relayInfo?.description && <div className="line-clamp-3">{relayInfo.description}</div>}
|
||||
{!!users?.length && (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
19
src/components/Stars/index.tsx
Normal file
19
src/components/Stars/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Star } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
export default function Stars({ stars, className }: { stars: number; className?: string }) {
|
||||
const roundedStars = useMemo(() => Math.round(stars), [stars])
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
{Array.from({ length: 5 }).map((_, index) =>
|
||||
index < roundedStars ? (
|
||||
<Star key={index} className="size-4 text-foreground fill-foreground" />
|
||||
) : (
|
||||
<Star key={index} className="size-4 text-muted-foreground" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -29,7 +29,8 @@ export default function TranslateButton({
|
||||
kinds.Highlights,
|
||||
ExtendedKind.COMMENT,
|
||||
ExtendedKind.PICTURE,
|
||||
ExtendedKind.POLL
|
||||
ExtendedKind.POLL,
|
||||
ExtendedKind.RELAY_REVIEW
|
||||
].includes(event.kind),
|
||||
[event]
|
||||
)
|
||||
|
||||
@@ -68,7 +68,7 @@ export function SimpleUserAvatar({
|
||||
onClick
|
||||
}: {
|
||||
userId: string
|
||||
size?: 'large' | 'big' | 'normal' | 'small' | 'xSmall' | 'tiny'
|
||||
size?: 'large' | 'big' | 'normal' | 'medium' | 'small' | 'xSmall' | 'tiny'
|
||||
className?: string
|
||||
onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void
|
||||
}) {
|
||||
|
||||
235
src/components/ui/carousel.tsx
Normal file
235
src/components/ui/carousel.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import * as React from 'react'
|
||||
import useEmblaCarousel, { type UseEmblaCarouselType } from 'embla-carousel-react'
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useCarousel must be used within a <Carousel />')
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const Carousel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||
>(({ orientation = 'horizontal', opts, setApi, plugins, className, children, ...props }, ref) => {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === 'horizontal' ? 'x' : 'y'
|
||||
},
|
||||
plugins
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) {
|
||||
return
|
||||
}
|
||||
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
onSelect(api)
|
||||
api.on('reInit', onSelect)
|
||||
api.on('select', onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off('select', onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation: orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn('relative', className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
})
|
||||
Carousel.displayName = 'Carousel'
|
||||
|
||||
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} className="overflow-hidden">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex',
|
||||
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
CarouselContent.displayName = 'CarouselContent'
|
||||
|
||||
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
className={cn(
|
||||
'min-w-0 shrink-0 grow-0 basis-full',
|
||||
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
CarouselItem.displayName = 'CarouselItem'
|
||||
|
||||
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
|
||||
({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
'absolute h-8 w-8 rounded-full',
|
||||
orientation === 'horizontal'
|
||||
? 'left-4 top-1/2 -translate-y-1/2'
|
||||
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
canScrollPrev ? '' : 'invisible',
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
)
|
||||
CarouselPrevious.displayName = 'CarouselPrevious'
|
||||
|
||||
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
|
||||
({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
'absolute h-8 w-8 rounded-full',
|
||||
orientation === 'horizontal'
|
||||
? 'right-4 top-1/2 -translate-y-1/2'
|
||||
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
canScrollNext ? '' : 'invisible',
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
)
|
||||
CarouselNext.displayName = 'CarouselNext'
|
||||
|
||||
export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext }
|
||||
Reference in New Issue
Block a user