feat: explore (#85)
This commit is contained in:
parent
80893ec033
commit
b91f46723e
35 changed files with 811 additions and 179 deletions
|
|
@ -3,30 +3,20 @@ import { useSecondaryPage } from '@/PageManager'
|
|||
import { ChevronLeft } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function BackButton({
|
||||
hide = false,
|
||||
children
|
||||
}: {
|
||||
hide?: boolean
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
export default function BackButton({ children }: { children?: React.ReactNode }) {
|
||||
const { t } = useTranslation()
|
||||
const { pop } = useSecondaryPage()
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hide && (
|
||||
<Button
|
||||
className="flex gap-1 items-center w-fit max-w-full justify-start pl-2 pr-3"
|
||||
variant="ghost"
|
||||
size="titlebar-icon"
|
||||
title={t('back')}
|
||||
onClick={() => pop()}
|
||||
>
|
||||
<ChevronLeft />
|
||||
<div className="truncate text-lg font-semibold">{children}</div>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
<Button
|
||||
className="flex gap-1 items-center w-fit max-w-full justify-start pl-2 pr-3"
|
||||
variant="ghost"
|
||||
size="titlebar-icon"
|
||||
title={t('back')}
|
||||
onClick={() => pop()}
|
||||
>
|
||||
<ChevronLeft />
|
||||
<div className="truncate text-lg font-semibold">{children}</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export default function BottomNavigationBarItem({
|
|||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'flex shadow-none items-center bg-transparent w-full h-12 xl:w-full xl:h-auto p-3 m-0 xl:py-2 xl:px-4 rounded-lg xl:justify-start text-lg font-semibold [&_svg]:size-full xl:[&_svg]:size-4',
|
||||
'flex shadow-none items-center bg-transparent w-full h-12 p-3 m-0 rounded-lg [&_svg]:size-6',
|
||||
active && 'text-primary hover:text-primary'
|
||||
)}
|
||||
variant="ghost"
|
||||
|
|
|
|||
13
src/components/BottomNavigationBar/ExploreButton.tsx
Normal file
13
src/components/BottomNavigationBar/ExploreButton.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { usePrimaryPage } from '@/PageManager'
|
||||
import { Compass } from 'lucide-react'
|
||||
import BottomNavigationBarItem from './BottomNavigationBarItem'
|
||||
|
||||
export default function ExploreButton() {
|
||||
const { navigate, current } = usePrimaryPage()
|
||||
|
||||
return (
|
||||
<BottomNavigationBarItem active={current === 'explore'} onClick={() => navigate('explore')}>
|
||||
<Compass />
|
||||
</BottomNavigationBarItem>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import PostEditor from '@/components/PostEditor'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { PencilLine } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import BottomNavigationBarItem from './BottomNavigationBarItem'
|
||||
|
||||
export default function PostButton() {
|
||||
const { checkLogin } = useNostr()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<BottomNavigationBarItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
checkLogin(() => {
|
||||
setOpen(true)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<PencilLine />
|
||||
</BottomNavigationBarItem>
|
||||
<PostEditor open={open} setOpen={setOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
import AccountButton from './AccountButton'
|
||||
import ExploreButton from './ExploreButton'
|
||||
import HomeButton from './HomeButton'
|
||||
import NotificationsButton from './NotificationsButton'
|
||||
import PostButton from './PostButton'
|
||||
|
||||
export default function BottomNavigationBar() {
|
||||
return (
|
||||
|
|
@ -16,7 +16,7 @@ export default function BottomNavigationBar() {
|
|||
}}
|
||||
>
|
||||
<HomeButton />
|
||||
<PostButton />
|
||||
<ExploreButton />
|
||||
<NotificationsButton />
|
||||
<AccountButton />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useMuteList } from '@/providers/MuteListProvider'
|
|||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import client from '@/services/client.service'
|
||||
import relayInfoService from '@/services/relay-info.service'
|
||||
import storage from '@/services/storage.service'
|
||||
import { TNoteListMode } from '@/types'
|
||||
import dayjs from 'dayjs'
|
||||
|
|
@ -76,7 +77,7 @@ export default function NoteList({
|
|||
|
||||
let areAlgoRelays = false
|
||||
if (needCheckAlgoRelay) {
|
||||
const relayInfos = await client.fetchRelayInfos(relayUrls)
|
||||
const relayInfos = await relayInfoService.getRelayInfos(relayUrls)
|
||||
areAlgoRelays = relayInfos.every((relayInfo) => checkAlgoRelay(relayInfo))
|
||||
}
|
||||
const filter = areAlgoRelays ? { ...noteFilter, limit: ALGO_RELAY_LIMIT } : noteFilter
|
||||
|
|
@ -255,7 +256,7 @@ function ListModeSwitch({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'sticky top-12 bg-background z-10 duration-700 transition-transform',
|
||||
'sticky top-12 bg-background z-30 duration-700 transition-transform',
|
||||
deepBrowsing && lastScrollTop > 800 ? '-translate-y-[calc(100%+12rem)]' : ''
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -3,12 +3,10 @@ import { Badge } from '@/components/ui/badge'
|
|||
import { useFetchRelayInfo, useFetchRelayList } from '@/hooks'
|
||||
import { toRelay } from '@/lib/link'
|
||||
import { userIdToPubkey } from '@/lib/pubkey'
|
||||
import { simplifyUrl } from '@/lib/url'
|
||||
import { TMailboxRelay } from '@/types'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RelayIcon from '../RelayIcon'
|
||||
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu'
|
||||
import RelaySimpleInfo from '../RelaySimpleInfo'
|
||||
|
||||
export default function OthersRelayList({ userId }: { userId: string }) {
|
||||
const { t } = useTranslation()
|
||||
|
|
@ -35,27 +33,15 @@ function RelayItem({ relay }: { relay: TMailboxRelay }) {
|
|||
const { url, scope } = relay
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 justify-between p-4 rounded-lg border clickable"
|
||||
onClick={() => push(toRelay(url))}
|
||||
>
|
||||
<div className="flex-1 w-0 space-y-2">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<RelayIcon url={url} />
|
||||
<div className="truncate font-semibold text-lg">{simplifyUrl(url)}</div>
|
||||
</div>
|
||||
{!!relayInfo?.description && <div className="line-clamp-2">{relayInfo.description}</div>}
|
||||
<div className="flex gap-2">
|
||||
{['both', 'read'].includes(scope) && (
|
||||
<Badge className="bg-blue-400 hover:bg-blue-400/80">{t('Read')}</Badge>
|
||||
)}
|
||||
{['both', 'write'].includes(scope) && (
|
||||
<Badge className="bg-green-400 hover:bg-green-400/80">{t('Write')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<SaveRelayDropdownMenu urls={[url]} />
|
||||
<div className="p-4 rounded-lg border clickable space-y-1" onClick={() => push(toRelay(url))}>
|
||||
<RelaySimpleInfo relayInfo={relayInfo} hideBadge />
|
||||
<div className="flex gap-2">
|
||||
{['both', 'read'].includes(scope) && (
|
||||
<Badge className="bg-blue-400 hover:bg-blue-400/80">{t('Read')}</Badge>
|
||||
)}
|
||||
{['both', 'write'].includes(scope) && (
|
||||
<Badge className="bg-green-400 hover:bg-green-400/80">{t('Write')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { createCommentDraftEvent, createShortTextNoteDraftEvent } from '@/lib/dr
|
|||
import { useFeed } from '@/providers/FeedProvider.tsx'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import client from '@/services/client.service'
|
||||
import relayInfoService from '@/services/relay-info.service'
|
||||
import { ChevronDown, ImageUp, LoaderCircle } from 'lucide-react'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { useState } from 'react'
|
||||
|
|
@ -54,7 +55,7 @@ export default function NormalPostContent({
|
|||
}
|
||||
let protectedEvent = false
|
||||
if (postOptions.sendOnlyToCurrentRelays) {
|
||||
const relayInfos = await client.fetchRelayInfos(relayUrls)
|
||||
const relayInfos = await relayInfoService.getRelayInfos(relayUrls)
|
||||
protectedEvent = relayInfos.every((info) => info?.supported_nips?.includes(70))
|
||||
}
|
||||
const draftEvent =
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { createPictureNoteDraftEvent } from '@/lib/draft-event'
|
|||
import { cn } from '@/lib/utils'
|
||||
import { useFeed } from '@/providers/FeedProvider.tsx'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import client from '@/services/client.service'
|
||||
import relayInfoService from '@/services/relay-info.service'
|
||||
import { ChevronDown, Loader, LoaderCircle, Plus, X } from 'lucide-react'
|
||||
import { Dispatch, SetStateAction, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
@ -43,7 +43,7 @@ export default function PicturePostContent({ close }: { close: () => void }) {
|
|||
}
|
||||
let protectedEvent = false
|
||||
if (postOptions.sendOnlyToCurrentRelays) {
|
||||
const relayInfos = await client.fetchRelayInfos(relayUrls)
|
||||
const relayInfos = await relayInfoService.getRelayInfos(relayUrls)
|
||||
protectedEvent = relayInfos.every((info) => info?.supported_nips?.includes(70))
|
||||
}
|
||||
const draftEvent = await createPictureNoteDraftEvent(content, pictureInfos, {
|
||||
|
|
|
|||
40
src/components/RelayBadges/index.tsx
Normal file
40
src/components/RelayBadges/index.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
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?.auth_required) {
|
||||
b.push('Auth')
|
||||
}
|
||||
if (relayInfo.supported_nips?.includes(50)) {
|
||||
b.push('Search')
|
||||
}
|
||||
if (relayInfo.limitation?.payment_required) {
|
||||
b.push('Payment')
|
||||
}
|
||||
return b
|
||||
}, [relayInfo])
|
||||
|
||||
if (!badges.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{badges.includes('Auth') && (
|
||||
<Badge className="bg-green-400 hover:bg-green-400/80">{t('relayInfoBadgeAuth')}</Badge>
|
||||
)}
|
||||
{badges.includes('Search') && (
|
||||
<Badge className="bg-pink-400 hover:bg-pink-400/80">{t('relayInfoBadgeSearch')}</Badge>
|
||||
)}
|
||||
{badges.includes('Payment') && (
|
||||
<Badge className="bg-orange-400 hover:bg-orange-400/80">{t('relayInfoBadgePayment')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ export default function RelayIcon({
|
|||
className = 'w-6 h-6',
|
||||
iconSize = 14
|
||||
}: {
|
||||
url: string
|
||||
url?: string
|
||||
className?: string
|
||||
iconSize?: number
|
||||
}) {
|
||||
|
|
@ -17,13 +17,14 @@ export default function RelayIcon({
|
|||
if (relayInfo?.icon) {
|
||||
return relayInfo.icon
|
||||
}
|
||||
if (!url) return
|
||||
const u = new URL(url)
|
||||
return `${u.protocol === 'wss:' ? 'https:' : 'http:'}//${u.host}/favicon.ico`
|
||||
}, [url, relayInfo])
|
||||
|
||||
return (
|
||||
<Avatar className={className}>
|
||||
<AvatarImage src={iconUrl} />
|
||||
<AvatarImage src={iconUrl} className="object-cover object-center" />
|
||||
<AvatarFallback>
|
||||
<Server size={iconSize} />
|
||||
</AvatarFallback>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import { Badge } from '@/components/ui/badge'
|
||||
import { useFetchRelayInfo } from '@/hooks'
|
||||
import { TRelayInfo } from '@/types'
|
||||
import { normalizeHttpUrl } from '@/lib/url'
|
||||
import { GitBranch, Mail, SquareCode } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RelayBadges from '../RelayBadges'
|
||||
import RelayIcon from '../RelayIcon'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
|
||||
export default function RelayInfo({ url }: { url: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { relayInfo, isFetching } = useFetchRelayInfo(url)
|
||||
if (isFetching || !relayInfo) {
|
||||
return null
|
||||
|
|
@ -33,10 +36,45 @@ export default function RelayInfo({ url }: { url: string }) {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!!relayInfo.supported_nips?.length && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-semibold text-muted-foreground">{t('Supported NIPs')}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{relayInfo.supported_nips
|
||||
.sort((a, b) => a - b)
|
||||
.map((nip) => (
|
||||
<Badge
|
||||
key={nip}
|
||||
variant="secondary"
|
||||
className="clickable"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`https://github.com/nostr-protocol/nips/blob/master/${formatNip(nip)}.md`
|
||||
)
|
||||
}
|
||||
>
|
||||
{formatNip(nip)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{relayInfo.payments_url && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-semibold text-muted-foreground">{t('Payment page')}:</div>
|
||||
<a
|
||||
href={normalizeHttpUrl(relayInfo.payments_url)}
|
||||
target="_blank"
|
||||
className="hover:underline text-primary"
|
||||
>
|
||||
{relayInfo.payments_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">Operator</div>
|
||||
<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" />
|
||||
|
|
@ -45,7 +83,7 @@ export default function RelayInfo({ url }: { url: string }) {
|
|||
)}
|
||||
{relayInfo.contact && (
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="text-sm font-semibold text-muted-foreground">Contact</div>
|
||||
<div className="text-sm font-semibold text-muted-foreground">{t('Contact')}</div>
|
||||
<div className="flex gap-2 items-center font-semibold">
|
||||
<Mail />
|
||||
{relayInfo.contact}
|
||||
|
|
@ -54,7 +92,7 @@ export default function RelayInfo({ url }: { url: string }) {
|
|||
)}
|
||||
{relayInfo.software && (
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="text-sm font-semibold text-muted-foreground">Software</div>
|
||||
<div className="text-sm font-semibold text-muted-foreground">{t('Software')}</div>
|
||||
<div className="flex gap-2 items-center font-semibold">
|
||||
<SquareCode />
|
||||
{formatSoftware(relayInfo.software)}
|
||||
|
|
@ -63,7 +101,7 @@ export default function RelayInfo({ url }: { url: string }) {
|
|||
)}
|
||||
{relayInfo.version && (
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="text-sm font-semibold text-muted-foreground">Version</div>
|
||||
<div className="text-sm font-semibold text-muted-foreground">{t('Version')}</div>
|
||||
<div className="flex gap-2 items-center font-semibold">
|
||||
<GitBranch />
|
||||
{relayInfo.version}
|
||||
|
|
@ -80,21 +118,9 @@ function formatSoftware(software: string) {
|
|||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
export function RelayBadges({ relayInfo }: { relayInfo: TRelayInfo }) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{relayInfo.supported_nips?.includes(42) && (
|
||||
<Badge className="bg-green-400 hover:bg-green-400/80">Auth</Badge>
|
||||
)}
|
||||
{relayInfo.supported_nips?.includes(50) && (
|
||||
<Badge className="bg-pink-400 hover:bg-pink-400/80">Search</Badge>
|
||||
)}
|
||||
{relayInfo.limitation?.payment_required && (
|
||||
<Badge className="bg-orange-400 hover:bg-orange-400/80">Payment</Badge>
|
||||
)}
|
||||
{relayInfo.supported_nips?.includes(29) && (
|
||||
<Badge className="bg-blue-400 hover:bg-blue-400/80">Groups</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
function formatNip(nip: number) {
|
||||
if (nip < 10) {
|
||||
return `0${nip}`
|
||||
}
|
||||
return `${nip}`
|
||||
}
|
||||
|
|
|
|||
108
src/components/RelayList/index.tsx
Normal file
108
src/components/RelayList/index.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toRelay } from '@/lib/link'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import relayInfoService from '@/services/relay-info.service'
|
||||
import { TNip66RelayInfo } from '@/types'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RelaySimpleInfo from '../RelaySimpleInfo'
|
||||
import SearchInput from '../SearchInput'
|
||||
|
||||
export default function RelayList() {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [relays, setRelays] = useState<TNip66RelayInfo[]>([])
|
||||
const [showCount, setShowCount] = useState(20)
|
||||
const [input, setInput] = useState('')
|
||||
const [debouncedInput, setDebouncedInput] = useState(input)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const search = async () => {
|
||||
const relayInfos = await relayInfoService.search(debouncedInput)
|
||||
setShowCount(20)
|
||||
setRelays(relayInfos)
|
||||
setLoading(false)
|
||||
}
|
||||
search()
|
||||
}, [debouncedInput])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedInput(input)
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler)
|
||||
}
|
||||
}, [input])
|
||||
|
||||
useEffect(() => {
|
||||
const options = {
|
||||
root: null,
|
||||
rootMargin: '10px',
|
||||
threshold: 1
|
||||
}
|
||||
|
||||
const observerInstance = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && showCount < relays.length) {
|
||||
setShowCount((prev) => prev + 20)
|
||||
}
|
||||
}, options)
|
||||
|
||||
const currentBottomRef = bottomRef.current
|
||||
if (currentBottomRef) {
|
||||
observerInstance.observe(currentBottomRef)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observerInstance && currentBottomRef) {
|
||||
observerInstance.unobserve(currentBottomRef)
|
||||
}
|
||||
}
|
||||
}, [showCount, relays])
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInput(e.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="px-4 py-2 sticky top-12 bg-background z-30">
|
||||
<SearchInput placeholder={t('Search relays')} value={input} onChange={handleInputChange} />
|
||||
</div>
|
||||
{relays.slice(0, showCount).map((relay) => (
|
||||
<RelaySimpleInfo
|
||||
key={relay.url}
|
||||
relayInfo={relay}
|
||||
className="clickable p-4 border-b"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
push(toRelay(relay.url))
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{showCount < relays.length && <div ref={bottomRef} />}
|
||||
{loading && (
|
||||
<div className="p-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2 w-full">
|
||||
<div className="flex flex-1 w-0 items-center gap-2">
|
||||
<Skeleton className="h-9 w-9 rounded-full" />
|
||||
<div className="flex-1 w-0 space-y-1">
|
||||
<Skeleton className="w-40 h-5" />
|
||||
<Skeleton className="w-20 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="w-5 h-5 rounded-lg" />
|
||||
</div>
|
||||
<Skeleton className="w-full h-4" />
|
||||
<Skeleton className="w-2/3 h-4" />
|
||||
</div>
|
||||
)}
|
||||
{!loading && relays.length === 0 && (
|
||||
<div className="text-center text-muted-foreground text-sm">{t('no relays found')}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
35
src/components/RelaySimpleInfo/index.tsx
Normal file
35
src/components/RelaySimpleInfo/index.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
import { TNip66RelayInfo } from '@/types'
|
||||
import RelayBadges from '../RelayBadges'
|
||||
import RelayIcon from '../RelayIcon'
|
||||
import SaveRelayDropdownMenu from '../SaveRelayDropdownMenu'
|
||||
import { HTMLProps } from 'react'
|
||||
|
||||
export default function RelaySimpleInfo({
|
||||
relayInfo,
|
||||
hideBadge = false,
|
||||
className,
|
||||
...props
|
||||
}: HTMLProps<HTMLDivElement> & {
|
||||
relayInfo?: TNip66RelayInfo
|
||||
hideBadge?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('space-y-1', className)} {...props}>
|
||||
<div className="flex items-start justify-between gap-2 w-full">
|
||||
<div className="flex flex-1 w-0 items-center gap-2">
|
||||
<RelayIcon url={relayInfo?.url} className="h-9 w-9" />
|
||||
<div className="flex-1 w-0">
|
||||
<div className="truncate font-semibold">{relayInfo?.name || relayInfo?.shortUrl}</div>
|
||||
{relayInfo?.name && (
|
||||
<div className="text-xs text-muted-foreground truncate">{relayInfo?.shortUrl}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{relayInfo && <SaveRelayDropdownMenu urls={[relayInfo.url]} />}
|
||||
</div>
|
||||
{!hideBadge && relayInfo && <RelayBadges relayInfo={relayInfo} />}
|
||||
{!!relayInfo?.description && <div className="line-clamp-4">{relayInfo.description}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -32,9 +32,15 @@ export default function SaveRelayDropdownMenu({
|
|||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size={atTitlebar ? 'titlebar-icon' : 'icon'}>
|
||||
<Star className={alreadySaved ? 'fill-primary stroke-primary' : ''} />
|
||||
</Button>
|
||||
{atTitlebar ? (
|
||||
<Button variant="ghost" size="titlebar-icon">
|
||||
<Star className={alreadySaved ? 'fill-primary stroke-primary' : ''} />
|
||||
</Button>
|
||||
) : (
|
||||
<button className="enabled:hover:text-primary [&_svg]:size-5">
|
||||
<Star className={alreadySaved ? 'fill-primary stroke-primary' : ''} />
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>{t('Save to')} ...</DropdownMenuLabel>
|
||||
|
|
|
|||
33
src/components/SearchInput/index.tsx
Normal file
33
src/components/SearchInput/index.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
import { SearchIcon, X } from 'lucide-react'
|
||||
import { ComponentProps, useEffect, useState } from 'react'
|
||||
|
||||
export default function SearchInput({ value, onChange, ...props }: ComponentProps<'input'>) {
|
||||
const [displayClear, setDisplayClear] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayClear(!!value)
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center rounded-md border border-input bg-transparent px-2 py-1 text-base shadow-sm transition-colors md:text-sm [&:has(:focus-visible)]:ring-ring [&:has(:focus-visible)]:ring-1 [&:has(:focus-visible)]:outline-none'
|
||||
)}
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<input
|
||||
{...props}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="size-full mx-2 border-none bg-transparent focus:outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{displayClear && (
|
||||
<button type="button" onClick={() => onChange?.({ target: { value: '' } } as any)}>
|
||||
<X className="size-4 shrink-0 opacity-50 hover:opacity-100" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
src/components/Sidebar/ExploreButton.tsx
Normal file
19
src/components/Sidebar/ExploreButton.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { usePrimaryPage } from '@/PageManager'
|
||||
import { Compass } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SidebarItem from './SidebarItem'
|
||||
|
||||
export default function RelaysButton() {
|
||||
const { t } = useTranslation()
|
||||
const { navigate, current } = usePrimaryPage()
|
||||
|
||||
return (
|
||||
<SidebarItem
|
||||
title={t('Explore')}
|
||||
onClick={() => navigate('explore')}
|
||||
active={current === 'explore'}
|
||||
>
|
||||
<Compass strokeWidth={3} />
|
||||
</SidebarItem>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import AccountButton from './AccountButton'
|
|||
import HomeButton from './HomeButton'
|
||||
import NotificationsButton from './NotificationButton'
|
||||
import PostButton from './PostButton'
|
||||
import RelaysButton from './ExploreButton'
|
||||
import SearchButton from './SearchButton'
|
||||
import SettingsButton from './SettingsButton'
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ export default function PrimaryPageSidebar() {
|
|||
<Logo className="max-xl:hidden" />
|
||||
</div>
|
||||
<HomeButton />
|
||||
<RelaysButton />
|
||||
<NotificationsButton />
|
||||
<SearchButton />
|
||||
<SettingsButton />
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export function Titlebar({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'sticky top-0 w-full z-20 bg-background [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
'sticky top-0 w-full z-40 bg-background [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue