40 changed files with 1202 additions and 203 deletions
@ -0,0 +1,16 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -1,28 +0,0 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,57 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,200 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,90 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,19 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -0,0 +1,235 @@
@@ -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 } |
||||
@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
|
||||
import NoteList from '@/components/NoteList' |
||||
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants' |
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' |
||||
import { normalizeUrl, simplifyUrl } from '@/lib/url' |
||||
import { forwardRef, useMemo } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
import NotFoundPage from '../NotFoundPage' |
||||
|
||||
const RelayReviewsPage = forwardRef(({ url, index }: { url?: string; index?: number }, ref) => { |
||||
const { t } = useTranslation() |
||||
const normalizedUrl = useMemo(() => (url ? normalizeUrl(url) : undefined), [url]) |
||||
const title = useMemo( |
||||
() => (url ? t('Reviews for {{relay}}', { relay: simplifyUrl(url) }) : undefined), |
||||
[url] |
||||
) |
||||
|
||||
if (!normalizedUrl) { |
||||
return <NotFoundPage ref={ref} /> |
||||
} |
||||
|
||||
return ( |
||||
<SecondaryPageLayout ref={ref} index={index} title={title} displayScrollToTopButton> |
||||
<NoteList |
||||
showKinds={[ExtendedKind.RELAY_REVIEW]} |
||||
subRequests={[ |
||||
{ |
||||
urls: [normalizedUrl, ...BIG_RELAY_URLS], |
||||
filter: { '#d': [normalizedUrl] } |
||||
} |
||||
]} |
||||
/> |
||||
</SecondaryPageLayout> |
||||
) |
||||
}) |
||||
RelayReviewsPage.displayName = 'RelayReviewsPage' |
||||
export default RelayReviewsPage |
||||
Loading…
Reference in new issue