40 changed files with 1202 additions and 203 deletions
@ -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 @@ |
|||||||
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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