refactor: remove electron-related code
This commit is contained in:
parent
bed8df06e8
commit
2b1e6fe8f5
200 changed files with 2771 additions and 8432 deletions
48
src/components/AboutInfoDialog/index.tsx
Normal file
48
src/components/AboutInfoDialog/index.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog'
|
||||
import Username from '../Username'
|
||||
|
||||
export default function AboutInfoDialog({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Jumble</DialogTitle>
|
||||
<DialogDescription>
|
||||
A beautiful nostr client focused on browsing relay feeds
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div>
|
||||
Made by{' '}
|
||||
<Username
|
||||
userId={'npub1syjmjy0dp62dhccq3g97fr87tngvpvzey08llyt6ul58m2zqpzps9wf6wl'}
|
||||
className="inline-block text-primary"
|
||||
showAt
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
Source code:{' '}
|
||||
<a
|
||||
href="https://github.com/CodyTseng/jumble"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
If you like this project, you can buy me a coffee ☕️ <br />
|
||||
<div className="font-semibold">⚡️ codytseng@getalby.com ⚡️</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
29
src/components/AccountButton/LoginButton.tsx
Normal file
29
src/components/AccountButton/LoginButton.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { LogIn } from 'lucide-react'
|
||||
|
||||
export default function LoginButton({
|
||||
variant = 'titlebar'
|
||||
}: {
|
||||
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
|
||||
}) {
|
||||
const { checkLogin } = useNostr()
|
||||
|
||||
let triggerComponent: React.ReactNode
|
||||
if (variant === 'titlebar' || variant === 'small-screen-titlebar') {
|
||||
triggerComponent = <LogIn />
|
||||
} else {
|
||||
triggerComponent = (
|
||||
<>
|
||||
<LogIn size={16} />
|
||||
<div>Login</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant={variant} size={variant} onClick={() => checkLogin()}>
|
||||
{triggerComponent}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
81
src/components/AccountButton/ProfileButton.tsx
Normal file
81
src/components/AccountButton/ProfileButton.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useFetchProfile } from '@/hooks'
|
||||
import { toProfile } from '@/lib/link'
|
||||
import { formatPubkey, generateImageByPubkey } from '@/lib/pubkey'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ProfileButton({
|
||||
pubkey,
|
||||
variant = 'titlebar'
|
||||
}: {
|
||||
pubkey: string
|
||||
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { logout } = useNostr()
|
||||
const { profile } = useFetchProfile(pubkey)
|
||||
const { push } = useSecondaryPage()
|
||||
|
||||
const defaultAvatar = generateImageByPubkey(pubkey)
|
||||
const { username, avatar } = profile || { username: formatPubkey(pubkey), avatar: defaultAvatar }
|
||||
|
||||
let triggerComponent: React.ReactNode
|
||||
if (variant === 'titlebar') {
|
||||
triggerComponent = (
|
||||
<button>
|
||||
<Avatar className="w-7 h-7 hover:opacity-90">
|
||||
<AvatarImage src={avatar} />
|
||||
<AvatarFallback>
|
||||
<img src={defaultAvatar} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</button>
|
||||
)
|
||||
} else if (variant === 'small-screen-titlebar') {
|
||||
triggerComponent = (
|
||||
<button>
|
||||
<Avatar className="w-8 h-8 hover:opacity-90">
|
||||
<AvatarImage src={avatar} />
|
||||
<AvatarFallback>
|
||||
<img src={defaultAvatar} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</button>
|
||||
)
|
||||
} else {
|
||||
triggerComponent = (
|
||||
<Button variant="sidebar" size="sidebar" className="border hover:bg-muted px-2">
|
||||
<div className="flex gap-2 items-center flex-1 w-0">
|
||||
<Avatar className="w-10 h-10">
|
||||
<AvatarImage src={avatar} />
|
||||
<AvatarFallback>
|
||||
<img src={defaultAvatar} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="truncate font-semibold text-lg">{username}</div>
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{triggerComponent}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => push(toProfile(pubkey))}>{t('Profile')}</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={logout}>
|
||||
{t('Logout')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
17
src/components/AccountButton/index.tsx
Normal file
17
src/components/AccountButton/index.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import LoginButton from './LoginButton'
|
||||
import ProfileButton from './ProfileButton'
|
||||
|
||||
export default function AccountButton({
|
||||
variant = 'titlebar'
|
||||
}: {
|
||||
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
|
||||
}) {
|
||||
const { pubkey } = useNostr()
|
||||
|
||||
if (pubkey) {
|
||||
return <ProfileButton variant={variant} pubkey={pubkey} />
|
||||
} else {
|
||||
return <LoginButton variant={variant} />
|
||||
}
|
||||
}
|
||||
25
src/components/BackButton/index.tsx
Normal file
25
src/components/BackButton/index.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function BackButton({
|
||||
hide = false,
|
||||
variant = 'titlebar'
|
||||
}: {
|
||||
hide?: boolean
|
||||
variant?: 'titlebar' | 'small-screen-titlebar'
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { pop } = useSecondaryPage()
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hide && (
|
||||
<Button variant={variant} size={variant} title={t('back')} onClick={() => pop()}>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
145
src/components/Content/index.tsx
Normal file
145
src/components/Content/index.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { isNsfwEvent } from '@/lib/event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
embedded,
|
||||
embeddedHashtagRenderer,
|
||||
embeddedNormalUrlRenderer,
|
||||
embeddedNostrNpubRenderer,
|
||||
embeddedNostrProfileRenderer,
|
||||
EmbeddedNote,
|
||||
embeddedWebsocketUrlRenderer
|
||||
} from '../Embedded'
|
||||
import ImageGallery from '../ImageGallery'
|
||||
import VideoPlayer from '../VideoPlayer'
|
||||
import WebPreview from '../WebPreview'
|
||||
|
||||
const Content = memo(
|
||||
({
|
||||
event,
|
||||
className,
|
||||
size = 'normal'
|
||||
}: {
|
||||
event: Event
|
||||
className?: string
|
||||
size?: 'normal' | 'small'
|
||||
}) => {
|
||||
const { content, images, videos, embeddedNotes, lastNonMediaUrl } = preprocess(event.content)
|
||||
const isNsfw = isNsfwEvent(event)
|
||||
const nodes = embedded(content, [
|
||||
embeddedNormalUrlRenderer,
|
||||
embeddedWebsocketUrlRenderer,
|
||||
embeddedHashtagRenderer,
|
||||
embeddedNostrNpubRenderer,
|
||||
embeddedNostrProfileRenderer
|
||||
])
|
||||
|
||||
// Add images
|
||||
if (images.length) {
|
||||
nodes.push(
|
||||
<ImageGallery
|
||||
className={`w-fit ${size === 'small' ? 'mt-1' : 'mt-2'}`}
|
||||
key={`image-gallery-${event.id}`}
|
||||
images={images}
|
||||
isNsfw={isNsfw}
|
||||
size={size}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Add videos
|
||||
if (videos.length) {
|
||||
videos.forEach((src, index) => {
|
||||
nodes.push(
|
||||
<VideoPlayer
|
||||
className={size === 'small' ? 'mt-1' : 'mt-2'}
|
||||
key={`video-${index}-${src}`}
|
||||
src={src}
|
||||
isNsfw={isNsfw}
|
||||
size={size}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Add website preview
|
||||
if (lastNonMediaUrl) {
|
||||
nodes.push(
|
||||
<WebPreview
|
||||
className={size === 'small' ? 'mt-1' : 'mt-2'}
|
||||
key={`web-preview-${event.id}`}
|
||||
url={lastNonMediaUrl}
|
||||
size={size}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Add embedded notes
|
||||
if (embeddedNotes.length) {
|
||||
embeddedNotes.forEach((note, index) => {
|
||||
const id = note.split(':')[1]
|
||||
nodes.push(
|
||||
<EmbeddedNote
|
||||
key={`embedded-event-${index}`}
|
||||
noteId={id}
|
||||
className={size === 'small' ? 'mt-1' : 'mt-2'}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return <div className={cn('text-wrap break-words whitespace-pre-wrap', className)}>{nodes}</div>
|
||||
}
|
||||
)
|
||||
Content.displayName = 'Content'
|
||||
export default Content
|
||||
|
||||
function preprocess(content: string) {
|
||||
const urlRegex = /(https?:\/\/[^\s"']+)/g
|
||||
const urls = content.match(urlRegex) || []
|
||||
let lastNonMediaUrl: string | undefined
|
||||
|
||||
let c = content
|
||||
const images: string[] = []
|
||||
const videos: string[] = []
|
||||
|
||||
urls.forEach((url) => {
|
||||
if (isImage(url)) {
|
||||
c = c.replace(url, '').trim()
|
||||
images.push(url)
|
||||
} else if (isVideo(url)) {
|
||||
c = c.replace(url, '').trim()
|
||||
videos.push(url)
|
||||
} else {
|
||||
lastNonMediaUrl = url
|
||||
}
|
||||
})
|
||||
|
||||
const embeddedNotes: string[] = []
|
||||
const embeddedNoteRegex = /nostr:(note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g
|
||||
;(c.match(embeddedNoteRegex) || []).forEach((note) => {
|
||||
c = c.replace(note, '').trim()
|
||||
embeddedNotes.push(note)
|
||||
})
|
||||
|
||||
return { content: c, images, videos, embeddedNotes, lastNonMediaUrl }
|
||||
}
|
||||
|
||||
function isImage(url: string) {
|
||||
try {
|
||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.svg']
|
||||
return imageExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isVideo(url: string) {
|
||||
try {
|
||||
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov']
|
||||
return videoExtensions.some((ext) => new URL(url).pathname.toLowerCase().endsWith(ext))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
22
src/components/Embedded/EmbeddedHashtag.tsx
Normal file
22
src/components/Embedded/EmbeddedHashtag.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { toNoteList } from '@/lib/link'
|
||||
import { SecondaryPageLink } from '@/PageManager'
|
||||
import { TEmbeddedRenderer } from './types'
|
||||
|
||||
export function EmbeddedHashtag({ hashtag }: { hashtag: string }) {
|
||||
return (
|
||||
<SecondaryPageLink
|
||||
className="text-highlight hover:underline"
|
||||
to={toNoteList({ hashtag })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
#{hashtag}
|
||||
</SecondaryPageLink>
|
||||
)
|
||||
}
|
||||
|
||||
export const embeddedHashtagRenderer: TEmbeddedRenderer = {
|
||||
regex: /#([\p{L}\p{N}\p{M}]+)/gu,
|
||||
render: (hashtag: string, index: number) => {
|
||||
return <EmbeddedHashtag key={`hashtag-${index}-${hashtag}`} hashtag={hashtag} />
|
||||
}
|
||||
}
|
||||
29
src/components/Embedded/EmbeddedMention.tsx
Normal file
29
src/components/Embedded/EmbeddedMention.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import Username from '../Username'
|
||||
import { TEmbeddedRenderer } from './types'
|
||||
|
||||
export function EmbeddedMention({ userId }: { userId: string }) {
|
||||
return <Username userId={userId} showAt className="text-highlight font-normal inline-block" />
|
||||
}
|
||||
|
||||
export const embeddedNostrNpubRenderer: TEmbeddedRenderer = {
|
||||
regex: /(nostr:npub1[a-z0-9]{58})/g,
|
||||
render: (id: string, index: number) => {
|
||||
const npub1 = id.split(':')[1]
|
||||
return <EmbeddedMention key={`embedded-nostr-npub-${index}-${npub1}`} userId={npub1} />
|
||||
}
|
||||
}
|
||||
|
||||
export const embeddedNostrProfileRenderer: TEmbeddedRenderer = {
|
||||
regex: /(nostr:nprofile1[a-z0-9]+)/g,
|
||||
render: (id: string, index: number) => {
|
||||
const nprofile = id.split(':')[1]
|
||||
return <EmbeddedMention key={`embedded-nostr-profile-${index}-${nprofile}`} userId={nprofile} />
|
||||
}
|
||||
}
|
||||
|
||||
export const embeddedNpubRenderer: TEmbeddedRenderer = {
|
||||
regex: /(npub1[a-z0-9]{58})/g,
|
||||
render: (npub1: string, index: number) => {
|
||||
return <EmbeddedMention key={`embedded-npub-${index}-${npub1}`} userId={npub1} />
|
||||
}
|
||||
}
|
||||
22
src/components/Embedded/EmbeddedNormalUrl.tsx
Normal file
22
src/components/Embedded/EmbeddedNormalUrl.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { TEmbeddedRenderer } from './types'
|
||||
|
||||
export function EmbeddedNormalUrl({ url }: { url: string }) {
|
||||
return (
|
||||
<a
|
||||
className="text-highlight hover:underline"
|
||||
href={url}
|
||||
target="_blank"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export const embeddedNormalUrlRenderer: TEmbeddedRenderer = {
|
||||
regex: /(https?:\/\/[^\s]+)/g,
|
||||
render: (url: string, index: number) => {
|
||||
return <EmbeddedNormalUrl key={`normal-url-${index}-${url}`} url={url} />
|
||||
}
|
||||
}
|
||||
29
src/components/Embedded/EmbeddedNote.tsx
Normal file
29
src/components/Embedded/EmbeddedNote.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useFetchEvent } from '@/hooks'
|
||||
import { toNoStrudelArticle, toNoStrudelNote, toNoStrudelStream } from '@/lib/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { kinds } from 'nostr-tools'
|
||||
import ShortTextNoteCard from '../NoteCard/ShortTextNoteCard'
|
||||
|
||||
export function EmbeddedNote({ noteId, className }: { noteId: string; className?: string }) {
|
||||
const { event } = useFetchEvent(noteId)
|
||||
|
||||
return event && event.kind === kinds.ShortTextNote ? (
|
||||
<ShortTextNoteCard className={cn('w-full', className)} event={event} embedded />
|
||||
) : (
|
||||
<a
|
||||
href={
|
||||
event?.kind === kinds.LongFormArticle
|
||||
? toNoStrudelArticle(noteId)
|
||||
: event?.kind === kinds.LiveEvent
|
||||
? toNoStrudelStream(noteId)
|
||||
: toNoStrudelNote(noteId)
|
||||
}
|
||||
target="_blank"
|
||||
className="text-highlight hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{noteId}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
26
src/components/Embedded/EmbeddedWebsocketUrl.tsx
Normal file
26
src/components/Embedded/EmbeddedWebsocketUrl.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { toNoteList } from '@/lib/link'
|
||||
import { TEmbeddedRenderer } from './types'
|
||||
|
||||
export function EmbeddedWebsocketUrl({ url }: { url: string }) {
|
||||
const { push } = useSecondaryPage()
|
||||
return (
|
||||
<span
|
||||
className="cursor-pointer px-1 text-highlight hover:bg-highlight/20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
push(toNoteList({ relay: url }))
|
||||
}}
|
||||
>
|
||||
[ {url} ]
|
||||
<span className="w-2 h-1 bg-highlight" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const embeddedWebsocketUrlRenderer: TEmbeddedRenderer = {
|
||||
regex: /(wss?:\/\/[^\s]+)/g,
|
||||
render: (url: string, index: number) => {
|
||||
return <EmbeddedWebsocketUrl key={`websocket-url-${index}-${url}`} url={url} />
|
||||
}
|
||||
}
|
||||
18
src/components/Embedded/index.tsx
Normal file
18
src/components/Embedded/index.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export * from './EmbeddedHashtag'
|
||||
export * from './EmbeddedMention'
|
||||
export * from './EmbeddedNormalUrl'
|
||||
export * from './EmbeddedNote'
|
||||
export * from './EmbeddedWebsocketUrl'
|
||||
|
||||
import reactStringReplace from 'react-string-replace'
|
||||
import { TEmbeddedRenderer } from './types'
|
||||
|
||||
export function embedded(content: string, renderers: TEmbeddedRenderer[]) {
|
||||
let nodes: React.ReactNode[] = [content]
|
||||
|
||||
renderers.forEach((renderer) => {
|
||||
nodes = reactStringReplace(nodes, renderer.regex, renderer.render)
|
||||
})
|
||||
|
||||
return nodes
|
||||
}
|
||||
4
src/components/Embedded/types.tsx
Normal file
4
src/components/Embedded/types.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export type TEmbeddedRenderer = {
|
||||
regex: RegExp
|
||||
render: (match: string, index: number) => JSX.Element
|
||||
}
|
||||
73
src/components/FollowButton/index.tsx
Normal file
73
src/components/FollowButton/index.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/hooks'
|
||||
import { useFollowList } from '@/providers/FollowListProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { Loader } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function FollowButton({ pubkey }: { pubkey: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { toast } = useToast()
|
||||
const { pubkey: accountPubkey, checkLogin } = useNostr()
|
||||
const { followListEvent, followings, isReady, follow, unfollow } = useFollowList()
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey])
|
||||
|
||||
if (!accountPubkey || !isReady || (pubkey && pubkey === accountPubkey)) return null
|
||||
|
||||
const handleFollow = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
checkLogin(async () => {
|
||||
if (isFollowing) return
|
||||
|
||||
setUpdating(true)
|
||||
try {
|
||||
await follow(pubkey)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('Follow failed'),
|
||||
description: (error as Error).message,
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleUnfollow = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
checkLogin(async () => {
|
||||
if (!isFollowing || !followListEvent) return
|
||||
|
||||
setUpdating(true)
|
||||
try {
|
||||
await unfollow(pubkey)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t('Unfollow failed'),
|
||||
description: (error as Error).message,
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return isFollowing ? (
|
||||
<Button
|
||||
className="w-20 min-w-20 rounded-full"
|
||||
variant="secondary"
|
||||
onClick={handleUnfollow}
|
||||
disabled={updating}
|
||||
>
|
||||
{updating ? <Loader className="animate-spin" /> : t('Unfollow')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="w-20 min-w-20 rounded-full" onClick={handleFollow} disabled={updating}>
|
||||
{updating ? <Loader className="animate-spin" /> : t('Follow')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
36
src/components/FormattedTimestamp/index.tsx
Normal file
36
src/components/FormattedTimestamp/index.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import dayjs from 'dayjs'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function FormattedTimestamp({
|
||||
timestamp,
|
||||
short = false
|
||||
}: {
|
||||
timestamp: number
|
||||
short?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const time = dayjs(timestamp * 1000)
|
||||
const now = dayjs()
|
||||
|
||||
const diffMonth = now.diff(time, 'month')
|
||||
if (diffMonth >= 2) {
|
||||
return t('date', { timestamp: time.valueOf() })
|
||||
}
|
||||
|
||||
const diffDay = now.diff(time, 'day')
|
||||
if (diffDay >= 1) {
|
||||
return short ? t('n d', { n: diffDay }) : t('n days ago', { n: diffDay })
|
||||
}
|
||||
|
||||
const diffHour = now.diff(time, 'hour')
|
||||
if (diffHour >= 1) {
|
||||
return short ? t('n h', { n: diffHour }) : t('n hours ago', { n: diffHour })
|
||||
}
|
||||
|
||||
const diffMinute = now.diff(time, 'minute')
|
||||
if (diffMinute >= 1) {
|
||||
return short ? t('n m', { n: diffMinute }) : t('n minutes ago', { n: diffMinute })
|
||||
}
|
||||
|
||||
return t('just now')
|
||||
}
|
||||
62
src/components/ImageGallery/index.tsx
Normal file
62
src/components/ImageGallery/index.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { Image } from '@nextui-org/image'
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useState } from 'react'
|
||||
import Lightbox from 'yet-another-react-lightbox'
|
||||
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
|
||||
import NsfwOverlay from '../NsfwOverlay'
|
||||
|
||||
export default function ImageGallery({
|
||||
className,
|
||||
images,
|
||||
isNsfw = false,
|
||||
size = 'normal'
|
||||
}: {
|
||||
className?: string
|
||||
images: string[]
|
||||
isNsfw?: boolean
|
||||
size?: 'normal' | 'small'
|
||||
}) {
|
||||
const [index, setIndex] = useState(-1)
|
||||
|
||||
const handlePhotoClick = (event: React.MouseEvent, current: number) => {
|
||||
event.preventDefault()
|
||||
setIndex(current)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)} onClick={(e) => e.stopPropagation()}>
|
||||
<ScrollArea className="w-full">
|
||||
<div className="flex space-x-2">
|
||||
{images.map((src, index) => (
|
||||
<Image
|
||||
key={index}
|
||||
className={cn(
|
||||
'rounded-lg cursor-pointer z-0 object-cover',
|
||||
size === 'small' ? 'h-[15vh]' : 'h-[30vh]'
|
||||
)}
|
||||
src={src}
|
||||
onClick={(e) => handlePhotoClick(e, index)}
|
||||
removeWrapper
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
<Lightbox
|
||||
index={index}
|
||||
slides={images.map((src) => ({ src }))}
|
||||
plugins={[Zoom]}
|
||||
open={index >= 0}
|
||||
close={() => setIndex(-1)}
|
||||
controller={{
|
||||
closeOnBackdropClick: true,
|
||||
closeOnPullUp: true,
|
||||
closeOnPullDown: true
|
||||
}}
|
||||
styles={{ toolbar: { paddingTop: '2.25rem' } }}
|
||||
/>
|
||||
{isNsfw && <NsfwOverlay className="rounded-lg" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
src/components/LoginDialog/BunkerLogin.tsx
Normal file
47
src/components/LoginDialog/BunkerLogin.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { Loader } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function BunkerLogin({ onLoginSuccess }: { onLoginSuccess: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const { bunkerLogin } = useNostr()
|
||||
const [pending, setPending] = useState(false)
|
||||
const [bunkerInput, setBunkerInput] = useState('')
|
||||
const [errMsg, setErrMsg] = useState<string | null>(null)
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setBunkerInput(e.target.value)
|
||||
setErrMsg(null)
|
||||
}
|
||||
|
||||
const handleLogin = () => {
|
||||
if (bunkerInput === '') return
|
||||
|
||||
setPending(true)
|
||||
bunkerLogin(bunkerInput)
|
||||
.then(() => onLoginSuccess())
|
||||
.catch((err) => setErrMsg(err.message))
|
||||
.finally(() => setPending(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
placeholder="bunker://..."
|
||||
value={bunkerInput}
|
||||
onChange={handleInputChange}
|
||||
className={errMsg ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
|
||||
</div>
|
||||
<Button onClick={handleLogin} disabled={pending}>
|
||||
<Loader className={pending ? 'animate-spin' : 'hidden'} />
|
||||
{t('Login')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
48
src/components/LoginDialog/NsecLogin.tsx
Normal file
48
src/components/LoginDialog/NsecLogin.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function PrivateKeyLogin({ onLoginSuccess }: { onLoginSuccess: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const { nsecLogin } = useNostr()
|
||||
const [nsec, setNsec] = useState('')
|
||||
const [errMsg, setErrMsg] = useState<string | null>(null)
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNsec(e.target.value)
|
||||
setErrMsg(null)
|
||||
}
|
||||
|
||||
const handleLogin = () => {
|
||||
if (nsec === '') return
|
||||
|
||||
nsecLogin(nsec)
|
||||
.then(() => onLoginSuccess())
|
||||
.catch((err) => {
|
||||
setErrMsg(err.message)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-orange-400">
|
||||
{t(
|
||||
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.'
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="nsec1.."
|
||||
value={nsec}
|
||||
onChange={handleInputChange}
|
||||
className={errMsg ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
|
||||
</div>
|
||||
<Button onClick={handleLogin}>{t('Login')}</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
72
src/components/LoginDialog/index.tsx
Normal file
72
src/components/LoginDialog/index.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { Dispatch, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import BunkerLogin from './BunkerLogin'
|
||||
import PrivateKeyLogin from './NsecLogin'
|
||||
|
||||
export default function LoginDialog({
|
||||
open,
|
||||
setOpen
|
||||
}: {
|
||||
open: boolean
|
||||
setOpen: Dispatch<boolean>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [loginMethod, setLoginMethod] = useState<'nsec' | 'nip07' | 'bunker' | null>(null)
|
||||
const { nip07Login } = useNostr()
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="w-96">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="hidden" />
|
||||
<DialogDescription className="hidden" />
|
||||
</DialogHeader>
|
||||
{loginMethod === 'nsec' ? (
|
||||
<>
|
||||
<div
|
||||
className="absolute left-4 top-4 opacity-70 hover:opacity-100 cursor-pointer"
|
||||
onClick={() => setLoginMethod(null)}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</div>
|
||||
<PrivateKeyLogin onLoginSuccess={() => setOpen(false)} />
|
||||
</>
|
||||
) : loginMethod === 'bunker' ? (
|
||||
<>
|
||||
<div
|
||||
className="absolute left-4 top-4 opacity-70 hover:opacity-100 cursor-pointer"
|
||||
onClick={() => setLoginMethod(null)}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</div>
|
||||
<BunkerLogin onLoginSuccess={() => setOpen(false)} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!!window.nostr && (
|
||||
<Button onClick={() => nip07Login().then(() => setOpen(false))} className="w-full">
|
||||
{t('Login with Browser Extension')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" onClick={() => setLoginMethod('bunker')} className="w-full">
|
||||
{t('Login with Bunker')}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setLoginMethod('nsec')} className="w-full">
|
||||
{t('Login with Private Key')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
26
src/components/Nip05/index.tsx
Normal file
26
src/components/Nip05/index.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { useFetchNip05 } from '@/hooks/useFetchNip05'
|
||||
import { BadgeAlert, BadgeCheck } from 'lucide-react'
|
||||
|
||||
export default function Nip05({ nip05, pubkey }: { nip05?: string; pubkey: string }) {
|
||||
const { nip05IsVerified, nip05Name, nip05Domain } = useFetchNip05(nip05, pubkey)
|
||||
|
||||
return (
|
||||
nip05Name &&
|
||||
nip05Domain && (
|
||||
<div className="flex items-center space-x-1">
|
||||
{nip05Name !== '_' ? (
|
||||
<div className="text-sm text-muted-foreground truncate">@{nip05Name}</div>
|
||||
) : null}
|
||||
<a
|
||||
href={`https://${nip05Domain}`}
|
||||
target="_blank"
|
||||
className={`flex items-center space-x-1 hover:underline ${nip05IsVerified ? 'text-highlight' : 'text-muted-foreground'}`}
|
||||
rel="noreferrer"
|
||||
>
|
||||
{nip05IsVerified ? <BadgeCheck size={16} /> : <BadgeAlert size={16} />}
|
||||
<div className="text-sm">{nip05Domain}</div>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
61
src/components/Note/index.tsx
Normal file
61
src/components/Note/index.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { Event } from 'nostr-tools'
|
||||
import Content from '../Content'
|
||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import NoteStats from '../NoteStats'
|
||||
import ParentNotePreview from '../ParentNotePreview'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
|
||||
export default function Note({
|
||||
event,
|
||||
parentEvent,
|
||||
size = 'normal',
|
||||
className,
|
||||
hideStats = false,
|
||||
fetchNoteStats = false
|
||||
}: {
|
||||
event: Event
|
||||
parentEvent?: Event
|
||||
size?: 'normal' | 'small'
|
||||
className?: string
|
||||
hideStats?: boolean
|
||||
fetchNoteStats?: boolean
|
||||
}) {
|
||||
const { push } = useSecondaryPage()
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<UserAvatar userId={event.pubkey} size={size === 'small' ? 'small' : 'normal'} />
|
||||
<div
|
||||
className={`flex-1 w-0 ${size === 'small' ? 'flex space-x-2 items-end overflow-hidden' : ''}`}
|
||||
>
|
||||
<Username
|
||||
userId={event.pubkey}
|
||||
className={`font-semibold flex ${size === 'small' ? 'text-sm' : ''}`}
|
||||
skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
<FormattedTimestamp timestamp={event.created_at} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{parentEvent && (
|
||||
<ParentNotePreview
|
||||
event={parentEvent}
|
||||
className="mt-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
push(toNote(parentEvent))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Content className="mt-2" event={event} />
|
||||
{!hideStats && (
|
||||
<NoteStats className="mt-3 sm:mt-4" event={event} fetchIfNotExisting={fetchNoteStats} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
src/components/NoteCard/RepostNoteCard.tsx
Normal file
23
src/components/NoteCard/RepostNoteCard.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import client from '@/services/client.service'
|
||||
import { Event, kinds, verifyEvent } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import ShortTextNoteCard from './ShortTextNoteCard'
|
||||
|
||||
export default function RepostNoteCard({ event, className }: { event: Event; className?: string }) {
|
||||
const targetEvent = useMemo(() => {
|
||||
const targetEvent = event.content ? (JSON.parse(event.content) as Event) : null
|
||||
try {
|
||||
if (!targetEvent || !verifyEvent(targetEvent) || targetEvent.kind !== kinds.ShortTextNote) {
|
||||
return null
|
||||
}
|
||||
client.addEventToCache(targetEvent)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
return targetEvent
|
||||
}, [event])
|
||||
if (!targetEvent) return null
|
||||
|
||||
return <ShortTextNoteCard className={className} reposter={event.pubkey} event={targetEvent} />
|
||||
}
|
||||
68
src/components/NoteCard/ShortTextNoteCard.tsx
Normal file
68
src/components/NoteCard/ShortTextNoteCard.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { useFetchEvent } from '@/hooks'
|
||||
import { getParentEventId, getRootEventId } from '@/lib/event'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { Repeat2 } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Note from '../Note'
|
||||
import Username from '../Username'
|
||||
|
||||
export default function ShortTextNoteCard({
|
||||
event,
|
||||
className,
|
||||
reposter,
|
||||
embedded
|
||||
}: {
|
||||
event: Event
|
||||
className?: string
|
||||
reposter?: string
|
||||
embedded?: boolean
|
||||
}) {
|
||||
const { push } = useSecondaryPage()
|
||||
const { event: rootEvent } = useFetchEvent(getRootEventId(event))
|
||||
const { event: parentEvent } = useFetchEvent(getParentEventId(event))
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
push(toNote(event))
|
||||
}}
|
||||
>
|
||||
<RepostDescription reposter={reposter} className="max-sm:hidden pl-4" />
|
||||
<div
|
||||
className={`hover:bg-muted/50 text-left cursor-pointer ${embedded ? 'p-2 sm:p-3 border rounded-lg' : 'px-4 py-3 sm:py-4 sm:border sm:rounded-lg max-sm:border-b'}`}
|
||||
>
|
||||
<RepostDescription reposter={reposter} className="sm:hidden" />
|
||||
<Note
|
||||
size={embedded ? 'small' : 'normal'}
|
||||
event={event}
|
||||
parentEvent={parentEvent ?? rootEvent}
|
||||
hideStats={embedded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RepostDescription({
|
||||
reposter,
|
||||
className
|
||||
}: {
|
||||
reposter?: string | null
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
if (!reposter) return null
|
||||
|
||||
return (
|
||||
<div className={cn('flex gap-1 text-sm items-center text-muted-foreground mb-1', className)}>
|
||||
<Repeat2 size={16} className="shrink-0" />
|
||||
<Username userId={reposter} className="font-semibold truncate" skeletonClassName="h-3" />
|
||||
<div>{t('reposted')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
src/components/NoteCard/index.tsx
Normal file
10
src/components/NoteCard/index.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Event, kinds } from 'nostr-tools'
|
||||
import RepostNoteCard from './RepostNoteCard'
|
||||
import ShortTextNoteCard from './ShortTextNoteCard'
|
||||
|
||||
export default function NoteCard({ event, className }: { event: Event; className?: string }) {
|
||||
if (event.kind === kinds.Repost) {
|
||||
return <RepostNoteCard event={event} className={className} />
|
||||
}
|
||||
return <ShortTextNoteCard event={event} className={className} />
|
||||
}
|
||||
226
src/components/NoteList/index.tsx
Normal file
226
src/components/NoteList/index.tsx
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useFetchRelayInfos } from '@/hooks'
|
||||
import { isReplyNoteEvent } from '@/lib/event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import client from '@/services/client.service'
|
||||
import dayjs from 'dayjs'
|
||||
import { Event, Filter, kinds } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import NoteCard from '../NoteCard'
|
||||
|
||||
const NORMAL_RELAY_LIMIT = 100
|
||||
const ALGO_RELAY_LIMIT = 500
|
||||
|
||||
export default function NoteList({
|
||||
relayUrls,
|
||||
filter = {},
|
||||
className
|
||||
}: {
|
||||
relayUrls: string[]
|
||||
filter?: Filter
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { signEvent, checkLogin } = useNostr()
|
||||
const { isFetching: isFetchingRelayInfo, areAlgoRelays } = useFetchRelayInfos(relayUrls)
|
||||
const [refreshCount, setRefreshCount] = useState(0)
|
||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||
const [events, setEvents] = useState<Event[]>([])
|
||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||
const [hasMore, setHasMore] = useState<boolean>(true)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const [displayReplies, setDisplayReplies] = useState(false)
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
const noteFilter = useMemo(() => {
|
||||
return {
|
||||
kinds: [kinds.ShortTextNote, kinds.Repost],
|
||||
limit: areAlgoRelays ? ALGO_RELAY_LIMIT : NORMAL_RELAY_LIMIT,
|
||||
...filter
|
||||
}
|
||||
}, [JSON.stringify(filter), areAlgoRelays])
|
||||
|
||||
useEffect(() => {
|
||||
if (isFetchingRelayInfo) return
|
||||
|
||||
async function init() {
|
||||
setInitialized(false)
|
||||
setEvents([])
|
||||
setNewEvents([])
|
||||
setHasMore(true)
|
||||
|
||||
const { closer, timelineKey } = await client.subscribeTimeline(
|
||||
relayUrls,
|
||||
noteFilter,
|
||||
{
|
||||
onEvents: (events, eosed) => {
|
||||
if (events.length > 0) {
|
||||
setEvents(events)
|
||||
} else {
|
||||
setHasMore(false)
|
||||
}
|
||||
if (areAlgoRelays) {
|
||||
setHasMore(false)
|
||||
}
|
||||
if (eosed) {
|
||||
setInitialized(true)
|
||||
}
|
||||
},
|
||||
onNew: (event) => {
|
||||
setNewEvents((oldEvents) =>
|
||||
[event, ...oldEvents].sort((a, b) => b.created_at - a.created_at)
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
signer: async (evt) => {
|
||||
const signedEvt = await checkLogin(() => signEvent(evt))
|
||||
return signedEvt ?? null
|
||||
},
|
||||
needSort: !areAlgoRelays
|
||||
}
|
||||
)
|
||||
setTimelineKey(timelineKey)
|
||||
return closer
|
||||
}
|
||||
|
||||
const promise = init()
|
||||
return () => {
|
||||
promise.then((closer) => closer())
|
||||
}
|
||||
}, [
|
||||
JSON.stringify(relayUrls),
|
||||
JSON.stringify(noteFilter),
|
||||
isFetchingRelayInfo,
|
||||
areAlgoRelays,
|
||||
refreshCount
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized) return
|
||||
|
||||
const options = {
|
||||
root: null,
|
||||
rootMargin: '10px',
|
||||
threshold: 1
|
||||
}
|
||||
|
||||
const observerInstance = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && hasMore) {
|
||||
loadMore()
|
||||
}
|
||||
}, options)
|
||||
|
||||
const currentBottomRef = bottomRef.current
|
||||
|
||||
if (currentBottomRef) {
|
||||
observerInstance.observe(currentBottomRef)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observerInstance && currentBottomRef) {
|
||||
observerInstance.unobserve(currentBottomRef)
|
||||
}
|
||||
}
|
||||
}, [initialized, hasMore, events, timelineKey])
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!timelineKey) return
|
||||
|
||||
const newEvents = await client.loadMoreTimeline(
|
||||
timelineKey,
|
||||
events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(),
|
||||
noteFilter.limit
|
||||
)
|
||||
if (newEvents.length === 0) {
|
||||
setHasMore(false)
|
||||
return
|
||||
}
|
||||
setEvents((oldEvents) => [...oldEvents, ...newEvents])
|
||||
}
|
||||
|
||||
const showNewEvents = () => {
|
||||
setEvents((oldEvents) => [...newEvents, ...oldEvents])
|
||||
setNewEvents([])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2 sm:space-y-4', className)}>
|
||||
<DisplayRepliesSwitch displayReplies={displayReplies} setDisplayReplies={setDisplayReplies} />
|
||||
{newEvents.length > 0 && (
|
||||
<div className="flex justify-center w-full max-sm:mt-2">
|
||||
<Button size="lg" onClick={showNewEvents}>
|
||||
{t('show new notes')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col sm:gap-4">
|
||||
{events
|
||||
.filter((event) => displayReplies || !isReplyNoteEvent(event))
|
||||
.map((event) => (
|
||||
<NoteCard key={event.id} className="w-full" event={event} />
|
||||
))}
|
||||
</div>
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
{hasMore ? (
|
||||
<div ref={bottomRef}>{t('loading...')}</div>
|
||||
) : events.length ? (
|
||||
t('no more notes')
|
||||
) : (
|
||||
<div className="flex justify-center w-full max-sm:mt-2">
|
||||
<Button size="lg" onClick={() => setRefreshCount((pre) => pre + 1)}>
|
||||
{t('reload notes')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DisplayRepliesSwitch({
|
||||
displayReplies,
|
||||
setDisplayReplies
|
||||
}: {
|
||||
displayReplies: boolean
|
||||
setDisplayReplies: (value: boolean) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex">
|
||||
<div
|
||||
className={`w-1/2 text-center py-2 font-semibold hover:bg-muted cursor-pointer rounded-lg ${displayReplies ? 'text-muted-foreground' : ''}`}
|
||||
onClick={() => setDisplayReplies(false)}
|
||||
>
|
||||
{t('Notes')}
|
||||
</div>
|
||||
<div
|
||||
className={`w-1/2 text-center py-2 font-semibold hover:bg-muted cursor-pointer rounded-lg ${displayReplies ? '' : 'text-muted-foreground'}`}
|
||||
onClick={() => setDisplayReplies(true)}
|
||||
>
|
||||
{t('Notes & Replies')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`w-1/2 px-4 transition-transform duration-500 ${displayReplies ? 'translate-x-full' : ''}`}
|
||||
>
|
||||
<div className="w-full h-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
<div>{t('Display replies')}</div>
|
||||
<Switch checked={displayReplies} onCheckedChange={setDisplayReplies} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
89
src/components/NoteStats/LikeButton.tsx
Normal file
89
src/components/NoteStats/LikeButton.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { createReactionDraftEvent } from '@/lib/draft-event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { Heart, Loader } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { formatCount } from './utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function LikeButton({
|
||||
event,
|
||||
variant = 'normal',
|
||||
canFetch = false
|
||||
}: {
|
||||
event: Event
|
||||
variant?: 'normal' | 'reply'
|
||||
canFetch?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { publish, checkLogin } = useNostr()
|
||||
const { noteStatsMap, fetchNoteLikedStatus, fetchNoteLikeCount, markNoteAsLiked } = useNoteStats()
|
||||
const [liking, setLiking] = useState(false)
|
||||
const { likeCount, hasLiked } = useMemo(
|
||||
() => noteStatsMap.get(event.id) ?? {},
|
||||
[noteStatsMap, event.id]
|
||||
)
|
||||
const canLike = !hasLiked && !liking
|
||||
|
||||
useEffect(() => {
|
||||
if (!canFetch) return
|
||||
|
||||
if (likeCount === undefined) {
|
||||
fetchNoteLikeCount(event)
|
||||
}
|
||||
if (hasLiked === undefined) {
|
||||
fetchNoteLikedStatus(event)
|
||||
}
|
||||
}, [canFetch, event])
|
||||
|
||||
const like = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
checkLogin(async () => {
|
||||
if (!canLike) return
|
||||
|
||||
setLiking(true)
|
||||
const timer = setTimeout(() => setLiking(false), 5000)
|
||||
|
||||
try {
|
||||
const [liked] = await Promise.all([
|
||||
hasLiked === undefined ? fetchNoteLikedStatus(event) : hasLiked,
|
||||
likeCount === undefined ? fetchNoteLikeCount(event) : likeCount
|
||||
])
|
||||
if (liked) return
|
||||
|
||||
const targetRelayList = await client.fetchRelayList(event.pubkey)
|
||||
const reaction = createReactionDraftEvent(event)
|
||||
await publish(reaction, targetRelayList.read.slice(0, 3))
|
||||
markNoteAsLiked(event.id)
|
||||
} catch (error) {
|
||||
console.error('like failed', error)
|
||||
} finally {
|
||||
setLiking(false)
|
||||
clearTimeout(timer)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center enabled:hover:text-red-400',
|
||||
variant === 'normal' ? 'gap-1' : 'flex-col',
|
||||
hasLiked ? 'text-red-400' : 'text-muted-foreground'
|
||||
)}
|
||||
onClick={like}
|
||||
disabled={!canLike}
|
||||
title={t('Like')}
|
||||
>
|
||||
{liking ? (
|
||||
<Loader className="animate-spin" size={16} />
|
||||
) : (
|
||||
<Heart size={16} className={hasLiked ? 'fill-red-400' : ''} />
|
||||
)}
|
||||
<div className="text-sm">{formatCount(likeCount)}</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
34
src/components/NoteStats/NoteOptions/RawEventDialog.tsx
Normal file
34
src/components/NoteStats/NoteOptions/RawEventDialog.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
import { Event } from 'nostr-tools'
|
||||
|
||||
export default function RawEventDialog({
|
||||
event,
|
||||
isOpen,
|
||||
onClose
|
||||
}: {
|
||||
event: Event
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="h-[60vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Raw Event</DialogTitle>
|
||||
<DialogDescription className="hidden" />
|
||||
</DialogHeader>
|
||||
<ScrollArea className="h-full">
|
||||
<pre className="text-sm text-muted-foreground">{JSON.stringify(event, null, 2)}</pre>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
47
src/components/NoteStats/NoteOptions/index.tsx
Normal file
47
src/components/NoteStats/NoteOptions/index.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { getSharableEventId } from '@/lib/event'
|
||||
import { Code, Copy, Ellipsis } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RawEventDialog from './RawEventDialog'
|
||||
|
||||
export default function NoteOptions({ event }: { event: Event }) {
|
||||
const { t } = useTranslation()
|
||||
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="h-4" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Ellipsis
|
||||
size={16}
|
||||
className="text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent collisionPadding={8}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText('nostr:' + getSharableEventId(event))}
|
||||
>
|
||||
<Copy />
|
||||
{t('copy embedded code')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setIsRawEventDialogOpen(true)}>
|
||||
<Code />
|
||||
{t('raw event')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<RawEventDialog
|
||||
event={event}
|
||||
isOpen={isRawEventDialogOpen}
|
||||
onClose={() => setIsRawEventDialogOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
src/components/NoteStats/ReplyButton.tsx
Normal file
34
src/components/NoteStats/ReplyButton.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import { MessageCircle } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
import PostDialog from '../PostDialog'
|
||||
import { formatCount } from './utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ReplyButton({ event }: { event: Event }) {
|
||||
const { t } = useTranslation()
|
||||
const { noteStatsMap } = useNoteStats()
|
||||
const { pubkey } = useNostr()
|
||||
const { replyCount } = useMemo(() => noteStatsMap.get(event.id) ?? {}, [noteStatsMap, event.id])
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-blue-400"
|
||||
disabled={!pubkey}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpen(true)
|
||||
}}
|
||||
title={t('Reply')}
|
||||
>
|
||||
<MessageCircle size={16} />
|
||||
<div className="text-sm">{formatCount(replyCount)}</div>
|
||||
</button>
|
||||
<PostDialog parentEvent={event} open={open} setOpen={setOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
121
src/components/NoteStats/RepostButton.tsx
Normal file
121
src/components/NoteStats/RepostButton.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { createRepostDraftEvent } from '@/lib/draft-event'
|
||||
import { getSharableEventId } from '@/lib/event'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { Loader, PencilLine, Repeat } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import PostDialog from '../PostDialog'
|
||||
import { formatCount } from './utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function RepostButton({
|
||||
event,
|
||||
canFetch = false
|
||||
}: {
|
||||
event: Event
|
||||
canFetch?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { publish, checkLogin } = useNostr()
|
||||
const { noteStatsMap, fetchNoteRepostCount, fetchNoteRepostedStatus, markNoteAsReposted } =
|
||||
useNoteStats()
|
||||
const [reposting, setReposting] = useState(false)
|
||||
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
|
||||
const { repostCount, hasReposted } = useMemo(
|
||||
() => noteStatsMap.get(event.id) ?? {},
|
||||
[noteStatsMap, event.id]
|
||||
)
|
||||
const canRepost = !hasReposted && !reposting
|
||||
|
||||
useEffect(() => {
|
||||
if (!canFetch) return
|
||||
|
||||
if (repostCount === undefined) {
|
||||
fetchNoteRepostCount(event)
|
||||
}
|
||||
if (hasReposted === undefined) {
|
||||
fetchNoteRepostedStatus(event)
|
||||
}
|
||||
}, [canFetch, event])
|
||||
|
||||
const repost = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
checkLogin(async () => {
|
||||
if (!canRepost) return
|
||||
|
||||
setReposting(true)
|
||||
const timer = setTimeout(() => setReposting(false), 5000)
|
||||
|
||||
try {
|
||||
const [reposted] = await Promise.all([
|
||||
hasReposted === undefined ? fetchNoteRepostedStatus(event) : hasReposted,
|
||||
repostCount === undefined ? fetchNoteRepostCount(event) : repostCount
|
||||
])
|
||||
if (reposted) return
|
||||
|
||||
const targetRelayList = await client.fetchRelayList(event.pubkey)
|
||||
const repost = createRepostDraftEvent(event)
|
||||
await publish(repost, targetRelayList.read.slice(0, 5))
|
||||
markNoteAsReposted(event.id)
|
||||
} catch (error) {
|
||||
console.error('repost failed', error)
|
||||
} finally {
|
||||
setReposting(false)
|
||||
clearTimeout(timer)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex gap-1 items-center enabled:hover:text-lime-500',
|
||||
hasReposted ? 'text-lime-500' : 'text-muted-foreground'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={!canRepost}
|
||||
title={t('Repost')}
|
||||
>
|
||||
{reposting ? <Loader className="animate-spin" size={16} /> : <Repeat size={16} />}
|
||||
<div className="text-sm">{formatCount(repostCount)}</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem onClick={repost}>
|
||||
<Repeat /> {t('Repost')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsPostDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<PencilLine /> {t('Quote')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<PostDialog
|
||||
open={isPostDialogOpen}
|
||||
setOpen={setIsPostDialogOpen}
|
||||
defaultContent={'\nnostr:' + getSharableEventId(event)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
27
src/components/NoteStats/index.tsx
Normal file
27
src/components/NoteStats/index.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
import { Event } from 'nostr-tools'
|
||||
import LikeButton from './LikeButton'
|
||||
import NoteOptions from './NoteOptions'
|
||||
import ReplyButton from './ReplyButton'
|
||||
import RepostButton from './RepostButton'
|
||||
|
||||
export default function NoteStats({
|
||||
event,
|
||||
className,
|
||||
fetchIfNotExisting = false
|
||||
}: {
|
||||
event: Event
|
||||
className?: string
|
||||
fetchIfNotExisting?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex justify-between', className)}>
|
||||
<div className="flex gap-4 h-4 items-center">
|
||||
<ReplyButton event={event} />
|
||||
<RepostButton event={event} canFetch={fetchIfNotExisting} />
|
||||
<LikeButton event={event} canFetch={fetchIfNotExisting} />
|
||||
</div>
|
||||
<NoteOptions event={event} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
4
src/components/NoteStats/utils.ts
Normal file
4
src/components/NoteStats/utils.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export function formatCount(count?: number) {
|
||||
if (count === undefined || count <= 0) return ''
|
||||
return count >= 100 ? '99+' : count
|
||||
}
|
||||
39
src/components/NotificationButton/index.tsx
Normal file
39
src/components/NotificationButton/index.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { toNotifications } from '@/lib/link'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { Bell } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function NotificationButton({
|
||||
variant = 'titlebar'
|
||||
}: {
|
||||
variant?: 'sidebar' | 'titlebar' | 'small-screen-titlebar'
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
|
||||
if (variant === 'sidebar') {
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={variant}
|
||||
title={t('notifications')}
|
||||
onClick={() => push(toNotifications())}
|
||||
>
|
||||
<Bell />
|
||||
{t('Notifications')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={variant}
|
||||
title={t('notifications')}
|
||||
onClick={() => push(toNotifications())}
|
||||
>
|
||||
<Bell />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
217
src/components/NotificationList/index.tsx
Normal file
217
src/components/NotificationList/index.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import { useFetchEvent } from '@/hooks'
|
||||
import { toNote } from '@/lib/link'
|
||||
import { tagNameEquals } from '@/lib/tag'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import client from '@/services/client.service'
|
||||
import dayjs from 'dayjs'
|
||||
import { Heart, MessageCircle, Repeat } from 'lucide-react'
|
||||
import { Event, kinds, nip19, validateEvent } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
|
||||
const LIMIT = 50
|
||||
|
||||
export default function NotificationList() {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey } = useNostr()
|
||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const [notifications, setNotifications] = useState<Event[]>([])
|
||||
const [until, setUntil] = useState<number | undefined>(dayjs().unix())
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!pubkey) {
|
||||
setUntil(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const init = async () => {
|
||||
const relayList = await client.fetchRelayList(pubkey)
|
||||
const { closer, timelineKey } = await client.subscribeTimeline(
|
||||
relayList.read.length >= 4
|
||||
? relayList.read
|
||||
: relayList.read.concat(client.getDefaultRelayUrls()).slice(0, 4),
|
||||
{
|
||||
'#p': [pubkey],
|
||||
kinds: [kinds.ShortTextNote, kinds.Repost, kinds.Reaction],
|
||||
limit: LIMIT
|
||||
},
|
||||
{
|
||||
onEvents: (events, eosed) => {
|
||||
setNotifications(events.filter((event) => event.pubkey !== pubkey))
|
||||
setUntil(events.length >= LIMIT ? events[events.length - 1].created_at - 1 : undefined)
|
||||
if (eosed) {
|
||||
setInitialized(true)
|
||||
}
|
||||
},
|
||||
onNew: (event) => {
|
||||
if (event.pubkey === pubkey) return
|
||||
setNotifications((oldEvents) => [event, ...oldEvents])
|
||||
}
|
||||
}
|
||||
)
|
||||
setTimelineKey(timelineKey)
|
||||
return closer
|
||||
}
|
||||
|
||||
const promise = init()
|
||||
return () => {
|
||||
promise.then((closer) => closer?.())
|
||||
}
|
||||
}, [pubkey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized) return
|
||||
|
||||
const options = {
|
||||
root: null,
|
||||
rootMargin: '10px',
|
||||
threshold: 1
|
||||
}
|
||||
|
||||
const observerInstance = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
loadMore()
|
||||
}
|
||||
}, options)
|
||||
|
||||
const currentBottomRef = bottomRef.current
|
||||
|
||||
if (currentBottomRef) {
|
||||
observerInstance.observe(currentBottomRef)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observerInstance && currentBottomRef) {
|
||||
observerInstance.unobserve(currentBottomRef)
|
||||
}
|
||||
}
|
||||
}, [until, initialized, timelineKey])
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!pubkey || !timelineKey || !until) return
|
||||
const notifications = await client.loadMoreTimeline(timelineKey, until, LIMIT)
|
||||
if (notifications.length === 0) {
|
||||
setUntil(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (notifications.length > 0) {
|
||||
setNotifications((oldNotifications) => [...oldNotifications, ...notifications])
|
||||
}
|
||||
|
||||
setUntil(notifications[notifications.length - 1].created_at - 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{notifications.map((notification) => (
|
||||
<NotificationItem key={notification.id} notification={notification} />
|
||||
))}
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
{until ? <div ref={bottomRef}>{t('loading...')}</div> : t('no more notifications')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationItem({ notification }: { notification: Event }) {
|
||||
if (notification.kind === kinds.Reaction) {
|
||||
return <ReactionNotification notification={notification} />
|
||||
}
|
||||
if (notification.kind === kinds.ShortTextNote) {
|
||||
return <ReplyNotification notification={notification} />
|
||||
}
|
||||
if (notification.kind === kinds.Repost) {
|
||||
return <RepostNotification notification={notification} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function ReactionNotification({ notification }: { notification: Event }) {
|
||||
const { push } = useSecondaryPage()
|
||||
const bech32Id = useMemo(() => {
|
||||
const eTag = notification.tags.findLast(tagNameEquals('e'))
|
||||
const pTag = notification.tags.find(tagNameEquals('p'))
|
||||
const eventId = eTag?.[1]
|
||||
const author = pTag?.[1]
|
||||
return eventId
|
||||
? nip19.neventEncode(author ? { id: eventId, author } : { id: eventId })
|
||||
: undefined
|
||||
}, [notification])
|
||||
const { event } = useFetchEvent(bech32Id)
|
||||
if (!event || !bech32Id || event.kind !== kinds.ShortTextNote) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer py-2"
|
||||
onClick={() => push(toNote(bech32Id))}
|
||||
>
|
||||
<div className="flex gap-2 items-center flex-1">
|
||||
<UserAvatar userId={notification.pubkey} size="small" />
|
||||
<Heart size={24} className="text-red-400" />
|
||||
<ContentPreview event={event} />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReplyNotification({ notification }: { notification: Event }) {
|
||||
const { push } = useSecondaryPage()
|
||||
return (
|
||||
<div
|
||||
className="flex gap-2 items-center cursor-pointer py-2"
|
||||
onClick={() => push(toNote(notification))}
|
||||
>
|
||||
<UserAvatar userId={notification.pubkey} size="small" />
|
||||
<MessageCircle size={24} className="text-blue-400" />
|
||||
<ContentPreview event={notification} />
|
||||
<div className="text-muted-foreground">
|
||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RepostNotification({ notification }: { notification: Event }) {
|
||||
const { push } = useSecondaryPage()
|
||||
const event = useMemo(() => {
|
||||
try {
|
||||
const event = JSON.parse(notification.content) as Event
|
||||
const isValid = validateEvent(event)
|
||||
if (!isValid) return null
|
||||
client.addEventToCache(event)
|
||||
return event
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [notification.content])
|
||||
if (!event) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex gap-2 items-center cursor-pointer py-2"
|
||||
onClick={() => push(toNote(event))}
|
||||
>
|
||||
<UserAvatar userId={notification.pubkey} size="small" />
|
||||
<Repeat size={24} className="text-green-400" />
|
||||
<ContentPreview event={event} />
|
||||
<div className="text-muted-foreground">
|
||||
<FormattedTimestamp timestamp={notification.created_at} short />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContentPreview({ event }: { event?: Event }) {
|
||||
if (!event || event.kind !== kinds.ShortTextNote) return null
|
||||
|
||||
return <div className="truncate flex-1 w-0">{event.content}</div>
|
||||
}
|
||||
18
src/components/NsfwOverlay/index.tsx
Normal file
18
src/components/NsfwOverlay/index.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function NsfwOverlay({ className }: { className?: string }) {
|
||||
const [isHidden, setIsHidden] = useState(true)
|
||||
|
||||
return (
|
||||
isHidden && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0 left-0 backdrop-blur-3xl w-full h-full cursor-pointer',
|
||||
className
|
||||
)}
|
||||
onClick={() => setIsHidden(false)}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
29
src/components/ParentNotePreview/index.tsx
Normal file
29
src/components/ParentNotePreview/index.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
|
||||
export default function ParentNotePreview({
|
||||
event,
|
||||
className,
|
||||
onClick
|
||||
}: {
|
||||
event: Event
|
||||
className?: string
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement> | undefined
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex space-x-1 items-center text-sm rounded-full px-2 bg-muted w-fit max-w-full text-muted-foreground hover:text-foreground cursor-pointer',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="shrink-0">{t('reply to')}</div>
|
||||
<UserAvatar userId={event.pubkey} size="tiny" />
|
||||
<div className="truncate">{event.content}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
src/components/PostButton/index.tsx
Normal file
32
src/components/PostButton/index.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import PostDialog from '@/components/PostDialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { PencilLine } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function PostButton({
|
||||
variant = 'titlebar'
|
||||
}: {
|
||||
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant={variant}
|
||||
size={variant}
|
||||
title={t('New post')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
<PencilLine />
|
||||
{variant === 'sidebar' && <div>{t('Post')}</div>}
|
||||
</Button>
|
||||
<PostDialog open={open} setOpen={setOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
57
src/components/PostDialog/Metions.tsx
Normal file
57
src/components/PostDialog/Metions.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { extractMentions } from '@/lib/event'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function Mentions({
|
||||
content,
|
||||
parentEvent
|
||||
}: {
|
||||
content: string
|
||||
parentEvent?: Event
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey } = useNostr()
|
||||
const [pubkeys, setPubkeys] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
extractMentions(content, parentEvent).then(({ pubkeys }) =>
|
||||
setPubkeys(pubkeys.filter((p) => p !== pubkey))
|
||||
)
|
||||
}, [content, parentEvent, pubkey])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className="px-3"
|
||||
variant="ghost"
|
||||
disabled={pubkeys.length === 0}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t('Mentions')} {pubkeys.length > 0 && `(${pubkeys.length})`}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-semibold">{t('Mentions')}:</div>
|
||||
{pubkeys.map((pubkey, index) => (
|
||||
<div key={`${pubkey}-${index}`} className="flex gap-1 items-center">
|
||||
<UserAvatar userId={pubkey} size="small" />
|
||||
<Username
|
||||
userId={pubkey}
|
||||
className="font-semibold text-sm truncate"
|
||||
skeletonClassName="h-3"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
21
src/components/PostDialog/Preview.tsx
Normal file
21
src/components/PostDialog/Preview.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { Card } from '@/components/ui/card'
|
||||
import dayjs from 'dayjs'
|
||||
import Content from '../Content'
|
||||
|
||||
export default function Preview({ content }: { content: string }) {
|
||||
return (
|
||||
<Card className="p-3">
|
||||
<Content
|
||||
event={{
|
||||
content,
|
||||
kind: 1,
|
||||
tags: [],
|
||||
created_at: dayjs().unix(),
|
||||
id: '',
|
||||
pubkey: '',
|
||||
sig: ''
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
82
src/components/PostDialog/Uploader.tsx
Normal file
82
src/components/PostDialog/Uploader.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { ImageUp, LoaderCircle } from 'lucide-react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
|
||||
export default function Uploader({
|
||||
setContent
|
||||
}: {
|
||||
setContent: React.Dispatch<React.SetStateAction<string>>
|
||||
}) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const { signHttpAuth } = useNostr()
|
||||
const { toast } = useToast()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
setUploading(true)
|
||||
const url = 'https://nostr.build/api/v2/nip96/upload'
|
||||
const auth = await signHttpAuth(url, 'POST')
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
Authorization: auth
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.status.toString())
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const tags = z.array(z.array(z.string())).parse(data.nip94_event?.tags ?? [])
|
||||
const imageUrl = tags.find(([tagName]) => tagName === 'url')?.[1]
|
||||
if (imageUrl) {
|
||||
setContent((prevContent) => `${prevContent}\n${imageUrl}`)
|
||||
} else {
|
||||
throw new Error('No image url found')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading file', error)
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Failed to upload file',
|
||||
description: (error as Error).message
|
||||
})
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="secondary" onClick={handleUploadClick} disabled={uploading}>
|
||||
{uploading ? <LoaderCircle className="animate-spin" /> : <ImageUp />}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*,video/*,audio/*"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
143
src/components/PostDialog/index.tsx
Normal file
143
src/components/PostDialog/index.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useToast } from '@/hooks/use-toast'
|
||||
import { createShortTextNoteDraftEvent } from '@/lib/draft-event'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { LoaderCircle } from 'lucide-react'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { Dispatch, useState } from 'react'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Mentions from './Metions'
|
||||
import Preview from './Preview'
|
||||
import Uploader from './Uploader'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function PostDialog({
|
||||
defaultContent = '',
|
||||
parentEvent,
|
||||
open,
|
||||
setOpen
|
||||
}: {
|
||||
defaultContent?: string
|
||||
parentEvent?: Event
|
||||
open: boolean
|
||||
setOpen: Dispatch<boolean>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { toast } = useToast()
|
||||
const { publish, checkLogin } = useNostr()
|
||||
const [content, setContent] = useState(defaultContent)
|
||||
const [posting, setPosting] = useState(false)
|
||||
const canPost = !!content && !posting
|
||||
|
||||
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setContent(e.target.value)
|
||||
}
|
||||
|
||||
const post = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
checkLogin(async () => {
|
||||
if (!canPost) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
setPosting(true)
|
||||
try {
|
||||
const additionalRelayUrls: string[] = []
|
||||
if (parentEvent) {
|
||||
const relayList = await client.fetchRelayList(parentEvent.pubkey)
|
||||
additionalRelayUrls.push(...relayList.read.slice(0, 5))
|
||||
}
|
||||
const draftEvent = await createShortTextNoteDraftEvent(content, parentEvent)
|
||||
await publish(draftEvent, additionalRelayUrls)
|
||||
setContent('')
|
||||
setOpen(false)
|
||||
} catch (error) {
|
||||
if (error instanceof AggregateError) {
|
||||
error.errors.forEach((e) =>
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('Failed to post'),
|
||||
description: e.message
|
||||
})
|
||||
)
|
||||
} else if (error instanceof Error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('Failed to post'),
|
||||
description: error.message
|
||||
})
|
||||
}
|
||||
console.error(error)
|
||||
return
|
||||
} finally {
|
||||
setPosting(false)
|
||||
}
|
||||
toast({
|
||||
title: t('Post successful'),
|
||||
description: t('Your post has been published')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="p-0" withoutClose>
|
||||
<ScrollArea className="px-4 h-full max-h-screen">
|
||||
<div className="space-y-4 px-2 py-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{parentEvent ? (
|
||||
<div className="flex gap-2 items-center max-w-full">
|
||||
<div className="shrink-0">{t('Reply to')}</div>
|
||||
<UserAvatar userId={parentEvent.pubkey} size="tiny" />
|
||||
<div className="truncate">{parentEvent.content}</div>
|
||||
</div>
|
||||
) : (
|
||||
t('New post')
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="hidden" />
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
className="h-32"
|
||||
onChange={handleTextareaChange}
|
||||
value={content}
|
||||
placeholder={t('Write something...')}
|
||||
/>
|
||||
{content && <Preview content={content} />}
|
||||
<div className="flex items-center justify-between">
|
||||
<Uploader setContent={setContent} />
|
||||
<div className="flex gap-2">
|
||||
<Mentions content={content} parentEvent={parentEvent} />
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canPost} onClick={post}>
|
||||
{posting && <LoaderCircle className="animate-spin" />}
|
||||
{parentEvent ? t('Reply') : t('Post')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
25
src/components/ProfileAbout/index.tsx
Normal file
25
src/components/ProfileAbout/index.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { useMemo } from 'react'
|
||||
import {
|
||||
embedded,
|
||||
embeddedHashtagRenderer,
|
||||
embeddedNormalUrlRenderer,
|
||||
embeddedNostrNpubRenderer,
|
||||
embeddedNpubRenderer,
|
||||
embeddedWebsocketUrlRenderer
|
||||
} from '../Embedded'
|
||||
|
||||
export default function ProfileAbout({ about, className }: { about?: string; className?: string }) {
|
||||
const nodes = useMemo(() => {
|
||||
return about
|
||||
? embedded(about, [
|
||||
embeddedWebsocketUrlRenderer,
|
||||
embeddedNormalUrlRenderer,
|
||||
embeddedHashtagRenderer,
|
||||
embeddedNostrNpubRenderer,
|
||||
embeddedNpubRenderer
|
||||
])
|
||||
: null
|
||||
}, [about])
|
||||
|
||||
return <div className={className}>{nodes}</div>
|
||||
}
|
||||
35
src/components/ProfileBanner/index.tsx
Normal file
35
src/components/ProfileBanner/index.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Image } from '@nextui-org/image'
|
||||
import { generateImageByPubkey } from '@/lib/pubkey'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
export default function ProfileBanner({
|
||||
pubkey,
|
||||
banner,
|
||||
className
|
||||
}: {
|
||||
pubkey: string
|
||||
banner?: string
|
||||
className?: string
|
||||
}) {
|
||||
const defaultBanner = useMemo(() => generateImageByPubkey(pubkey), [pubkey])
|
||||
const [bannerUrl, setBannerUrl] = useState(banner || defaultBanner)
|
||||
|
||||
useEffect(() => {
|
||||
if (banner) {
|
||||
setBannerUrl(banner)
|
||||
} else {
|
||||
setBannerUrl(defaultBanner)
|
||||
}
|
||||
}, [defaultBanner, banner])
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={bannerUrl}
|
||||
alt={`${pubkey} banner`}
|
||||
className={cn('z-0', className)}
|
||||
onError={() => setBannerUrl(defaultBanner)}
|
||||
removeWrapper
|
||||
/>
|
||||
)
|
||||
}
|
||||
41
src/components/ProfileCard/index.tsx
Normal file
41
src/components/ProfileCard/index.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { useFetchProfile } from '@/hooks'
|
||||
import { generateImageByPubkey } from '@/lib/pubkey'
|
||||
import { useMemo } from 'react'
|
||||
import FollowButton from '../FollowButton'
|
||||
import Nip05 from '../Nip05'
|
||||
import ProfileAbout from '../ProfileAbout'
|
||||
|
||||
export default function ProfileCard({ pubkey }: { pubkey: string }) {
|
||||
const { profile } = useFetchProfile(pubkey)
|
||||
const defaultImage = useMemo(() => generateImageByPubkey(pubkey), [pubkey])
|
||||
|
||||
if (!profile) return null
|
||||
const { avatar = '', username, nip05, about } = profile
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<div className="flex space-x-2 w-full items-start justify-between">
|
||||
<Avatar className="w-12 h-12">
|
||||
<AvatarImage className="object-cover object-center" src={avatar} />
|
||||
<AvatarFallback>
|
||||
<img src={defaultImage} alt={pubkey} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<FollowButton pubkey={pubkey} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold truncate">{username}</div>
|
||||
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} />}
|
||||
</div>
|
||||
{about && (
|
||||
<div
|
||||
className="text-sm text-wrap break-words w-full overflow-hidden text-ellipsis"
|
||||
style={{ display: '-webkit-box', WebkitLineClamp: 6, WebkitBoxOrient: 'vertical' }}
|
||||
>
|
||||
<ProfileAbout about={about} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
src/components/RefreshButton/index.tsx
Normal file
19
src/components/RefreshButton/index.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { usePrimaryPage } from '@/PageManager'
|
||||
import { RefreshCcw } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function RefreshButton({
|
||||
variant = 'titlebar'
|
||||
}: {
|
||||
variant?: 'titlebar' | 'sidebar'
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { refresh } = usePrimaryPage()
|
||||
return (
|
||||
<Button variant={variant} size={variant} onClick={refresh} title={t('Refresh')}>
|
||||
<RefreshCcw />
|
||||
{variant === 'sidebar' && <div>{t('Refresh')}</div>}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
195
src/components/RelaySettings/RelayGroup.tsx
Normal file
195
src/components/RelaySettings/RelayGroup.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
||||
import { Check, ChevronDown, Circle, CircleCheck, EllipsisVertical } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import RelayUrls from './RelayUrl'
|
||||
import { useRelaySettingsComponent } from './provider'
|
||||
import { TRelayGroup } from './types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function RelayGroup({ group }: { group: TRelayGroup }) {
|
||||
const { t } = useTranslation()
|
||||
const { expandedRelayGroup } = useRelaySettingsComponent()
|
||||
const { temporaryRelayUrls } = useRelaySettings()
|
||||
const { groupName, relayUrls } = group
|
||||
const isActive = temporaryRelayUrls.length === 0 && group.isActive
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full border rounded-lg p-4 ${isActive ? 'border-highlight bg-highlight/5' : ''}`}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex space-x-2 items-center">
|
||||
<RelayGroupActiveToggle
|
||||
groupName={groupName}
|
||||
isActive={isActive}
|
||||
canActive={relayUrls.length > 0}
|
||||
/>
|
||||
<RelayGroupName groupName={groupName} />
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<RelayUrlsExpandToggle groupName={groupName}>
|
||||
{t('n relays', { n: relayUrls.length })}
|
||||
</RelayUrlsExpandToggle>
|
||||
<RelayGroupOptions group={group} />
|
||||
</div>
|
||||
</div>
|
||||
{expandedRelayGroup === groupName && <RelayUrls groupName={groupName} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RelayGroupActiveToggle({
|
||||
groupName,
|
||||
isActive,
|
||||
canActive
|
||||
}: {
|
||||
groupName: string
|
||||
isActive: boolean
|
||||
canActive: boolean
|
||||
}) {
|
||||
const { switchRelayGroup } = useRelaySettings()
|
||||
|
||||
return isActive ? (
|
||||
<CircleCheck size={18} className="text-highlight shrink-0" />
|
||||
) : (
|
||||
<Circle
|
||||
size={18}
|
||||
className={`text-muted-foreground shrink-0 ${canActive ? 'cursor-pointer hover:text-foreground ' : ''}`}
|
||||
onClick={() => {
|
||||
if (canActive) {
|
||||
switchRelayGroup(groupName)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RelayGroupName({ groupName }: { groupName: string }) {
|
||||
const { t } = useTranslation()
|
||||
const [newGroupName, setNewGroupName] = useState(groupName)
|
||||
const [newNameError, setNewNameError] = useState<string | null>(null)
|
||||
const { relayGroups, switchRelayGroup, renameRelayGroup } = useRelaySettings()
|
||||
const { renamingGroup, setRenamingGroup } = useRelaySettingsComponent()
|
||||
|
||||
const hasRelayUrls = relayGroups.find((group) => group.groupName === groupName)?.relayUrls.length
|
||||
|
||||
const saveNewGroupName = () => {
|
||||
if (groupName === newGroupName) {
|
||||
return setRenamingGroup(null)
|
||||
}
|
||||
if (relayGroups.find((group) => group.groupName === newGroupName)) {
|
||||
return setNewNameError(t('relay collection name already exists'))
|
||||
}
|
||||
const errMsg = renameRelayGroup(groupName, newGroupName)
|
||||
if (errMsg) {
|
||||
setNewNameError(errMsg)
|
||||
return
|
||||
}
|
||||
setRenamingGroup(null)
|
||||
}
|
||||
|
||||
const handleRenameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNewGroupName(e.target.value)
|
||||
setNewNameError(null)
|
||||
}
|
||||
|
||||
const handleRenameInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
saveNewGroupName()
|
||||
}
|
||||
}
|
||||
|
||||
return renamingGroup === groupName ? (
|
||||
<div className="flex gap-1 items-center">
|
||||
<Input
|
||||
value={newGroupName}
|
||||
onChange={handleRenameInputChange}
|
||||
onBlur={saveNewGroupName}
|
||||
onKeyDown={handleRenameInputKeyDown}
|
||||
className={`font-semibold w-28 ${newNameError ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
<Button variant="ghost" size="icon" onClick={saveNewGroupName}>
|
||||
<Check size={18} className="text-green-500" />
|
||||
</Button>
|
||||
{newNameError && <div className="text-xs text-destructive">{newNameError}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`h-8 font-semibold flex items-center ${hasRelayUrls ? 'cursor-pointer' : 'text-muted-foreground'}`}
|
||||
onClick={() => {
|
||||
if (hasRelayUrls) {
|
||||
switchRelayGroup(groupName)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{groupName}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RelayUrlsExpandToggle({
|
||||
groupName,
|
||||
children
|
||||
}: {
|
||||
groupName: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { expandedRelayGroup, setExpandedRelayGroup } = useRelaySettingsComponent()
|
||||
return (
|
||||
<div
|
||||
className="text-sm text-muted-foreground flex items-center gap-1 cursor-pointer hover:text-foreground"
|
||||
onClick={() => setExpandedRelayGroup((pre) => (pre === groupName ? null : groupName))}
|
||||
>
|
||||
<div className="select-none">{children}</div>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`transition-transform duration-200 ${expandedRelayGroup === groupName ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RelayGroupOptions({ group }: { group: TRelayGroup }) {
|
||||
const { t } = useTranslation()
|
||||
const { deleteRelayGroup } = useRelaySettings()
|
||||
const { setRenamingGroup } = useRelaySettingsComponent()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => setRenamingGroup(group.groupName)}>
|
||||
{t('Rename')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`https://jumble.social/?${group.relayUrls.map((url) => 'r=' + url).join('&')}`
|
||||
)
|
||||
}}
|
||||
>
|
||||
{t('Copy share link')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => deleteRelayGroup(group.groupName)}
|
||||
>
|
||||
{t('Delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
151
src/components/RelaySettings/RelayUrl.tsx
Normal file
151
src/components/RelaySettings/RelayUrl.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useFetchRelayInfos } from '@/hooks'
|
||||
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
|
||||
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { CircleX, SearchCheck } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function RelayUrls({ groupName }: { groupName: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { relayGroups, updateRelayGroupRelayUrls } = useRelaySettings()
|
||||
const isActive = useMemo(
|
||||
() => relayGroups.find((group) => group.groupName === groupName)?.isActive ?? false,
|
||||
[relayGroups, groupName]
|
||||
)
|
||||
const [newRelayUrl, setNewRelayUrl] = useState('')
|
||||
const [newRelayUrlError, setNewRelayUrlError] = useState<string | null>(null)
|
||||
const [relays, setRelays] = useState<
|
||||
{
|
||||
url: string
|
||||
isConnected: boolean
|
||||
}[]
|
||||
>(
|
||||
relayGroups
|
||||
.find((group) => group.groupName === groupName)
|
||||
?.relayUrls.map((url) => ({ url, isConnected: false })) ?? []
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const connectionStatusMap = client.listConnectionStatus()
|
||||
setRelays((pre) => {
|
||||
return pre.map((relay) => {
|
||||
const isConnected = connectionStatusMap.get(relay.url) || false
|
||||
return { ...relay, isConnected }
|
||||
})
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const removeRelayUrl = (url: string) => {
|
||||
setRelays((relays) => relays.filter((relay) => relay.url !== url))
|
||||
updateRelayGroupRelayUrls(
|
||||
groupName,
|
||||
relays.map(({ url }) => url).filter((u) => u !== url)
|
||||
)
|
||||
}
|
||||
|
||||
const saveNewRelayUrl = () => {
|
||||
if (newRelayUrl === '') return
|
||||
const normalizedUrl = normalizeUrl(newRelayUrl)
|
||||
if (relays.some(({ url }) => url === normalizedUrl)) {
|
||||
return setNewRelayUrlError(t('Relay already exists'))
|
||||
}
|
||||
if (!isWebsocketUrl(normalizedUrl)) {
|
||||
return setNewRelayUrlError(t('invalid relay URL'))
|
||||
}
|
||||
setRelays((pre) => [...pre, { url: normalizedUrl, isConnected: false }])
|
||||
const newRelayUrls = [...relays.map(({ url }) => url), normalizedUrl]
|
||||
updateRelayGroupRelayUrls(groupName, newRelayUrls)
|
||||
setNewRelayUrl('')
|
||||
}
|
||||
|
||||
const handleRelayUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNewRelayUrl(e.target.value)
|
||||
setNewRelayUrlError(null)
|
||||
}
|
||||
|
||||
const handleRelayUrlInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
saveNewRelayUrl()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-1">
|
||||
{relays.map(({ url, isConnected: isConnected }, index) => (
|
||||
<RelayUrl
|
||||
key={index}
|
||||
isActive={isActive}
|
||||
url={url}
|
||||
isConnected={isConnected}
|
||||
onRemove={() => removeRelayUrl(url)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Input
|
||||
className={newRelayUrlError ? 'border-destructive' : ''}
|
||||
placeholder={t('Add a new relay')}
|
||||
value={newRelayUrl}
|
||||
onKeyDown={handleRelayUrlInputKeyDown}
|
||||
onChange={handleRelayUrlInputChange}
|
||||
onBlur={saveNewRelayUrl}
|
||||
/>
|
||||
<Button onClick={saveNewRelayUrl}>{t('Add')}</Button>
|
||||
</div>
|
||||
{newRelayUrlError && <div className="text-xs text-destructive mt-1">{newRelayUrlError}</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RelayUrl({
|
||||
isActive,
|
||||
url,
|
||||
isConnected,
|
||||
onRemove
|
||||
}: {
|
||||
isActive: boolean
|
||||
url: string
|
||||
isConnected: boolean
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
relayInfos: [relayInfo]
|
||||
} = useFetchRelayInfos([url])
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2 items-center">
|
||||
{!isActive ? (
|
||||
<div className="text-muted-foreground text-xs">●</div>
|
||||
) : isConnected ? (
|
||||
<div className="text-green-500 text-xs">●</div>
|
||||
) : (
|
||||
<div className="text-red-500 text-xs">●</div>
|
||||
)}
|
||||
<div className="text-muted-foreground text-sm">{url}</div>
|
||||
{relayInfo?.supported_nips?.includes(50) && (
|
||||
<div title={t('supports search')} className="text-highlight">
|
||||
<SearchCheck size={14} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<CircleX
|
||||
size={16}
|
||||
onClick={onRemove}
|
||||
className="text-muted-foreground hover:text-destructive cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
src/components/RelaySettings/TemporaryRelayGroup.tsx
Normal file
81
src/components/RelaySettings/TemporaryRelayGroup.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { useFetchRelayInfos } from '@/hooks'
|
||||
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { Save, SearchCheck } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function TemporaryRelayGroup() {
|
||||
const { t } = useTranslation()
|
||||
const { temporaryRelayUrls, relayGroups, addRelayGroup, switchRelayGroup } = useRelaySettings()
|
||||
const [relays, setRelays] = useState<
|
||||
{
|
||||
url: string
|
||||
isConnected: boolean
|
||||
}[]
|
||||
>(temporaryRelayUrls.map((url) => ({ url, isConnected: false })))
|
||||
const { relayInfos } = useFetchRelayInfos(relays.map((relay) => relay.url))
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const connectionStatusMap = client.listConnectionStatus()
|
||||
setRelays((pre) => {
|
||||
return pre.map((relay) => {
|
||||
const isConnected = connectionStatusMap.get(relay.url) || false
|
||||
return { ...relay, isConnected }
|
||||
})
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setRelays(temporaryRelayUrls.map((url) => ({ url, isConnected: false })))
|
||||
}, [temporaryRelayUrls])
|
||||
|
||||
if (!relays.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
const existingTemporaryIndexes = relayGroups
|
||||
.filter((group) => /^Temporary \d+$/.test(group.groupName))
|
||||
.map((group) => group.groupName.split(' ')[1])
|
||||
.map(Number)
|
||||
.filter((index) => !isNaN(index))
|
||||
const nextIndex = Math.max(...existingTemporaryIndexes, 0) + 1
|
||||
const groupName = `Temporary ${nextIndex}`
|
||||
addRelayGroup(groupName, temporaryRelayUrls)
|
||||
switchRelayGroup(groupName)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`w-full border border-dashed rounded-lg p-4 border-highlight bg-highlight/5`}>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="h-8 font-semibold">Temporary</div>
|
||||
<Button title="save" size="icon" variant="ghost" onClick={handleSave}>
|
||||
<Save />
|
||||
</Button>
|
||||
</div>
|
||||
{relays.map((relay, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<div className="flex gap-2 items-center">
|
||||
{relay.isConnected ? (
|
||||
<div className="text-green-500 text-xs">●</div>
|
||||
) : (
|
||||
<div className="text-red-500 text-xs">●</div>
|
||||
)}
|
||||
<div className="text-muted-foreground text-sm">{relay.url}</div>
|
||||
{relayInfos[index]?.supported_nips?.includes(50) && (
|
||||
<div title={t('supports search')} className="text-highlight">
|
||||
<SearchCheck size={14} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
src/components/RelaySettings/index.tsx
Normal file
81
src/components/RelaySettings/index.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { RelaySettingsComponentProvider } from './provider'
|
||||
import RelayGroup from './RelayGroup'
|
||||
import TemporaryRelayGroup from './TemporaryRelayGroup'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function RelaySettings({ hideTitle = false }: { hideTitle?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const { relayGroups, addRelayGroup } = useRelaySettings()
|
||||
const [newGroupName, setNewGroupName] = useState('')
|
||||
const [newNameError, setNewNameError] = useState<string | null>(null)
|
||||
const dummyRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (dummyRef.current) {
|
||||
dummyRef.current.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const saveRelayGroup = () => {
|
||||
if (relayGroups.find((group) => group.groupName === newGroupName)) {
|
||||
return setNewNameError(t('relay collection name already exists'))
|
||||
}
|
||||
const errMsg = addRelayGroup(newGroupName)
|
||||
if (errMsg) {
|
||||
return setNewNameError(errMsg)
|
||||
}
|
||||
setNewGroupName('')
|
||||
}
|
||||
|
||||
const handleNewGroupNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNewGroupName(e.target.value)
|
||||
setNewNameError(null)
|
||||
}
|
||||
|
||||
const handleNewGroupNameKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
saveRelayGroup()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<RelaySettingsComponentProvider>
|
||||
<div ref={dummyRef} tabIndex={-1} style={{ position: 'absolute', opacity: 0 }}></div>
|
||||
{!hideTitle && <div className="text-lg font-semibold mb-4">{t('Relay Settings')}</div>}
|
||||
<div className="space-y-2">
|
||||
<TemporaryRelayGroup />
|
||||
{relayGroups.map((group, index) => (
|
||||
<RelayGroup key={index} group={group} />
|
||||
))}
|
||||
</div>
|
||||
{relayGroups.length < 5 && (
|
||||
<>
|
||||
<Separator className="my-4" />
|
||||
<div className="w-full border rounded-lg p-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="font-semibold">{t('Add a new relay collection')}</div>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Input
|
||||
className={newNameError ? 'border-destructive' : ''}
|
||||
placeholder={t('Relay collection name')}
|
||||
value={newGroupName}
|
||||
onChange={handleNewGroupNameChange}
|
||||
onKeyDown={handleNewGroupNameKeyDown}
|
||||
onBlur={saveRelayGroup}
|
||||
/>
|
||||
<Button onClick={saveRelayGroup}>{t('Add')}</Button>
|
||||
</div>
|
||||
{newNameError && <div className="text-xs text-destructive mt-1">{newNameError}</div>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</RelaySettingsComponentProvider>
|
||||
)
|
||||
}
|
||||
40
src/components/RelaySettings/provider.tsx
Normal file
40
src/components/RelaySettings/provider.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { createContext, useContext, useState } from 'react'
|
||||
|
||||
type TRelaySettingsComponentContext = {
|
||||
renamingGroup: string | null
|
||||
setRenamingGroup: React.Dispatch<React.SetStateAction<string | null>>
|
||||
expandedRelayGroup: string | null
|
||||
setExpandedRelayGroup: React.Dispatch<React.SetStateAction<string | null>>
|
||||
}
|
||||
|
||||
export const RelaySettingsComponentContext = createContext<
|
||||
TRelaySettingsComponentContext | undefined
|
||||
>(undefined)
|
||||
|
||||
export const useRelaySettingsComponent = () => {
|
||||
const context = useContext(RelaySettingsComponentContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useRelaySettingsComponent must be used within a RelaySettingsComponentProvider'
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function RelaySettingsComponentProvider({ children }: { children: React.ReactNode }) {
|
||||
const [renamingGroup, setRenamingGroup] = useState<string | null>(null)
|
||||
const [expandedRelayGroup, setExpandedRelayGroup] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<RelaySettingsComponentContext.Provider
|
||||
value={{
|
||||
renamingGroup,
|
||||
setRenamingGroup,
|
||||
expandedRelayGroup,
|
||||
setExpandedRelayGroup
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RelaySettingsComponentContext.Provider>
|
||||
)
|
||||
}
|
||||
5
src/components/RelaySettings/types.ts
Normal file
5
src/components/RelaySettings/types.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export type TRelayGroup = {
|
||||
groupName: string
|
||||
relayUrls: string[]
|
||||
isActive: boolean
|
||||
}
|
||||
50
src/components/RelaySettingsButton/index.tsx
Normal file
50
src/components/RelaySettingsButton/index.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import RelaySettings from '@/components/RelaySettings'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { toRelaySettings } from '@/lib/link'
|
||||
import { SecondaryPageLink } from '@/PageManager'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { Server } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function RelaySettingsButton({
|
||||
variant = 'titlebar'
|
||||
}: {
|
||||
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<SecondaryPageLink to={toRelaySettings()}>
|
||||
<Button variant={variant} size={variant} title={t('Relay settings')}>
|
||||
<Server />
|
||||
{variant === 'sidebar' && <div>{t('SidebarRelays')}</div>}
|
||||
</Button>
|
||||
</SecondaryPageLink>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={variant} size={variant} title={t('Relay settings')}>
|
||||
<Server />
|
||||
{variant === 'sidebar' && <div>{t('SidebarRelays')}</div>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-96 h-[450px] p-0"
|
||||
side={variant === 'titlebar' ? 'bottom' : 'right'}
|
||||
>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
<RelaySettings />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
57
src/components/ReplyNote/index.tsx
Normal file
57
src/components/ReplyNote/index.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { Event } from 'nostr-tools'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Content from '../Content'
|
||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||
import LikeButton from '../NoteStats/LikeButton'
|
||||
import ParentNotePreview from '../ParentNotePreview'
|
||||
import PostDialog from '../PostDialog'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
|
||||
export default function ReplyNote({
|
||||
event,
|
||||
parentEvent,
|
||||
onClickParent = () => {},
|
||||
highlight = false
|
||||
}: {
|
||||
event: Event
|
||||
parentEvent?: Event
|
||||
onClickParent?: (eventId: string) => void
|
||||
highlight?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex space-x-2 items-start rounded-lg p-2 transition-colors duration-500 ${highlight ? 'bg-highlight/50' : ''}`}
|
||||
>
|
||||
<UserAvatar userId={event.pubkey} size="small" className="shrink-0" />
|
||||
<div className="w-full overflow-hidden space-y-1">
|
||||
<Username
|
||||
userId={event.pubkey}
|
||||
className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate"
|
||||
skeletonClassName="h-3"
|
||||
/>
|
||||
{parentEvent && (
|
||||
<ParentNotePreview event={parentEvent} onClick={() => onClickParent(parentEvent.id)} />
|
||||
)}
|
||||
<Content event={event} size="small" />
|
||||
<div className="flex gap-2 text-xs">
|
||||
<div className="text-muted-foreground/60">
|
||||
<FormattedTimestamp timestamp={event.created_at} />
|
||||
</div>
|
||||
<div
|
||||
className="text-muted-foreground hover:text-primary cursor-pointer"
|
||||
onClick={() => setIsPostDialogOpen(true)}
|
||||
>
|
||||
{t('reply')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LikeButton event={event} variant="reply" />
|
||||
<PostDialog parentEvent={event} open={isPostDialogOpen} setOpen={setIsPostDialogOpen} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
193
src/components/ReplyNoteList/index.tsx
Normal file
193
src/components/ReplyNoteList/index.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { Separator } from '@/components/ui/separator'
|
||||
import { isReplyNoteEvent } from '@/lib/event'
|
||||
import { isReplyETag, isRootETag } from '@/lib/tag'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import client from '@/services/client.service'
|
||||
import dayjs from 'dayjs'
|
||||
import { Event as NEvent, kinds } from 'nostr-tools'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ReplyNote from '../ReplyNote'
|
||||
|
||||
const LIMIT = 100
|
||||
|
||||
export default function ReplyNoteList({ event, className }: { event: NEvent; className?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey } = useNostr()
|
||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||
const [until, setUntil] = useState<number | undefined>(() => dayjs().unix())
|
||||
const [replies, setReplies] = useState<NEvent[]>([])
|
||||
const [replyMap, setReplyMap] = useState<
|
||||
Record<string, { event: NEvent; level: number; parent?: NEvent } | undefined>
|
||||
>({})
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined)
|
||||
const { updateNoteReplyCount } = useNoteStats()
|
||||
const replyRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
|
||||
useEffect(() => {
|
||||
const handleEventPublished = (data: Event) => {
|
||||
const customEvent = data as CustomEvent<NEvent>
|
||||
const evt = customEvent.detail
|
||||
if (
|
||||
isReplyNoteEvent(evt) &&
|
||||
evt.tags.some(([tagName, tagValue]) => tagName === 'e' && tagValue === event.id)
|
||||
) {
|
||||
onNewReply(evt)
|
||||
}
|
||||
}
|
||||
|
||||
client.addEventListener('eventPublished', handleEventPublished)
|
||||
return () => {
|
||||
client.removeEventListener('eventPublished', handleEventPublished)
|
||||
}
|
||||
}, [event])
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return
|
||||
|
||||
const init = async () => {
|
||||
setLoading(true)
|
||||
setReplies([])
|
||||
|
||||
try {
|
||||
const relayList = await client.fetchRelayList(event.pubkey)
|
||||
const { closer, timelineKey } = await client.subscribeTimeline(
|
||||
relayList.read.slice(0, 5),
|
||||
{
|
||||
'#e': [event.id],
|
||||
kinds: [kinds.ShortTextNote],
|
||||
limit: LIMIT
|
||||
},
|
||||
{
|
||||
onEvents: (evts, eosed) => {
|
||||
setReplies(evts.filter((evt) => isReplyNoteEvent(evt)).reverse())
|
||||
if (eosed) {
|
||||
setLoading(false)
|
||||
setUntil(evts.length >= LIMIT ? evts[evts.length - 1].created_at - 1 : undefined)
|
||||
}
|
||||
},
|
||||
onNew: (evt) => {
|
||||
if (!isReplyNoteEvent(evt)) return
|
||||
onNewReply(evt)
|
||||
}
|
||||
}
|
||||
)
|
||||
setTimelineKey(timelineKey)
|
||||
return closer
|
||||
} catch {
|
||||
setLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const promise = init()
|
||||
return () => {
|
||||
promise.then((closer) => closer?.())
|
||||
}
|
||||
}, [event])
|
||||
|
||||
useEffect(() => {
|
||||
updateNoteReplyCount(event.id, replies.length)
|
||||
|
||||
const replyMap: Record<string, { event: NEvent; level: number; parent?: NEvent } | undefined> =
|
||||
{}
|
||||
for (const reply of replies) {
|
||||
const parentReplyTag = reply.tags.find(isReplyETag)
|
||||
if (parentReplyTag) {
|
||||
const parentReplyInfo = replyMap[parentReplyTag[1]]
|
||||
const level = parentReplyInfo ? parentReplyInfo.level + 1 : 1
|
||||
replyMap[reply.id] = { event: reply, level, parent: parentReplyInfo?.event }
|
||||
continue
|
||||
}
|
||||
|
||||
const rootReplyTag = reply.tags.find(isRootETag)
|
||||
if (rootReplyTag) {
|
||||
replyMap[reply.id] = { event: reply, level: 1 }
|
||||
continue
|
||||
}
|
||||
|
||||
let level = 0
|
||||
let parent: NEvent | undefined
|
||||
for (const [tagName, tagValue] of reply.tags) {
|
||||
if (tagName === 'e') {
|
||||
const info = replyMap[tagValue]
|
||||
if (info && info.level > level) {
|
||||
level = info.level
|
||||
parent = info.event
|
||||
}
|
||||
}
|
||||
}
|
||||
replyMap[reply.id] = { event: reply, level: level + 1, parent }
|
||||
}
|
||||
setReplyMap(replyMap)
|
||||
}, [replies, event.id, updateNoteReplyCount])
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loading || !until || !timelineKey) return
|
||||
|
||||
setLoading(true)
|
||||
const events = await client.loadMoreTimeline(timelineKey, until, LIMIT)
|
||||
const olderReplies = events.filter((evt) => isReplyNoteEvent(evt)).reverse()
|
||||
if (olderReplies.length > 0) {
|
||||
setReplies((pre) => [...olderReplies, ...pre])
|
||||
}
|
||||
setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const onNewReply = (evt: NEvent) => {
|
||||
setReplies((pre) => {
|
||||
if (pre.some((reply) => reply.id === evt.id)) return pre
|
||||
return [...pre, evt]
|
||||
})
|
||||
if (evt.pubkey === pubkey) {
|
||||
setTimeout(() => {
|
||||
highlightReply(evt.id)
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
const highlightReply = (eventId: string) => {
|
||||
const ref = replyRefs.current[eventId]
|
||||
if (ref) {
|
||||
ref.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
setHighlightReplyId(eventId)
|
||||
setTimeout(() => {
|
||||
setHighlightReplyId((pre) => (pre === eventId ? undefined : pre))
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`text-sm text-center text-muted-foreground ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
|
||||
onClick={loadMore}
|
||||
>
|
||||
{loading ? t('loading...') : until ? t('load more older replies') : null}
|
||||
</div>
|
||||
{replies.length > 0 && (loading || until) && <Separator className="my-2" />}
|
||||
<div className={cn('mb-4', className)}>
|
||||
{replies.map((reply) => {
|
||||
const info = replyMap[reply.id]
|
||||
return (
|
||||
<div ref={(el) => (replyRefs.current[reply.id] = el)} key={reply.id}>
|
||||
<ReplyNote
|
||||
event={reply}
|
||||
parentEvent={info?.parent}
|
||||
onClickParent={highlightReply}
|
||||
highlight={highlightReplyId === reply.id}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{replies.length === 0 && !loading && !until && (
|
||||
<div className="text-sm text-center text-muted-foreground">{t('no replies')}</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
30
src/components/ScrollToTopButton/index.tsx
Normal file
30
src/components/ScrollToTopButton/index.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronUp } from 'lucide-react'
|
||||
|
||||
export default function ScrollToTopButton({
|
||||
scrollAreaRef,
|
||||
className,
|
||||
visible = true
|
||||
}: {
|
||||
scrollAreaRef: React.RefObject<HTMLDivElement>
|
||||
className?: string
|
||||
visible?: boolean
|
||||
}) {
|
||||
const handleScrollToTop = () => {
|
||||
scrollAreaRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary-2"
|
||||
className={cn(
|
||||
`absolute bottom-2 right-2 rounded-full w-11 h-11 p-0 hover:text-background transition-transform ${visible ? '' : 'translate-y-14'}`,
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToTop}
|
||||
>
|
||||
<ChevronUp />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
24
src/components/SearchButton/index.tsx
Normal file
24
src/components/SearchButton/index.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SearchDialog } from '../SearchDialog'
|
||||
|
||||
export default function RefreshButton({
|
||||
variant = 'titlebar'
|
||||
}: {
|
||||
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant={variant} size={variant} onClick={() => setOpen(true)} title={t('Search')}>
|
||||
<Search />
|
||||
{variant === 'sidebar' && <div>{t('Search')}</div>}
|
||||
</Button>
|
||||
<SearchDialog open={open} setOpen={setOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
155
src/components/SearchDialog/index.tsx
Normal file
155
src/components/SearchDialog/index.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { SecondaryPageLink } from '@/PageManager'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { CommandDialog, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { useSearchProfiles } from '@/hooks'
|
||||
import { toNote, toNoteList, toProfile, toProfileList } from '@/lib/link'
|
||||
import { generateImageByPubkey } from '@/lib/pubkey'
|
||||
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
||||
import { TProfile } from '@/types'
|
||||
import { Hash, Notebook, UserRound } from 'lucide-react'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { Dispatch, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function SearchDialog({ open, setOpen }: { open: boolean; setOpen: Dispatch<boolean> }) {
|
||||
const { t } = useTranslation()
|
||||
const [input, setInput] = useState('')
|
||||
const [debouncedInput, setDebouncedInput] = useState(input)
|
||||
const { profiles } = useSearchProfiles(debouncedInput, 10)
|
||||
|
||||
const list = useMemo(() => {
|
||||
const search = input.trim()
|
||||
if (!search) return
|
||||
|
||||
if (/^[0-9a-f]{64}$/.test(search)) {
|
||||
return (
|
||||
<>
|
||||
<NoteItem id={search} onClick={() => setOpen(false)} />
|
||||
<ProfileIdItem id={search} onClick={() => setOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
let id = search
|
||||
if (id.startsWith('nostr:')) {
|
||||
id = id.slice(6)
|
||||
}
|
||||
const { type } = nip19.decode(id)
|
||||
if (['nprofile', 'npub'].includes(type)) {
|
||||
return <ProfileIdItem id={id} onClick={() => setOpen(false)} />
|
||||
}
|
||||
if (['nevent', 'naddr', 'note'].includes(type)) {
|
||||
return <NoteItem id={id} onClick={() => setOpen(false)} />
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NormalItem search={search} onClick={() => setOpen(false)} />
|
||||
<HashtagItem search={search} onClick={() => setOpen(false)} />
|
||||
{profiles.map((profile) => (
|
||||
<ProfileItem key={profile.pubkey} profile={profile} onClick={() => setOpen(false)} />
|
||||
))}
|
||||
{profiles.length >= 10 && (
|
||||
<SecondaryPageLink to={toProfileList({ search })} onClick={() => setOpen(false)}>
|
||||
<CommandItem onClick={() => setOpen(false)} className="text-center">
|
||||
<div className="font-semibold">{t('Show more...')}</div>
|
||||
</CommandItem>
|
||||
</SecondaryPageLink>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}, [input, profiles, setOpen])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedInput(input)
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler)
|
||||
}
|
||||
}, [input])
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={setOpen} classNames={{ content: 'max-sm:top-0' }}>
|
||||
<CommandInput value={input} onValueChange={setInput} />
|
||||
<CommandList>{list}</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
|
||||
function NormalItem({ search, onClick }: { search: string; onClick?: () => void }) {
|
||||
const { searchableRelayUrls } = useRelaySettings()
|
||||
|
||||
if (searchableRelayUrls.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryPageLink to={toNoteList({ search })} onClick={onClick}>
|
||||
<CommandItem>
|
||||
<Notebook className="text-muted-foreground" />
|
||||
<div className="font-semibold">{search}</div>
|
||||
</CommandItem>
|
||||
</SecondaryPageLink>
|
||||
)
|
||||
}
|
||||
|
||||
function HashtagItem({ search, onClick }: { search: string; onClick?: () => void }) {
|
||||
const hashtag = search.match(/[\p{L}\p{N}\p{M}]+/u)?.[0].toLowerCase()
|
||||
return (
|
||||
<SecondaryPageLink to={toNoteList({ hashtag })} onClick={onClick}>
|
||||
<CommandItem value={`hashtag-${hashtag}`}>
|
||||
<Hash className="text-muted-foreground" />
|
||||
<div className="font-semibold">{hashtag}</div>
|
||||
</CommandItem>
|
||||
</SecondaryPageLink>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteItem({ id, onClick }: { id: string; onClick?: () => void }) {
|
||||
return (
|
||||
<SecondaryPageLink to={toNote(id)} onClick={onClick}>
|
||||
<CommandItem>
|
||||
<Notebook className="text-muted-foreground" />
|
||||
<div className="font-semibold truncate">{id}</div>
|
||||
</CommandItem>
|
||||
</SecondaryPageLink>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileIdItem({ id, onClick }: { id: string; onClick?: () => void }) {
|
||||
return (
|
||||
<SecondaryPageLink to={toProfile(id)} onClick={onClick}>
|
||||
<CommandItem>
|
||||
<UserRound className="text-muted-foreground" />
|
||||
<div className="font-semibold truncate">{id}</div>
|
||||
</CommandItem>
|
||||
</SecondaryPageLink>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileItem({ profile, onClick }: { profile: TProfile; onClick?: () => void }) {
|
||||
return (
|
||||
<SecondaryPageLink to={toProfile(profile.pubkey)} onClick={onClick}>
|
||||
<CommandItem>
|
||||
<div className="flex gap-2">
|
||||
<Avatar>
|
||||
<AvatarImage src={profile.avatar} alt={profile.username} />
|
||||
<AvatarFallback>
|
||||
<img src={generateImageByPubkey(profile.pubkey)} alt={profile.username} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="font-semibold">{profile.username}</div>
|
||||
<div className="line-clamp-1 text-muted-foreground">{profile.about}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</SecondaryPageLink>
|
||||
)
|
||||
}
|
||||
37
src/components/Sidebar/index.tsx
Normal file
37
src/components/Sidebar/index.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import Logo from '@/assets/Logo'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Info } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AboutInfoDialog from '../AboutInfoDialog'
|
||||
import AccountButton from '../AccountButton'
|
||||
import NotificationButton from '../NotificationButton'
|
||||
import PostButton from '../PostButton'
|
||||
import RefreshButton from '../RefreshButton'
|
||||
import RelaySettingsButton from '../RelaySettingsButton'
|
||||
import SearchButton from '../SearchButton'
|
||||
|
||||
export default function PrimaryPageSidebar() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="w-52 h-full shrink-0 hidden xl:flex flex-col pb-8 pt-10 pl-4 justify-between relative">
|
||||
<div className="absolute top-0 left-0 h-11 w-full" />
|
||||
<div className="space-y-2">
|
||||
<div className="ml-4 mb-8 w-40">
|
||||
<Logo />
|
||||
</div>
|
||||
<PostButton variant="sidebar" />
|
||||
<RelaySettingsButton variant="sidebar" />
|
||||
<NotificationButton variant="sidebar" />
|
||||
<SearchButton variant="sidebar" />
|
||||
<RefreshButton variant="sidebar" />
|
||||
<AboutInfoDialog>
|
||||
<Button variant="sidebar" size="sidebar">
|
||||
<Info />
|
||||
{t('About')}
|
||||
</Button>
|
||||
</AboutInfoDialog>
|
||||
</div>
|
||||
<AccountButton variant="sidebar" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
46
src/components/ThemeToggle/index.tsx
Normal file
46
src/components/ThemeToggle/index.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { useTheme } from '@/providers/ThemeProvider'
|
||||
import { Moon, Sun, SunMoon } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function ThemeToggle({
|
||||
variant = 'titlebar'
|
||||
}: {
|
||||
variant?: 'titlebar' | 'small-screen-titlebar'
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { themeSetting, setThemeSetting } = useTheme()
|
||||
|
||||
return (
|
||||
<>
|
||||
{themeSetting === 'system' ? (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={variant}
|
||||
onClick={() => setThemeSetting('light')}
|
||||
title={t('switch to light theme')}
|
||||
>
|
||||
<SunMoon />
|
||||
</Button>
|
||||
) : themeSetting === 'light' ? (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={variant}
|
||||
onClick={() => setThemeSetting('dark')}
|
||||
title={t('switch to dark theme')}
|
||||
>
|
||||
<Sun />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={variant}
|
||||
onClick={() => setThemeSetting('system')}
|
||||
title={t('switch to system theme')}
|
||||
>
|
||||
<Moon />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
23
src/components/Titlebar/index.tsx
Normal file
23
src/components/Titlebar/index.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Titlebar({
|
||||
children,
|
||||
className,
|
||||
visible = true
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
visible?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0 w-full h-9 max-sm:h-11 z-50 bg-background/80 backdrop-blur-md flex items-center font-semibold gap-1 px-2 duration-700 transition-transform',
|
||||
visible ? '' : '-translate-y-full',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
src/components/UserAvatar/index.tsx
Normal file
56
src/components/UserAvatar/index.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useFetchProfile } from '@/hooks'
|
||||
import { generateImageByPubkey } from '@/lib/pubkey'
|
||||
import { toProfile } from '@/lib/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SecondaryPageLink } from '@/PageManager'
|
||||
import ProfileCard from '../ProfileCard'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
const UserAvatarSizeCnMap = {
|
||||
large: 'w-24 h-24',
|
||||
normal: 'w-10 h-10',
|
||||
small: 'w-7 h-7',
|
||||
tiny: 'w-4 h-4'
|
||||
}
|
||||
|
||||
export default function UserAvatar({
|
||||
userId,
|
||||
className,
|
||||
size = 'normal'
|
||||
}: {
|
||||
userId: string
|
||||
className?: string
|
||||
size?: 'large' | 'normal' | 'small' | 'tiny'
|
||||
}) {
|
||||
const { profile } = useFetchProfile(userId)
|
||||
const defaultAvatar = useMemo(
|
||||
() => (profile?.pubkey ? generateImageByPubkey(profile.pubkey) : ''),
|
||||
[profile]
|
||||
)
|
||||
|
||||
if (!profile) {
|
||||
return <Skeleton className={cn(UserAvatarSizeCnMap[size], 'rounded-full', className)} />
|
||||
}
|
||||
const { avatar, pubkey } = profile
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<SecondaryPageLink to={toProfile(pubkey)} onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar className={cn(UserAvatarSizeCnMap[size], className)}>
|
||||
<AvatarImage src={avatar} className="object-cover object-center" />
|
||||
<AvatarFallback>
|
||||
<img src={defaultAvatar} alt={pubkey} />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</SecondaryPageLink>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-72">
|
||||
<ProfileCard pubkey={pubkey} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)
|
||||
}
|
||||
22
src/components/UserItem/index.tsx
Normal file
22
src/components/UserItem/index.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import FollowButton from '@/components/FollowButton'
|
||||
import Nip05 from '@/components/Nip05'
|
||||
import UserAvatar from '@/components/UserAvatar'
|
||||
import Username from '@/components/Username'
|
||||
import { useFetchProfile } from '@/hooks'
|
||||
|
||||
export default function UserItem({ pubkey }: { pubkey: string }) {
|
||||
const { profile } = useFetchProfile(pubkey)
|
||||
const { nip05, about } = profile || {}
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-start">
|
||||
<UserAvatar userId={pubkey} className="shrink-0" />
|
||||
<div className="w-full overflow-hidden">
|
||||
<Username userId={pubkey} className="font-semibold truncate" skeletonClassName="h-4" />
|
||||
<Nip05 nip05={nip05} pubkey={pubkey} />
|
||||
<div className="truncate text-muted-foreground text-sm">{about}</div>
|
||||
</div>
|
||||
<FollowButton pubkey={pubkey} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
src/components/Username/index.tsx
Normal file
44
src/components/Username/index.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useFetchProfile } from '@/hooks'
|
||||
import { toProfile } from '@/lib/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SecondaryPageLink } from '@/PageManager'
|
||||
import ProfileCard from '../ProfileCard'
|
||||
|
||||
export default function Username({
|
||||
userId,
|
||||
showAt = false,
|
||||
className,
|
||||
skeletonClassName
|
||||
}: {
|
||||
userId: string
|
||||
showAt?: boolean
|
||||
className?: string
|
||||
skeletonClassName?: string
|
||||
}) {
|
||||
const { profile } = useFetchProfile(userId)
|
||||
if (!profile) return <Skeleton className={cn('w-16 my-1', skeletonClassName)} />
|
||||
|
||||
const { username, pubkey } = profile
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className={cn('max-w-fit', className)}>
|
||||
<SecondaryPageLink
|
||||
to={toProfile(pubkey)}
|
||||
className="truncate hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{showAt && '@'}
|
||||
{username}
|
||||
</SecondaryPageLink>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-80">
|
||||
<ProfileCard pubkey={pubkey} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)
|
||||
}
|
||||
26
src/components/VideoPlayer/index.tsx
Normal file
26
src/components/VideoPlayer/index.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
import NsfwOverlay from '../NsfwOverlay'
|
||||
|
||||
export default function VideoPlayer({
|
||||
src,
|
||||
className,
|
||||
isNsfw = false,
|
||||
size = 'normal'
|
||||
}: {
|
||||
src: string
|
||||
className?: string
|
||||
isNsfw?: boolean
|
||||
size?: 'normal' | 'small'
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<video
|
||||
controls
|
||||
preload="none"
|
||||
className={cn('rounded-lg', size === 'small' ? 'max-h-[20vh]' : 'max-h-[50vh]', className)}
|
||||
src={src}
|
||||
/>
|
||||
{isNsfw && <NsfwOverlay className="rounded-lg" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
src/components/WebPreview/index.tsx
Normal file
56
src/components/WebPreview/index.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { Image } from '@nextui-org/image'
|
||||
import { useFetchWebMetadata } from '@/hooks/useFetchWebMetadata'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
export default function WebPreview({
|
||||
url,
|
||||
className,
|
||||
size = 'normal'
|
||||
}: {
|
||||
url: string
|
||||
className?: string
|
||||
size?: 'normal' | 'small'
|
||||
}) {
|
||||
const { title, description, image } = useFetchWebMetadata(url)
|
||||
const hostname = useMemo(() => {
|
||||
try {
|
||||
return new URL(url).hostname
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}, [url])
|
||||
|
||||
if (!title) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('p-0 hover:bg-muted/50 cursor-pointer flex w-full', className)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.open(url, '_blank')
|
||||
}}
|
||||
>
|
||||
{image && (
|
||||
<Image
|
||||
src={image}
|
||||
className={`rounded-l-lg object-cover w-2/5 ${size === 'normal' ? 'h-44' : 'h-24'}`}
|
||||
removeWrapper
|
||||
/>
|
||||
)}
|
||||
<div className={`flex-1 w-0 p-2 border ${image ? 'rounded-r-lg' : 'rounded-lg'}`}>
|
||||
<div className="text-xs text-muted-foreground">{hostname}</div>
|
||||
<div className={`font-semibold ${size === 'normal' ? 'line-clamp-2' : 'line-clamp-1'}`}>
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs text-muted-foreground ${size === 'normal' ? 'line-clamp-5' : 'line-clamp-2'}`}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/components/ui/avatar.tsx
Normal file
45
src/components/ui/avatar.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import * as React from 'react'
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn('aspect-square h-full w-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full items-center justify-center rounded-full bg-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
57
src/components/ui/button.tsx
Normal file
57
src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
'secondary-2': 'bg-secondary text-secondary-foreground hover:bg-highlight',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
titlebar: 'hover:bg-accent hover:text-accent-foreground',
|
||||
sidebar: 'hover:bg-accent hover:text-accent-foreground',
|
||||
'small-screen-titlebar': 'hover:bg-accent hover:text-accent-foreground'
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
titlebar: 'h-7 w-7 rounded-full',
|
||||
sidebar: 'w-full flex py-2 px-4 rounded-full justify-start gap-4 text-lg font-semibold',
|
||||
'small-screen-titlebar': 'h-8 w-8 rounded-full'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
55
src/components/ui/card.tsx
Normal file
55
src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-lg border bg-card text-card-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
162
src/components/ui/command.tsx
Normal file
162
src/components/ui/command.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { type DialogProps } from '@radix-ui/react-dialog'
|
||||
import { Command as CommandPrimitive } from 'cmdk'
|
||||
import { Search } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
const CommandDialog = ({
|
||||
children,
|
||||
classNames,
|
||||
...props
|
||||
}: DialogProps & { classNames?: { content?: string } }) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="hidden">
|
||||
<DialogTitle />
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'overflow-hidden p-0 shadow-lg top-4 translate-y-0 data-[state=closed]:slide-out-to-top-4 data-[state=open]:slide-in-from-top-4',
|
||||
classNames?.content
|
||||
)}
|
||||
>
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
|
||||
>
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 pr-6',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ScrollArea className="max-h-[80vh]">
|
||||
<CommandPrimitive.List ref={ref} className={cn('overflow-x-hidden', className)} {...props} />
|
||||
</ScrollArea>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = 'CommandShortcut'
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut
|
||||
}
|
||||
104
src/components/ui/dialog.tsx
Normal file
104
src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { withoutClose?: boolean }
|
||||
>(({ className, children, withoutClose, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 sm:border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{!withoutClose && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription
|
||||
}
|
||||
184
src/components/ui/dropdown-menu.tsx
Normal file
184
src/components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import * as React from 'react'
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
collisionPadding={10}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 rounded-md',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
|
||||
}
|
||||
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup
|
||||
}
|
||||
28
src/components/ui/hover-card.tsx
Normal file
28
src/components/ui/hover-card.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import * as React from 'react'
|
||||
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={10}
|
||||
className={cn(
|
||||
'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
22
src/components/ui/input.tsx
Normal file
22
src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
32
src/components/ui/popover.tsx
Normal file
32
src/components/ui/popover.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import * as React from 'react'
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={10}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
40
src/components/ui/resizable.tsx
Normal file
40
src/components/ui/resizable.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { GripVertical } from 'lucide-react'
|
||||
import * as ResizablePrimitive from 'react-resizable-panels'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
)
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
40
src/components/ui/scroll-area.tsx
Normal file
40
src/components/ui/scroll-area.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import * as React from 'react'
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & { scrollBarClassName?: string }
|
||||
>(({ className, scrollBarClassName, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root className={cn('relative overflow-hidden', className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport ref={ref} className="h-full w-full rounded-[inherit] *:!block">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar className={scrollBarClassName} />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
24
src/components/ui/separator.tsx
Normal file
24
src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import * as React from 'react'
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
7
src/components/ui/skeleton.tsx
Normal file
7
src/components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('animate-pulse rounded-md bg-primary/10', className)} {...props} />
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
27
src/components/ui/switch.tsx
Normal file
27
src/components/ui/switch.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import * as React from 'react'
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
21
src/components/ui/textarea.tsx
Normal file
21
src/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<'textarea'>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export { Textarea }
|
||||
126
src/components/ui/toast.tsx
Normal file
126
src/components/ui/toast.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import * as React from 'react'
|
||||
import * as ToastPrimitives from '@radix-ui/react-toast'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border bg-background text-foreground',
|
||||
destructive:
|
||||
'destructive group border-destructive bg-destructive text-destructive-foreground'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn('text-sm font-semibold [&+div]:text-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm opacity-90', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction
|
||||
}
|
||||
31
src/components/ui/toaster.tsx
Normal file
31
src/components/ui/toaster.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { useToast } from '@/hooks/use-toast'
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport
|
||||
} from '@/components/ui/toast'
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && <ToastDescription>{description}</ToastDescription>}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue