Files
awesome-app/app/search/page.tsx
valknarness bbe7f9a22d feat: implement comprehensive UI/UX improvements
This commit implements 7 requested improvements:

1. Add footer component to all pages
   - Created reusable AppFooter component
   - Added to layout.tsx with flex-1 main container
   - Includes links to Legal, Disclaimer, Imprint, GitHub

2. Change search highlight from yellow to more discreet color
   - Updated mark styling with purple theme colors
   - Uses color-mix for theme-aware transparency
   - Added subtle border-bottom for better visibility

3. Strip markdown from search results
   - Created stripMarkdown function
   - Removes HTML tags, markdown links, images, formatting
   - Shows clean text descriptions only

4. Add page number links to pagination
   - Created getPageNumbers function with smart ellipsis
   - Shows current page ±2 pages with first/last always visible
   - Example: 1 ... 5 6 [7] 8 9 ... 20

5. Adjust README badge display to be inline
   - Custom marked renderer detects badges (shields.io, badgen, etc.)
   - Applies inline-block with !my-0 !mx-1 align-middle classes
   - Badges now display inline in paragraph flow

6. Fix relative image URLs in READMEs
   - Custom image renderer converts relative to absolute GitHub URLs
   - Handles ./path and path patterns
   - Converts to raw.githubusercontent.com URLs
   - Also handles /blob/ URLs conversion

7. Fix command menu highlight contrast
   - Reuses mark styling from search highlights
   - Consistent purple theme colors across app

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 09:27:01 +01:00

450 lines
16 KiB
TypeScript

