fix(search): polish default sorting and loading states

This commit is contained in:
yun-zhi-ztl 2026-03-15 13:44:07 +08:00
parent 091a5fe190
commit cc3e45dc30
9 changed files with 78 additions and 29 deletions

View file

@ -129,7 +129,7 @@ const searchRoute = createRoute({
validateSearch: (search: Record<string, unknown>) => {
return {
q: (search.q as string) || '',
sort: (search.sort as string) || 'relevance',
sort: (search.sort as string) || 'newest',
page: Number(search.page) || 0,
starredOnly: search.starredOnly === true || search.starredOnly === 'true',
}

View file

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Loader2, Search, X } from 'lucide-react'
import { Input } from '@/shared/ui/input'
import { Button } from '@/shared/ui/button'
@ -7,11 +8,12 @@ interface SearchBarProps {
defaultValue?: string
value?: string
placeholder?: string
isSearching?: boolean
onChange?: (query: string) => void
onSearch?: (query: string) => void
}
export function SearchBar({ defaultValue = '', value, placeholder, onChange, onSearch }: SearchBarProps) {
export function SearchBar({ defaultValue = '', value, placeholder, isSearching = false, onChange, onSearch }: SearchBarProps) {
const { t } = useTranslation()
const [query, setQuery] = useState(defaultValue)
const isControlled = value !== undefined
@ -37,32 +39,36 @@ export function SearchBar({ defaultValue = '', value, placeholder, onChange, onS
}
}
const handleClear = () => {
handleChange('')
onSearch?.('')
}
return (
<form onSubmit={handleSubmit} className="flex gap-3 glass-strong p-2 rounded-xl">
<div className="relative flex-1">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground pointer-events-none"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground pointer-events-none" />
<Input
type="text"
value={currentQuery}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder || t('searchBar.placeholder')}
className="pl-10 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-12"
className="pl-10 pr-10 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 h-12"
/>
{currentQuery ? (
<button
type="button"
onClick={handleClear}
className="absolute right-3 top-1/2 inline-flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-secondary/70 hover:text-foreground"
aria-label={t('searchBar.clear')}
title={t('searchBar.clear')}
>
<X className="h-4 w-4" />
</button>
) : null}
</div>
<Button type="submit" size="lg" className="px-8">
{t('searchBar.button')}
<Button type="submit" size="lg" className="px-8 min-w-28" disabled={isSearching}>
{isSearching ? <Loader2 className="h-4 w-4 animate-spin" /> : t('searchBar.button')}
</Button>
</form>
)

View file

@ -6,6 +6,7 @@ import { useStar, useToggleStar } from '@/features/social/use-star'
import { ConfirmDialog } from '@/shared/components/confirm-dialog'
import { Card } from '@/shared/ui/card'
import { NamespaceBadge } from '@/shared/components/namespace-badge'
import { formatCompactCount } from '@/shared/lib/number-format'
import { Bookmark } from 'lucide-react'
interface SkillCardProps {
@ -83,7 +84,7 @@ export function SkillCard({ skill, onClick, highlightStarred = true }: SkillCard
<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>
{skill.downloadCount}
{formatCompactCount(skill.downloadCount)}
</span>
<span
className={`flex items-center gap-1 ${showStarredBadge ? 'font-semibold text-primary' : ''}`}

View file

@ -103,11 +103,13 @@
"noStarredSkills": "You have not starred any skills yet",
"enterKeyword": "Please enter a search keyword",
"results": "{{count}} skills found",
"resultCount": "Found <1>{{count}}</1> results"
"resultCount": "Found <1>{{count}}</1> results",
"loadingMore": "Updating search results..."
},
"searchBar": {
"placeholder": "Search skills...",
"button": "Search"
"button": "Search",
"clear": "Clear search"
},
"login": {
"title": "Login to SkillHub",

View file

@ -103,11 +103,13 @@
"noStarredSkills": "你还没有收藏任何技能",
"enterKeyword": "请输入搜索关键词",
"results": "找到 {{count}} 个技能",
"resultCount": "找到 <1>{{count}}</1> 个结果"
"resultCount": "找到 <1>{{count}}</1> 个结果",
"loadingMore": "正在更新搜索结果..."
},
"searchBar": {
"placeholder": "搜索技能...",
"button": "搜索"
"button": "搜索",
"clear": "清空搜索"
},
"login": {
"title": "登录 SkillHub",

View file

@ -4,6 +4,7 @@ import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import { EmptyState } from '@/shared/components/empty-state'
import { useMySkills } from '@/shared/hooks/use-skill-queries'
import { formatCompactCount } from '@/shared/lib/number-format'
export function MySkillsPage() {
const navigate = useNavigate()
@ -86,7 +87,7 @@ export function MySkillsPage() {
<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>
{skill.downloadCount}
{formatCompactCount(skill.downloadCount)}
</span>
</div>
</div>

View file

@ -1,6 +1,7 @@
import { startTransition, useEffect, useState } from 'react'
import { useNavigate, useSearch } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Loader2 } from 'lucide-react'
import type { SkillSummary } from '@/api/types'
import { useAuth } from '@/features/auth/use-auth'
import { SearchBar } from '@/features/search/search-bar'
@ -44,7 +45,7 @@ export function SearchPage() {
const { isAuthenticated } = useAuth()
const q = searchParams.q || ''
const sort = searchParams.sort || 'relevance'
const sort = searchParams.sort || 'newest'
const page = searchParams.page ?? 0
const starredOnly = searchParams.starredOnly ?? false
const [queryInput, setQueryInput] = useState(q)
@ -53,14 +54,18 @@ export function SearchPage() {
setQueryInput(q)
}, [q])
const { data, isLoading } = useSearchSkills({
const { data, isLoading, isFetching } = useSearchSkills({
q,
sort,
page,
size: PAGE_SIZE,
starredOnly,
})
const { data: starredSkills, isLoading: isLoadingStarred } = useMyStars(starredOnly && isAuthenticated)
const {
data: starredSkills,
isLoading: isLoadingStarred,
isFetching: isFetchingStarred,
} = useMyStars(starredOnly && isAuthenticated)
useEffect(() => {
const normalizedQuery = queryInput.trim()
@ -131,13 +136,19 @@ export function SearchPage() {
: 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)
return (
<div className="space-y-8 animate-fade-up">
{/* Search Bar */}
<div className="max-w-3xl mx-auto">
<SearchBar value={queryInput} onChange={setQueryInput} onSearch={handleSearch} />
<SearchBar
value={queryInput}
isSearching={isUpdatingResults}
onChange={setQueryInput}
onSearch={handleSearch}
/>
</div>
{/* Sort And Filters */}
@ -177,6 +188,13 @@ export function SearchPage() {
)}
</div>
{isUpdatingResults ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{t('search.loadingMore')}</span>
</div>
) : null}
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-muted-foreground">{t('search.filters.label')}</span>
<Button

View file

@ -10,6 +10,7 @@ import { StarButton } from '@/features/social/star-button'
import { useAuth } from '@/features/auth/use-auth'
import { adminApi } from '@/api/client'
import { formatLocalDateTime } from '@/shared/lib/date-time'
import { formatCompactCount } from '@/shared/lib/number-format'
import { NamespaceBadge } from '@/shared/components/namespace-badge'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/shared/ui/tabs'
import { Button } from '@/shared/ui/button'
@ -228,7 +229,7 @@ export function SkillDetailPage() {
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">{t('skillDetail.downloads')}</div>
<div className="font-semibold text-foreground">{skill.downloadCount.toLocaleString()}</div>
<div className="font-semibold text-foreground">{formatCompactCount(skill.downloadCount)}</div>
</div>
<div className="h-px bg-border/40" />

View file

@ -0,0 +1,18 @@
export function formatCompactCount(value: number): string {
if (value >= 1_000_000) {
const millions = value / 1_000_000
return `${stripTrailingZero(millions)}M`
}
if (value >= 1_000) {
const thousands = value / 1_000
return `${stripTrailingZero(thousands)}K`
}
return String(value)
}
function stripTrailingZero(value: number): string {
const formatted = value >= 10 ? value.toFixed(0) : value.toFixed(1)
return formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted
}