style: 🎨

This commit is contained in:
codytseng
2025-04-24 22:23:22 +08:00
parent d3d5842804
commit 51913a5163
6 changed files with 310 additions and 310 deletions

View File

@@ -1,78 +1,78 @@
import { useToast } from '@/hooks'
import { useBookmarks } from '@/providers/BookmarksProvider'
import { useNostr } from '@/providers/NostrProvider'
import { BookmarkIcon, Loader } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Event } from 'nostr-tools'
export default function BookmarkButton({ event }: { event: Event }) {
const { t } = useTranslation()
const { toast } = useToast()
const { pubkey: accountPubkey, bookmarkListEvent, checkLogin } = useNostr()
const { addBookmark, removeBookmark } = useBookmarks()
const [updating, setUpdating] = useState(false)
const isBookmarked = useMemo(
() => bookmarkListEvent?.tags.some((tag) => tag[0] === 'e' && tag[1] === event.id),
[bookmarkListEvent, event]
)
if (!accountPubkey) return null
const handleBookmark = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (isBookmarked) return
setUpdating(true)
try {
await addBookmark(event)
} catch (error) {
toast({
title: t('Bookmark failed'),
description: (error as Error).message,
variant: 'destructive'
})
} finally {
setUpdating(false)
}
})
}
const handleRemoveBookmark = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (!isBookmarked) return
setUpdating(true)
try {
await removeBookmark(event)
} catch (error) {
toast({
title: t('Remove bookmark failed'),
description: (error as Error).message,
variant: 'destructive'
})
} finally {
setUpdating(false)
}
})
}
return (
<button
className={`flex items-center gap-1 ${
isBookmarked ? 'text-rose-400' : 'text-muted-foreground'
} enabled:hover:text-rose-400 px-3 h-full`}
onClick={isBookmarked ? handleRemoveBookmark : handleBookmark}
disabled={updating}
title={isBookmarked ? t('Remove bookmark') : t('Bookmark')}
>
{updating ? (
<Loader className="animate-spin" />
) : (
<BookmarkIcon className={isBookmarked ? 'fill-rose-400' : ''} />
)}
</button>
)
}
import { useToast } from '@/hooks'
import { useBookmarks } from '@/providers/BookmarksProvider'
import { useNostr } from '@/providers/NostrProvider'
import { BookmarkIcon, Loader } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Event } from 'nostr-tools'
export default function BookmarkButton({ event }: { event: Event }) {
const { t } = useTranslation()
const { toast } = useToast()
const { pubkey: accountPubkey, bookmarkListEvent, checkLogin } = useNostr()
const { addBookmark, removeBookmark } = useBookmarks()
const [updating, setUpdating] = useState(false)
const isBookmarked = useMemo(
() => bookmarkListEvent?.tags.some((tag) => tag[0] === 'e' && tag[1] === event.id),
[bookmarkListEvent, event]
)
if (!accountPubkey) return null
const handleBookmark = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (isBookmarked) return
setUpdating(true)
try {
await addBookmark(event)
} catch (error) {
toast({
title: t('Bookmark failed'),
description: (error as Error).message,
variant: 'destructive'
})
} finally {
setUpdating(false)
}
})
}
const handleRemoveBookmark = async (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(async () => {
if (!isBookmarked) return
setUpdating(true)
try {
await removeBookmark(event)
} catch (error) {
toast({
title: t('Remove bookmark failed'),
description: (error as Error).message,
variant: 'destructive'
})
} finally {
setUpdating(false)
}
})
}
return (
<button
className={`flex items-center gap-1 ${
isBookmarked ? 'text-rose-400' : 'text-muted-foreground'
} enabled:hover:text-rose-400 px-3 h-full`}
onClick={isBookmarked ? handleRemoveBookmark : handleBookmark}
disabled={updating}
title={isBookmarked ? t('Remove bookmark') : t('Bookmark')}
>
{updating ? (
<Loader className="animate-spin" />
) : (
<BookmarkIcon className={isBookmarked ? 'fill-rose-400' : ''} />
)}
</button>
)
}