'use client'
import * as React from 'react'
import { Suspense } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { Search, Star, Filter, SlidersHorizontal, ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import { ScrollArea } from '@/components/ui/scroll-area'
interface SearchResult {
repository_id: number
repository_name: string
repository_url: string
description: string | null
stars: number | null
language: string | null
topics: string | null
awesome_list_name: string | null
awesome_list_category: string | null
snippet: string | null
}
interface SearchResponse {
results: SearchResult[]
total: number
page: number
pageSize: number
totalPages: number
}
interface StatsResponse {
languages: { name: string; count: number }[]
categories: { name: string; count: number }[]
}
function SearchPageContent() {
const searchParams = useSearchParams()
const router = useRouter()
const [query, setQuery] = React.useState(searchParams.get('q') || '')
const [results, setResults] = React.useState<SearchResponse | null>(null)
const [stats, setStats] = React.useState<StatsResponse | null>(null)
const [loading, setLoading] = React.useState(false)
const [filters, setFilters] = React.useState({
language: searchParams.get('language') || '',
category: searchParams.get('category') || '',
minStars: searchParams.get('minStars') || '',
sortBy: (searchParams.get('sortBy') as 'relevance' | 'stars' | 'recent') || 'relevance'
})
// Fetch stats for filters
React.useEffect(() => {
fetch('/api/stats')
.then(res => res.json())
.then(data => setStats(data))
.catch(err => console.error('Failed to fetch stats:', err))
}, [])
// Perform search
const performSearch = React.useCallback((searchQuery: string, page = 1) => {
if (!searchQuery.trim()) {
setResults(null)
return
}
setLoading(true)
const params = new URLSearchParams({
q: searchQuery,
page: page.toString(),
...Object.fromEntries(
Object.entries(filters).filter(([_, v]) => v !== '')
)
})
fetch(`/api/search?${params}`)
.then(res => res.json())
.then(data => {
// Check if response is an error
if (data.error || !data.results) {
console.error('Search API error:', data.error || 'Invalid response')
setResults(null)
return
}
setResults(data)
// Update URL
router.push(`/search?${params}`)
})
.catch(err => {
console.error('Search failed:', err)
setResults(null)
})
.finally(() => setLoading(false))
}, [filters, router])
// Search on query change (debounced)
React.useEffect(() => {
const initialQuery = searchParams.get('q')
if (initialQuery) {
setQuery(initialQuery)
performSearch(initialQuery, parseInt(searchParams.get('page') || '1'))
}
}, []) // Only on mount
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
performSearch(query)
}
const handleFilterChange = (key: string, value: string) => {
setFilters(prev => ({ ...prev, [key]: value }))
}
// Strip markdown and HTML from descriptions
const stripMarkdown = (text: string | null): string => {
if (!text) return ''
return text
.replace(/<[^>]*>/g, '') // Remove HTML tags
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Convert [text](url) to text
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') // Convert ![alt](url) to alt text
.replace(/[*_~`#]/g, '') // Remove markdown formatting chars
.replace(/\s+/g, ' ') // Normalize whitespace
.trim()
}
// Generate page numbers for pagination
const getPageNumbers = (current: number, total: number): (number | string)[] => {
const delta = 2
const range: (number | string)[] = []
const rangeWithDots: (number | string)[] = []
let l: number | undefined
for (let i = 1; i <= total; i++) {
if (i === 1 || i === total || (i >= current - delta && i <= current + delta)) {
range.push(i)
}
}
for (const i of range) {
if (l !== undefined && typeof i === 'number') {
if (i - l === 2) {
rangeWithDots.push(l + 1)
} else if (i - l !== 1) {
rangeWithDots.push('...')
}
}
rangeWithDots.push(i)
if (typeof i === 'number') {
l = i
}
}
return rangeWithDots
}
React.useEffect(() => {
if (query) {
performSearch(query)
}
}, [filters.sortBy, filters.language, filters.category, filters.minStars])
return (
<div className="min-h-screen bg-linear-to-br from-background via-background to-primary/5">
{/* Header */}
<div className="border-b bg-background/80 backdrop-blur-sm">
<div className="mx-auto max-w-7xl px-6 py-6">
<h1 className="gradient-text mb-4 text-3xl font-bold">Search Awesome</h1>
{/* Search Form */}
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search repositories, topics, descriptions..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-10 text-base"
autoFocus
/>
</div>
<Button type="submit" className="btn-awesome" disabled={loading}>
<Search className="mr-2 h-4 w-4" />
Search
</Button>
</form>
{/* Quick Filters */}
<div className="mt-4 flex flex-wrap items-center gap-2">
<Select value={filters.sortBy} onValueChange={(v: string) => handleFilterChange('sortBy', v)}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="relevance">Relevance</SelectItem>
<SelectItem value="stars">Most Stars</SelectItem>
<SelectItem value="recent">Recent</SelectItem>
</SelectContent>
</Select>
{/* Mobile Filter Sheet */}
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="default">
<SlidersHorizontal className="mr-2 h-4 w-4" />
Filters
{(filters.language || filters.category || filters.minStars) && (
<Badge variant="secondary" className="ml-2">
{[filters.language, filters.category, filters.minStars].filter(Boolean).length}
</Badge>
)}
</Button>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Filters</SheetTitle>
<SheetDescription>
Refine your search results
</SheetDescription>
</SheetHeader>
<div className="mt-6 space-y-4">
<div>
<label className="mb-2 block text-sm font-medium">Language</label>
<Select value={filters.language || 'all'} onValueChange={(v: string) => handleFilterChange('language', v === 'all' ? '' : v)}>
<SelectTrigger>
<SelectValue placeholder="All languages" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All languages</SelectItem>
{stats?.languages.slice(0, 20).map(lang => (
<SelectItem key={lang.name} value={lang.name}>
{lang.name} ({lang.count})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="mb-2 block text-sm font-medium">Category</label>
<Select value={filters.category || 'all'} onValueChange={(v: string) => handleFilterChange('category', v === 'all' ? '' : v)}>
<SelectTrigger>
<SelectValue placeholder="All categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All categories</SelectItem>
{stats?.categories.map(cat => (
<SelectItem key={cat.name} value={cat.name}>
{cat.name} ({cat.count})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="mb-2 block text-sm font-medium">Minimum Stars</label>
<Select value={filters.minStars || 'any'} onValueChange={(v: string) => handleFilterChange('minStars', v === 'any' ? '' : v)}>
<SelectTrigger>
<SelectValue placeholder="Any" />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">Any</SelectItem>
<SelectItem value="100">100+</SelectItem>
<SelectItem value="500">500+</SelectItem>
<SelectItem value="1000">1,000+</SelectItem>
<SelectItem value="5000">5,000+</SelectItem>
<SelectItem value="10000">10,000+</SelectItem>
</SelectContent>
</Select>
</div>
<Button
variant="outline"
className="w-full"
onClick={() => setFilters({ language: '', category: '', minStars: '', sortBy: 'relevance' })}
>
Clear Filters
</Button>
</div>
</SheetContent>
</Sheet>
</div>
</div>
</div>
{/* Results */}
<div className="mx-auto max-w-7xl px-6 py-8">
{loading && (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="rounded-lg border bg-card p-6">
<Skeleton className="mb-2 h-6 w-2/3" />
<Skeleton className="mb-4 h-4 w-full" />
<div className="flex gap-2">
<Skeleton className="h-6 w-20" />
<Skeleton className="h-6 w-16" />
</div>
</div>
))}
</div>
)}
{!loading && results && results.total !== undefined && (
<>
<div className="mb-6 text-muted-foreground">
Found <strong>{results.total.toLocaleString()}</strong> results
{query && <> for &quot;<strong>{query}</strong>&quot;</>}
</div>
<div className="space-y-4">
{results.results.map((result) => (
<div key={result.repository_id} className="card-awesome rounded-lg bg-card p-6">
<div className="mb-2 flex items-start justify-between gap-4">
<h3 className="text-xl font-semibold">
<a
href={`/repository/${result.repository_id}`}
className="text-primary hover:text-primary/80"
>
{result.repository_name}
</a>
<a
href={result.repository_url}
target="_blank"
rel="noopener noreferrer"
className="ml-2 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
title="View on GitHub"
>
<ExternalLink className="h-4 w-4" />
</a>
</h3>
{result.stars !== null && (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Star className="h-4 w-4 fill-current text-accent" />
<span>{result.stars.toLocaleString()}</span>
</div>
)}
</div>
{result.description && (
<p className="mb-3 text-muted-foreground">{stripMarkdown(result.description)}</p>
)}
{result.snippet && (
<div
className="mb-3 rounded border-l-2 border-primary/40 bg-muted/50 p-3 text-sm"
dangerouslySetInnerHTML={{ __html: result.snippet }}
/>
)}
<div className="flex flex-wrap gap-2">
{result.language && (
<Badge variant="secondary">{result.language}</Badge>
)}
{result.awesome_list_category && (
<Badge variant="outline">{result.awesome_list_category}</Badge>
)}
{result.awesome_list_name && (
<Badge variant="outline" className="text-xs">
{result.awesome_list_name}
</Badge>
)}
</div>
</div>
))}
</div>
{/* Pagination */}
{results.totalPages > 1 && (
<div className="mt-8 flex flex-wrap items-center justify-center gap-2">
<Button
variant="outline"
size="icon"
disabled={results.page === 1}
onClick={() => performSearch(query, results.page - 1)}
title="Previous page"
>
<ChevronLeft className="h-4 w-4" />
</Button>
{getPageNumbers(results.page, results.totalPages).map((pageNum, idx) =>
pageNum === '...' ? (
<span key={`ellipsis-${idx}`} className="px-2 text-muted-foreground">
...
</span>
) : (
<Button
key={pageNum}
variant={pageNum === results.page ? 'default' : 'outline'}
size="icon"
onClick={() => performSearch(query, pageNum as number)}
className={pageNum === results.page ? 'btn-awesome' : ''}
>
{pageNum}
</Button>
)
)}
<Button
variant="outline"
size="icon"
disabled={results.page === results.totalPages}
onClick={() => performSearch(query, results.page + 1)}
title="Next page"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</>
)}
{!loading && !results && query && (
<div className="py-12 text-center">
<p className="text-lg text-muted-foreground">
Enter a search query to find awesome repositories
</p>
</div>
)}
</div>
</div>
)
}
export default function SearchPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<SearchPageContent />
</Suspense>
)
}