refactor: search

This commit is contained in:
codytseng
2025-08-31 22:43:47 +08:00
parent 88567c2c13
commit 0153465e29
24 changed files with 785 additions and 345 deletions

View File

@@ -1,22 +0,0 @@
import { Button } from '@/components/ui/button'
import { useSecondaryPage } from '@/PageManager'
import { ChevronLeft } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function BackButton({ children }: { children?: React.ReactNode }) {
const { t } = useTranslation()
const { pop } = useSecondaryPage()
return (
<Button
className="flex gap-1 items-center w-fit max-w-full justify-start pl-2 pr-3"
variant="ghost"
size="titlebar-icon"
title={t('back')}
onClick={() => pop()}
>
<ChevronLeft />
<div className="truncate text-lg font-semibold">{children}</div>
</Button>
)
}

View File

@@ -0,0 +1,68 @@
import { SEARCHABLE_RELAY_URLS } from '@/constants'
import client from '@/services/client.service'
import dayjs from 'dayjs'
import { useEffect, useRef, useState } from 'react'
import UserItem from '../UserItem'
const LIMIT = 50
export function ProfileListBySearch({ search }: { search: string }) {
const [until, setUntil] = useState<number>(() => dayjs().unix())
const [hasMore, setHasMore] = useState<boolean>(true)
const [pubkeySet, setPubkeySet] = useState(new Set<string>())
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!hasMore) 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)
}
}
}, [hasMore, search, until])
async function loadMore() {
const profiles = await client.searchProfiles(SEARCHABLE_RELAY_URLS, {
search,
until,
limit: LIMIT
})
const newPubkeySet = new Set<string>()
profiles.forEach((profile) => {
if (!pubkeySet.has(profile.pubkey)) {
newPubkeySet.add(profile.pubkey)
}
})
setPubkeySet((prev) => new Set([...prev, ...newPubkeySet]))
setHasMore(profiles.length >= LIMIT)
const lastProfileCreatedAt = profiles[profiles.length - 1].created_at
setUntil(lastProfileCreatedAt ? lastProfileCreatedAt - 1 : 0)
}
return (
<div className="px-4">
{Array.from(pubkeySet).map((pubkey, index) => (
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
))}
{hasMore && <div ref={bottomRef} />}
</div>
)
}

View File