View File

@@ -1,97 +1,97 @@
import { useFetchEvent } from '@/hooks'
import { generateEventIdFromETag } from '@/lib/tag'
import { useNostr } from '@/providers/NostrProvider'
import { kinds } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
const SHOW_COUNT = 10
export default function BookmarkList() {
const { t } = useTranslation()
const { bookmarkListEvent } = useNostr()
const eventIds = useMemo(() => {
if (!bookmarkListEvent) return []
return (
bookmarkListEvent.tags
.map((tag) => (tag[0] === 'e' ? generateEventIdFromETag(tag) : undefined))
.filter(Boolean) as `nevent1${string}`[]
).reverse()
}, [bookmarkListEvent])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const options = {
root: null,
rootMargin: '10px',
threshold: 0.1
}
const loadMore = () => {
if (showCount < eventIds.length) {
setShowCount((prev) => prev + SHOW_COUNT)
}
}
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)
}
}
}, [showCount, eventIds])
if (eventIds.length === 0) {
return (
<div className="mt-2 text-sm text-center text-muted-foreground">
{t('no bookmarks found')}
</div>
)
}
return (
<div>
{eventIds.slice(0, showCount).map((eventId) => (
<BookmarkedNote key={eventId} eventId={eventId} />
))}
{showCount < eventIds.length ? (
<div ref={bottomRef}>
<NoteCardLoadingSkeleton isPictures={false} />
</div>
) : (
<div className="text-center text-sm text-muted-foreground mt-2">
{t('no more bookmarks')}
</div>
)}
</div>
)
}
function BookmarkedNote({ eventId }: { eventId: string }) {
const { event, isFetching } = useFetchEvent(eventId)
if (isFetching) {
return <NoteCardLoadingSkeleton isPictures={false} />
}
if (!event || event.kind !== kinds.ShortTextNote) {
return null
}
return <NoteCard event={event} className="w-full" />
}
import { useFetchEvent } from '@/hooks'
import { generateEventIdFromETag } from '@/lib/tag'
import { useNostr } from '@/providers/NostrProvider'
import { kinds } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
const SHOW_COUNT = 10
export default function BookmarkList() {
const { t } = useTranslation()
const { bookmarkListEvent } = useNostr()
const eventIds = useMemo(() => {
if (!bookmarkListEvent) return []
return (
bookmarkListEvent.tags
.map((tag) => (tag[0] === 'e' ? generateEventIdFromETag(tag) : undefined))
.filter(Boolean) as `nevent1${string}`[]
).reverse()
}, [bookmarkListEvent])
const [showCount, setShowCount] = useState(SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const options = {
root: null,
rootMargin: '10px',
threshold: 0.1
}
const loadMore = () => {
if (showCount < eventIds.length) {
setShowCount((prev) => prev + SHOW_COUNT)
}
}
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)
}
}
}, [showCount, eventIds])
if (eventIds.length === 0) {
return (
<div className="mt-2 text-sm text-center text-muted-foreground">
{t('no bookmarks found')}
</div>
)
}
return (
<div>
{eventIds.slice(0, showCount).map((eventId) => (
<BookmarkedNote key={eventId} eventId={eventId} />
))}
{showCount < eventIds.length ? (
<div ref={bottomRef}>
<NoteCardLoadingSkeleton isPictures={false} />
</div>
) : (
<div className="text-center text-sm text-muted-foreground mt-2">
{t('no more bookmarks')}
</div>
)}
</div>
)
}
function BookmarkedNote({ eventId }: { eventId: string }) {
const { event, isFetching } = useFetchEvent(eventId)
if (isFetching) {
return <NoteCardLoadingSkeleton isPictures={false} />
}
if (!event || event.kind !== kinds.ShortTextNote) {
return null
}
return <NoteCard event={event} className="w-full" />
}

View File

