fix(web): refine search empty state and card interaction

This commit is contained in:
huishi3 2026-04-09 09:24:39 +05:30
parent 2167981392
commit 83b90c4334
3 changed files with 101 additions and 55 deletions

View file

@ -21,59 +21,72 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
const { data: starStatus } = useStar(skill.id, highlightStarred && isAuthenticated)
const showStarredHighlight = highlightStarred && isAuthenticated && starStatus?.starred
const headlineVersion = getHeadlineVersion(skill)
const isInteractive = typeof onClick === 'function'
return (
<Card
className="h-full p-5 cursor-pointer group relative overflow-hidden bg-white border shadow-sm transition-shadow hover:shadow-md"
style={{ borderColor: 'hsl(var(--border-card))' }}
onClick={onClick}
>
<div className="flex h-full flex-col">
<div className="flex items-start justify-between mb-3">
<div className="space-y-2">
<h3 className="font-semibold text-lg group-hover:text-primary transition-colors" style={{ color: 'hsl(var(--foreground))' }}>
{skill.displayName}
</h3>
</div>
<div className="flex items-center gap-2">
<NamespaceBadge type="TEAM" name={`@${skill.namespace}`} />
</div>
className="h-full p-5 cursor-pointer group relative overflow-hidden bg-white border shadow-sm transition-shadow hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2"
style={{ borderColor: 'hsl(var(--border-card))' }}
onClick={onClick}
onKeyDown={(event) => {
if (!isInteractive) {
return
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onClick()
}
}}
role={isInteractive ? 'link' : undefined}
tabIndex={isInteractive ? 0 : undefined}
>
<div className="flex h-full flex-col">
<div className="flex items-start justify-between mb-3">
<div className="space-y-2">
<h3 className="font-semibold text-lg group-hover:text-primary transition-colors" style={{ color: 'hsl(var(--foreground))' }}>
{skill.displayName}
</h3>
</div>
{skill.summary && (
<p className="text-sm text-muted-foreground mb-4 line-clamp-2 leading-relaxed">
{skill.summary}
</p>
)}
<div className="mt-auto flex items-center gap-4 text-xs text-muted-foreground">
{headlineVersion && (
<span className="px-2.5 py-1 rounded-full bg-secondary/60 font-mono">
v{headlineVersion.version}
</span>
)}
<span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
</svg>
{formatCompactCount(skill.downloadCount)}
</span>
<span
className={`flex items-center gap-1 ${showStarredHighlight ? 'font-semibold text-primary' : ''}`}
>
<Bookmark className={`w-3.5 h-3.5 ${showStarredHighlight ? 'fill-current' : ''}`} />
{skill.starCount}
</span>
{skill.ratingAvg !== undefined && skill.ratingCount > 0 && (
<span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5 text-primary" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
{skill.ratingAvg.toFixed(1)}
</span>
)}
<div className="flex items-center gap-2">
<NamespaceBadge type="TEAM" name={`@${skill.namespace}`} />
</div>
</div>
</Card>
{skill.summary && (
<p className="text-sm text-muted-foreground mb-4 line-clamp-2 leading-relaxed">
{skill.summary}
</p>
)}
<div className="mt-auto flex items-center gap-4 text-xs text-muted-foreground">
{headlineVersion && (
<span className="px-2.5 py-1 rounded-full bg-secondary/60 font-mono">
v{headlineVersion.version}
</span>
)}
<span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
</svg>
{formatCompactCount(skill.downloadCount)}
</span>
<span
className={`flex items-center gap-1 ${showStarredHighlight ? 'font-semibold text-primary' : ''}`}
>
<Bookmark className={`w-3.5 h-3.5 ${showStarredHighlight ? 'fill-current' : ''}`} />
{skill.starCount}
</span>
{skill.ratingAvg !== undefined && skill.ratingCount > 0 && (
<span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5 text-primary" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
{skill.ratingAvg.toFixed(1)}
</span>
)}
</div>
</div>
</Card>
)
}

View file

@ -1,4 +1,4 @@
import { startTransition, useEffect, useState } from 'react'
import { startTransition, useEffect, useRef, useState } from 'react'
import { useNavigate, useSearch } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Loader2 } from 'lucide-react'
@ -60,11 +60,38 @@ export function SearchPage() {
const page = searchParams.page ?? 0
const starredOnly = searchParams.starredOnly ?? false
const [queryInput, setQueryInput] = useState(q)
const previousPageRef = useRef(page)
useEffect(() => {
setQueryInput(q)
}, [q])
useEffect(() => {
if (previousPageRef.current !== page) {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
let secondFrame = 0
const firstFrame = window.requestAnimationFrame(() => {
window.scrollTo({ top: 0, behavior: 'auto' })
secondFrame = window.requestAnimationFrame(() => {
window.scrollTo({ top: 0, behavior: 'auto' })
})
})
previousPageRef.current = page
return () => {
window.cancelAnimationFrame(firstFrame)
if (secondFrame) {
window.cancelAnimationFrame(secondFrame)
}
}
}
previousPageRef.current = page
}, [page])
const { data, isLoading, isFetching } = useSearchSkills({
q,
label: selectedLabel || undefined,
@ -79,6 +106,7 @@ export function SearchPage() {
isLoading: isLoadingStarred,
isFetching: isFetchingStarred,
} = useMyStars(starredOnly && isAuthenticated)
const shouldShowGuidance = !starredOnly && !q && !selectedLabel
useEffect(() => {
// Debounce URL updates while the user is typing so query state stays shareable without
@ -117,6 +145,9 @@ export function SearchPage() {
}
const handlePageChange = (newPage: number) => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
navigate({ to: '/search', search: { q, label: selectedLabel, sort, page: newPage, starredOnly } })
}
@ -154,10 +185,10 @@ export function SearchPage() {
: data
? Math.ceil(data.total / data.size)
: 0
const displayItems = starredOnly ? starredPageItems : (data?.items ?? [])
const isPageLoading = starredOnly ? isLoadingStarred : isLoading
const isUpdatingResults = starredOnly ? isFetchingStarred && !isLoadingStarred : isFetching && !isLoading
const resultCount = starredOnly ? filteredStarredSkills.length : (data?.total ?? 0)
const displayItems = shouldShowGuidance ? [] : (starredOnly ? starredPageItems : (data?.items ?? []))
const isPageLoading = shouldShowGuidance ? false : (starredOnly ? isLoadingStarred : isLoading)
const isUpdatingResults = shouldShowGuidance ? false : (starredOnly ? isFetchingStarred && !isLoadingStarred : isFetching && !isLoading)
const resultCount = shouldShowGuidance ? 0 : (starredOnly ? filteredStarredSkills.length : (data?.total ?? 0))
return (
<div className={APP_SHELL_PAGE_CLASS_NAME}>
@ -265,7 +296,9 @@ export function SearchPage() {
<EmptyState
title={starredOnly ? t('search.noStarredResults') : t('search.noResults')}
description={
starredOnly
shouldShowGuidance
? t('search.enterKeyword')
: starredOnly
? (q ? t('search.noStarredResultsFor', { q }) : t('search.noStarredSkills'))
: (q ? t('search.noResultsFor', { q }) : t('search.enterKeyword'))
}

View file

@ -55,7 +55,7 @@ export function useSearchSkills(params: SearchParams) {
return useQuery({
queryKey: ['skills', 'search', params],
queryFn: () => searchSkills(params),
enabled: params.starredOnly !== true,
enabled: params.starredOnly !== true && Boolean(params.q || params.label),
})
}