@@ -3,6 +3,7 @@ import RelayInfo from '@/components/RelayInfo'
import SearchInput from '@/components/SearchInput'
import { useFetchRelayInfo } from '@/hooks'
import { normalizeUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import NotFound from '../NotFound'
@@ -29,7 +30,7 @@ export default function Relay({ url, className }: { url?: string; className?: st
}
return (
<div className={className}>
<div className={cn('pt-3', className)}>
<RelayInfo url={normalizedUrl} />
{relayInfo?.supported_nips?.includes(50) && (
<div className="px-4 py-2">

View File

@@ -0,0 +1,298 @@
import Nip05 from '@/components/Nip05'
import SearchInput from '@/components/SearchInput'
import { ScrollArea } from '@/components/ui/scroll-area'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import { useSearchProfiles } from '@/hooks'
import { toNote } from '@/lib/link'
import { randomString } from '@/lib/random'
import { normalizeUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import modalManager from '@/services/modal-manager.service'
import { TProfile, TSearchParams } from '@/types'
import { Hash, Notebook, Server, UserRound } from 'lucide-react'
import { nip19 } from 'nostr-tools'
import {
forwardRef,
HTMLAttributes,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState
} from 'react'
import { useTranslation } from 'react-i18next'
const SearchBar = forwardRef<
TSearchBarRef,
{
input: string
setInput: (input: string) => void
onSearch: (params: TSearchParams | null) => void
}
>(({ input, setInput, onSearch }, ref) => {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
const [debouncedInput, setDebouncedInput] = useState(input)
const { profiles } = useSearchProfiles(debouncedInput, 10)
const [searching, setSearching] = useState(false)
const searchInputRef = useRef<HTMLInputElement>(null)
const normalizedUrl = useMemo(() => {
if (['w', 'ws', 'ws:', 'ws:/', 'wss', 'wss:', 'wss:/'].includes(input)) {
return undefined
}
try {
return normalizeUrl(input)
} catch {
return undefined
}
}, [input])
const id = useMemo(() => `search-${randomString()}`, [])
useImperativeHandle(ref, () => ({
focus: () => {
searchInputRef.current?.focus()
},
blur: () => {
searchInputRef.current?.blur()
}
}))
useEffect(() => {
if (!input) {
onSearch(null)
}
}, [input])
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedInput(input)
}, 500)
return () => {
clearTimeout(handler)
}
}, [input])
const blur = () => {
setSearching(false)
searchInputRef.current?.blur()
}
const startSearch = () => {
setSearching(true)
}
const list = useMemo(() => {
const search = input.trim()
if (!search) return null
const updateSearch = (params: TSearchParams) => {
blur()
onSearch(params)
}
if (/^[0-9a-f]{64}$/.test(search)) {
return (
<>
<NoteItem
id={search}
onClick={() => {
blur()
push(toNote(search))
}}
/>
<ProfileIdItem id={search} onClick={() => updateSearch({ type: 'profile', search })} />
</>
)
}
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={() => updateSearch({ type: 'profile', search: id })} />
)
}
if (['nevent', 'naddr', 'note'].includes(type)) {
return (
<NoteItem
id={id}
onClick={() => {
blur()
push(toNote(id))
}}
/>
)
}
} catch {
// ignore
}
return (
<>
<NormalItem search={search} onClick={() => updateSearch({ type: 'notes', search })} />
<HashtagItem
search={search}
onClick={() => updateSearch({ type: 'hashtag', search, input: `#${search}` })}
/>
{!!normalizedUrl && (
<RelayItem
url={normalizedUrl}
onClick={() => updateSearch({ type: 'relay', search, input: normalizedUrl })}
/>
)}
{profiles.map((profile) => (
<ProfileItem
key={profile.pubkey}
profile={profile}
onClick={() =>
updateSearch({ type: 'profile', search: profile.npub, input: profile.username })
}
/>
))}
{profiles.length >= 10 && (
<Item onClick={() => updateSearch({ type: 'profiles', search })}>
<div className="font-semibold">{t('Show more...')}</div>
</Item>
)}
</>
)
}, [input, debouncedInput, profiles])
const showList = useMemo(() => searching && !!list, [searching, list])
useEffect(() => {
if (showList) {
modalManager.register(id, () => {
blur()
})
} else {
modalManager.unregister(id)
}
}, [showList])
return (
<div className="relative flex gap-1 items-center h-full w-full">
{showList && (
<>
<div
className={cn(
'bg-surface-background rounded-b-lg shadow-lg',
isSmallScreen
? 'fixed top-12 inset-x-0'
: 'absolute top-full -translate-y-1 inset-x-0 pt-1 ',
searching ? 'z-50' : ''
)}
onMouseDown={(e) => e.preventDefault()}
>
<ScrollArea className="h-[60vh]">{list}</ScrollArea>
</div>
<div className="fixed inset-0 w-full h-full" onClick={() => blur()} />
</>
)}
<SearchInput
ref={searchInputRef}
className={cn(
'bg-surface-background shadow-inner h-full border-none',
searching ? 'z-50' : ''
)}
value={input}
onChange={(e) => setInput(e.target.value)}
onFocus={() => startSearch()}
/>
</div>
)
})
SearchBar.displayName = 'SearchBar'
export default SearchBar
export type TSearchBarRef = {
focus: () => void
blur: () => void
}
function NormalItem({ search, onClick }: { search: string; onClick?: () => void }) {
return (
<Item onClick={onClick}>
<Notebook className="text-muted-foreground" />
<div className="font-semibold truncate">{search}</div>
</Item>
)
}
function HashtagItem({ search, onClick }: { search: string; onClick?: () => void }) {
const hashtag = search.match(/[\p{L}\p{N}\p{M}]+/u)?.[0].toLowerCase()
return (
<Item onClick={onClick}>
<Hash className="text-muted-foreground" />
<div className="font-semibold truncate">{hashtag}</div>
</Item>
)
}
function NoteItem({ id, onClick }: { id: string; onClick?: () => void }) {
return (
<Item onClick={onClick}>
<Notebook className="text-muted-foreground" />
<div className="font-semibold truncate">{id}</div>
</Item>
)
}
function ProfileIdItem({ id, onClick }: { id: string; onClick?: () => void }) {
return (
<Item onClick={onClick}>
<UserRound className="text-muted-foreground" />
<div className="font-semibold truncate">{id}</div>
</Item>
)
}
function ProfileItem({ profile, onClick }: { profile: TProfile; onClick?: () => void }) {
return (
<div className="p-2 hover:bg-accent rounded-md cursor-pointer" onClick={onClick}>
<div className="flex gap-2 items-center pointer-events-none h-11">
<UserAvatar userId={profile.pubkey} className="shrink-0" />
<div className="w-full overflow-hidden">
<Username
userId={profile.pubkey}
className="font-semibold truncate max-w-full w-fit"
skeletonClassName="h-4"
/>
<Nip05 pubkey={profile.pubkey} />
</div>
</div>
</div>
)
}
function RelayItem({ url, onClick }: { url: string; onClick?: () => void }) {
return (
<Item onClick={onClick}>
<Server className="text-muted-foreground" />
<div className="font-semibold truncate">{url}</div>
</Item>
)
}
function Item({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
'flex gap-2 items-center px-2 py-3 hover:bg-accent rounded-md cursor-pointer',
className
)}
{...props}
>
{children}
</div>
)
}