@@ -1,68 +1,68 @@
import { Button } from '@/components/ui/button'
import { SimpleUserAvatar } from '@/components/UserAvatar'
import { cn } from '@/lib/utils'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function NewNotesButton({
newEvents = [],
onClick
}: {
newEvents?: Event[]
onClick?: () => void
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const pubkeys = useMemo(() => {
const arr: string[] = []
for (const event of newEvents) {
if (!arr.includes(event.pubkey)) {
arr.push(event.pubkey)
}
if (arr.length >= 3) break
}
return arr
}, [newEvents])
return (
<>
{newEvents.length > 0 && (
<div
className={cn(
'w-full flex justify-center z-40 pointer-events-none',
isSmallScreen ? 'fixed' : 'absolute bottom-4'
)}
style={isSmallScreen ? { bottom: 'calc(4rem + env(safe-area-inset-bottom))' } : undefined}
>
<Button
onClick={onClick}
className="group rounded-full h-fit pl-2 pr-3 hover:bg-primary-hover pointer-events-auto"
>
{pubkeys.length > 0 && (
<div className="flex items-center">
{pubkeys.map((pubkey, index) => (
<div
key={pubkey}
className="relative -mr-2.5 last:mr-0"
style={{ zIndex: 3 - index }}
>
<SimpleUserAvatar
userId={pubkey}
size="small"
className="border-primary border-2 group-hover:border-primary-hover"
/>
</div>
))}
</div>
)}
<div className="text-md font-medium">
{t('Show n new notes', { n: newEvents.length > 99 ? '99+' : newEvents.length })}
</div>
</Button>
</div>
)}
</>
)
}
import { Button } from '@/components/ui/button'
import { SimpleUserAvatar } from '@/components/UserAvatar'
import { cn } from '@/lib/utils'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function NewNotesButton({
newEvents = [],
onClick
}: {
newEvents?: Event[]
onClick?: () => void
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const pubkeys = useMemo(() => {
const arr: string[] = []
for (const event of newEvents) {
if (!arr.includes(event.pubkey)) {
arr.push(event.pubkey)
}
if (arr.length >= 3) break
}
return arr
}, [newEvents])
return (
<>
{newEvents.length > 0 && (
<div
className={cn(
'w-full flex justify-center z-40 pointer-events-none',
isSmallScreen ? 'fixed' : 'absolute bottom-4'
)}
style={isSmallScreen ? { bottom: 'calc(4rem + env(safe-area-inset-bottom))' } : undefined}
>
<Button
onClick={onClick}
className="group rounded-full h-fit pl-2 pr-3 hover:bg-primary-hover pointer-events-auto"
>
{pubkeys.length > 0 && (
<div className="flex items-center">
{pubkeys.map((pubkey, index) => (
<div
key={pubkey}
className="relative -mr-2.5 last:mr-0"
style={{ zIndex: 3 - index }}
>
<SimpleUserAvatar
userId={pubkey}
size="small"
className="border-primary border-2 group-hover:border-primary-hover"
/>
</div>
))}
</div>
)}
<div className="text-md font-medium">
{t('Show n new notes', { n: newEvents.length > 99 ? '99+' : newEvents.length })}
</div>
</Button>
</div>
)}
</>
)
}

View File

@@ -67,7 +67,7 @@ export default function Likes({ event }: { event: Event }) {
like(key, emoji)
}}
>
{liking === key ? <Loader className="animate-spin size-5" /> : <Emoji emoji={emoji} />}
{liking === key ? <Loader className="animate-spin size-4" /> : <Emoji emoji={emoji} />}
<div className="text-sm">{pubkeys.size}</div>
</div>
))}

View File

@@ -1,65 +1,65 @@
import { createBookmarkDraftEvent } from '@/lib/draft-event'
import client from '@/services/client.service'
import { createContext, useContext } from 'react'
import { useNostr } from './NostrProvider'
import { Event } from 'nostr-tools'
type TBookmarksContext = {
addBookmark: (event: Event) => Promise<void>
removeBookmark: (event: Event) => Promise<void>
}
const BookmarksContext = createContext<TBookmarksContext | undefined>(undefined)
export const useBookmarks = () => {
const context = useContext(BookmarksContext)
if (!context) {
throw new Error('useBookmarks must be used within a BookmarksProvider')
}
return context
}
export function BookmarksProvider({ children }: { children: React.ReactNode }) {
const { pubkey: accountPubkey, publish, updateBookmarkListEvent } = useNostr()
const addBookmark = async (event: Event) => {
if (!accountPubkey) return
const bookmarkListEvent = await client.fetchBookmarkListEvent(accountPubkey)
const currentTags = bookmarkListEvent?.tags || []
if (currentTags.some((tag) => tag[0] === 'e' && tag[1] === event.id)) return
const newBookmarkDraftEvent = createBookmarkDraftEvent(
[...currentTags, ['e', event.id, client.getEventHint(event.id), '', event.pubkey]],
bookmarkListEvent?.content
)
const newBookmarkEvent = await publish(newBookmarkDraftEvent)
await updateBookmarkListEvent(newBookmarkEvent)
}
const removeBookmark = async (event: Event) => {
if (!accountPubkey) return
const bookmarkListEvent = await client.fetchBookmarkListEvent(accountPubkey)
if (!bookmarkListEvent) return
const newTags = bookmarkListEvent.tags.filter((tag) => !(tag[0] === 'e' && tag[1] === event.id))
if (newTags.length === bookmarkListEvent.tags.length) return
const newBookmarkDraftEvent = createBookmarkDraftEvent(newTags, bookmarkListEvent.content)
const newBookmarkEvent = await publish(newBookmarkDraftEvent)
await updateBookmarkListEvent(newBookmarkEvent)
}
return (
<BookmarksContext.Provider
value={{
addBookmark,
removeBookmark
}}
>
{children}
</BookmarksContext.Provider>
)
}
import { createBookmarkDraftEvent } from '@/lib/draft-event'
import client from '@/services/client.service'
import { createContext, useContext } from 'react'
import { useNostr } from './NostrProvider'
import { Event } from 'nostr-tools'
type TBookmarksContext = {
addBookmark: (event: Event) => Promise<void>
removeBookmark: (event: Event) => Promise<void>
}
const BookmarksContext = createContext<TBookmarksContext | undefined>(undefined)
export const useBookmarks = () => {
const context = useContext(BookmarksContext)
if (!context) {
throw new Error('useBookmarks must be used within a BookmarksProvider')
}
return context
}
export function BookmarksProvider({ children }: { children: React.ReactNode }) {
const { pubkey: accountPubkey, publish, updateBookmarkListEvent } = useNostr()
const addBookmark = async (event: Event) => {
if (!accountPubkey) return
const bookmarkListEvent = await client.fetchBookmarkListEvent(accountPubkey)
const currentTags = bookmarkListEvent?.tags || []
if (currentTags.some((tag) => tag[0] === 'e' && tag[1] === event.id)) return
const newBookmarkDraftEvent = createBookmarkDraftEvent(
[...currentTags, ['e', event.id, client.getEventHint(event.id), '', event.pubkey]],
bookmarkListEvent?.content
)
const newBookmarkEvent = await publish(newBookmarkDraftEvent)
await updateBookmarkListEvent(newBookmarkEvent)
}
const removeBookmark = async (event: Event) => {
if (!accountPubkey) return
const bookmarkListEvent = await client.fetchBookmarkListEvent(accountPubkey)
if (!bookmarkListEvent) return
const newTags = bookmarkListEvent.tags.filter((tag) => !(tag[0] === 'e' && tag[1] === event.id))
if (newTags.length === bookmarkListEvent.tags.length) return
const newBookmarkDraftEvent = createBookmarkDraftEvent(newTags, bookmarkListEvent.content)
const newBookmarkEvent = await publish(newBookmarkDraftEvent)
await updateBookmarkListEvent(newBookmarkEvent)
}
return (
<BookmarksContext.Provider
value={{
addBookmark,
removeBookmark
}}
>
{children}
</BookmarksContext.Provider>
)
}

View File

@@ -23,7 +23,7 @@ export default {
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
hover: 'hsl(var(--primary-hover))',
hover: 'hsl(var(--primary-hover))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',