View File

@@ -1,171 +0,0 @@
import { SecondaryPageLink } from '@/PageManager'
import { CommandDialog, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { useSearchProfiles } from '@/hooks'
import { toNote, toNoteList, toProfile, toProfileList, toRelay } from '@/lib/link'
import { normalizeUrl } from '@/lib/url'
import { TProfile } from '@/types'
import { Hash, Notebook, Server, UserRound } from 'lucide-react'
import { nip19 } from 'nostr-tools'
import { Dispatch, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Nip05 from '../Nip05'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
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 normalizedUrl = useMemo(() => {
if (['w', 'ws', 'ws:', 'ws:/', 'wss', 'wss:', 'wss:/'].includes(input)) {
return undefined
}
try {
return normalizeUrl(input)
} catch {
return undefined
}
}, [input])
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)} />
{!!normalizedUrl && <RelayItem url={normalizedUrl} 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 value="show-more" onClick={() => setOpen(false)} className="text-center">
<div className="font-semibold">{t('Show more...')}</div>
</CommandItem>
</SecondaryPageLink>
)}
</>
)
}, [input, debouncedInput, 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 scrollAreaClassName="max-h-[80vh]">{list}</CommandList>
</CommandDialog>
)
}
function NormalItem({ search, onClick }: { search: string; onClick?: () => void }) {
return (
<SecondaryPageLink to={toNoteList({ search })} onClick={onClick}>
<CommandItem value={`search-${search}`}>
<Notebook className="text-muted-foreground" />
<div className="font-semibold truncate">{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 value={`note-id-${id}`}>
<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 value={`profile-id-${id}`}>
<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 value={`profile-${profile.pubkey}`}>
<div className="flex gap-2 items-center pointer-events-none">
<UserAvatar userId={profile.pubkey} className="shrink-0" />
<div className="w-full overflow-hidden">
<Username
userId={profile.pubkey}
className="font-semibold truncate max-w-full w-fit"
skeletonClassName="h-4"
/>
<Nip05 pubkey={profile.pubkey} />
</div>
</div>
</CommandItem>
</SecondaryPageLink>
)
}
function RelayItem({ url, onClick }: { url: string; onClick?: () => void }) {
return (
<SecondaryPageLink to={toRelay(url)} onClick={onClick}>
<CommandItem value={`relay-${url}`}>
<Server className="text-muted-foreground" />
<div className="font-semibold truncate">{url}</div>
</CommandItem>
</SecondaryPageLink>
)
}

View File

@@ -1,33 +1,54 @@
import { cn } from '@/lib/utils'
import { SearchIcon, X } from 'lucide-react'
import { ComponentProps, useEffect, useState } from 'react'
import { ComponentProps, forwardRef, useEffect, useState } from 'react'
export default function SearchInput({ value, onChange, ...props }: ComponentProps<'input'>) {
const [displayClear, setDisplayClear] = useState(false)
const SearchInput = forwardRef<HTMLInputElement, ComponentProps<'input'>>(
({ value, onChange, className, ...props }, ref) => {
const [displayClear, setDisplayClear] = useState(false)
const [inputRef, setInputRef] = useState<HTMLInputElement | null>(null)
useEffect(() => {
setDisplayClear(!!value)
}, [value])
useEffect(() => {
setDisplayClear(!!value)
}, [value])
return (
<div
tabIndex={0}
className={cn(
'flex h-9 w-full items-center rounded-md border border-input bg-transparent px-2 py-1 text-base shadow-sm transition-colors md:text-sm [&:has(:focus-visible)]:ring-ring [&:has(:focus-visible)]:ring-1 [&:has(:focus-visible)]:outline-none'
)}
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<input
{...props}
value={value}
onChange={onChange}
className="size-full mx-2 border-none bg-transparent focus:outline-none placeholder:text-muted-foreground"
/>
{displayClear && (
<button type="button" onClick={() => onChange?.({ target: { value: '' } } as any)}>
<X className="size-4 shrink-0 opacity-50 hover:opacity-100" />
</button>
)}
</div>
)
}
function setRefs(el: HTMLInputElement) {
setInputRef(el)
if (typeof ref === 'function') {
ref(el)
} else if (ref) {
;(ref as React.MutableRefObject<HTMLInputElement | null>).current = el
}
}
return (
<div
tabIndex={0}
className={cn(
'flex h-9 w-full items-center rounded-md border border-input bg-transparent px-2 py-1 text-base shadow-sm transition-colors md:text-sm [&:has(:focus-visible)]:ring-ring [&:has(:focus-visible)]:ring-1 [&:has(:focus-visible)]:outline-none',
className
)}
>
<SearchIcon className="size-4 shrink-0 opacity-50" onClick={() => inputRef?.focus()} />
<input
{...props}
name="search-input"
ref={setRefs}
value={value}
onChange={onChange}
className="size-full mx-2 border-none bg-transparent focus:outline-none placeholder:text-muted-foreground"
/>
{displayClear && (
<button
type="button"
className="rounded-full bg-foreground/40 hover:bg-foreground transition-opacity size-5 shrink-0 flex flex-col items-center justify-center"
onClick={() => onChange?.({ target: { value: '' } } as any)}
>
<X className="!size-3 shrink-0 text-background" strokeWidth={4} />
</button>
)}
</div>
)
}
)
SearchInput.displayName = 'SearchInput'
export default SearchInput

View File

@@ -0,0 +1,34 @@
import { BIG_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import { TSearchParams } from '@/types'
import NormalFeed from '../NormalFeed'
import Profile from '../Profile'
import { ProfileListBySearch } from '../ProfileListBySearch'
import Relay from '../Relay'
import TrendingNotes from '../TrendingNotes'
export default function SearchResult({ searchParams }: { searchParams: TSearchParams | null }) {
if (!searchParams) {
return <TrendingNotes />
}
if (searchParams.type === 'profile') {
return <Profile id={searchParams.search} />
}
if (searchParams.type === 'profiles') {
return <ProfileListBySearch search={searchParams.search} />
}
if (searchParams.type === 'notes') {
return (
<NormalFeed
subRequests={[{ urls: SEARCHABLE_RELAY_URLS, filter: { search: searchParams.search } }]}
/>
)
}
if (searchParams.type === 'hashtag') {
return (
<NormalFeed
subRequests={[{ urls: BIG_RELAY_URLS, filter: { '#t': [searchParams.search] } }]}
/>
)
}
return <Relay url={searchParams.search} />
}

View File

@@ -1,24 +1,17 @@
import { usePrimaryPage } from '@/PageManager'
import { Search } from 'lucide-react'
import { useState } from 'react'
import { SearchDialog } from '../SearchDialog'
import SidebarItem from './SidebarItem'
export default function SearchButton() {
const [open, setOpen] = useState(false)
const { navigate, current, display } = usePrimaryPage()
return (
<>
<SidebarItem
title="Search"
description="Search"
onClick={(e) => {
e.stopPropagation()
setOpen(true)
}}
>
<Search strokeWidth={3} />
</SidebarItem>
<SearchDialog open={open} setOpen={setOpen} />
</>
<SidebarItem
title="Search"
onClick={() => navigate('search')}
active={current === 'search' && display}
>
<Search strokeWidth={3} />
</SidebarItem>
)
}

View File

@@ -0,0 +1,92 @@
import NoteCard, { NoteCardLoadingSkeleton } from '@/components/NoteCard'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service'
import { NostrEvent } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
const SHOW_COUNT = 10
export default function TrendingNotes() {
const { t } = useTranslation()
const { isEventDeleted } = useDeletedEvent()
const { hideUntrustedNotes, isUserTrusted } = useUserTrust()
const [trendingNotes, setTrendingNotes] = useState<NostrEvent[]>([])
const [showCount, setShowCount] = useState(10)
const [loading, setLoading] = useState(true)
const bottomRef = useRef<HTMLDivElement>(null)
const filteredEvents = useMemo(() => {
const idSet = new Set<string>()
return trendingNotes.slice(0, showCount).filter((evt) => {
if (isEventDeleted(evt)) return false
if (hideUntrustedNotes && !isUserTrusted(evt.pubkey)) return false
const id = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id
if (idSet.has(id)) {
return false
}
idSet.add(id)
return true
})
}, [trendingNotes, hideUntrustedNotes, showCount, isEventDeleted])
useEffect(() => {
const fetchTrendingPosts = async () => {
setLoading(true)
const events = await client.fetchTrendingNotes()
setTrendingNotes(events)
setLoading(false)
}
fetchTrendingPosts()
}, [])
useEffect(() => {
if (showCount >= trendingNotes.length) return
const options = {
root: null,
rootMargin: '10px',
threshold: 0.1
}
const observerInstance = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
setShowCount((prev) => prev + SHOW_COUNT)
}
}, options)
const currentBottomRef = bottomRef.current
if (currentBottomRef) {
observerInstance.observe(currentBottomRef)
}
return () => {
if (observerInstance && currentBottomRef) {
observerInstance.unobserve(currentBottomRef)
}
}
}, [loading, trendingNotes, showCount])
return (
<div className="min-h-screen">
<div className="sticky top-12 h-12 px-4 flex flex-col justify-center text-lg font-bold bg-background z-30 border-b">
{t('Trending Notes')}
</div>
{filteredEvents.map((event) => (
<NoteCard key={event.id} className="w-full" event={event} />
))}
{showCount < trendingNotes.length || loading ? (
<div ref={bottomRef}>
<NoteCardLoadingSkeleton />
</div>
) : (
<div className="text-center text-sm text-muted-foreground mt-2">{t('no more notes')}</div>
)}
</div>
)
}

View File

@@ -24,7 +24,7 @@ const buttonVariants = cva(
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
'titlebar-icon': 'h-10 w-10 rounded-lg [&_svg]:size-5'
'titlebar-icon': 'h-10 w-10 shrink-0 rounded-lg [&_svg]:size-5'
}
},
defaultVariants: {