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>
This commit is contained in:
@@ -237,6 +237,21 @@ kbd {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Search Highlight - Discreet styling */
|
||||
mark {
|
||||
background: color-mix(in oklab, var(--primary) 15%, transparent);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
border-bottom: 2px solid color-mix(in oklab, var(--primary) 40%, transparent);
|
||||
}
|
||||
|
||||
.dark mark {
|
||||
background: color-mix(in oklab, var(--primary) 20%, transparent);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
/* Loading Spinner */
|
||||
@keyframes spin-awesome {
|
||||
from {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Toaster } from '@/components/ui/sonner'
|
||||
import { WorkerProvider } from '@/components/providers/worker-provider'
|
||||
import { CommandProvider } from '@/components/providers/command-provider'
|
||||
import { AppHeader } from '@/components/layout/app-header'
|
||||
import { AppFooter } from '@/components/layout/app-footer'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
@@ -203,8 +204,11 @@ export default function RootLayout({
|
||||
>
|
||||
<WorkerProvider>
|
||||
<CommandProvider>
|
||||
<AppHeader />
|
||||
{children}
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<AppHeader />
|
||||
<main className="flex-1">{children}</main>
|
||||
<AppFooter />
|
||||
</div>
|
||||
</CommandProvider>
|
||||
</WorkerProvider>
|
||||
<Toaster />
|
||||
|
||||
33
app/page.tsx
33
app/page.tsx
@@ -237,39 +237,6 @@ export default function Home() {
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-border/40 px-6 py-12 lg:px-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="flex flex-col items-center justify-between gap-4 sm:flex-row">
|
||||
<div className="text-center sm:text-left">
|
||||
<div className="gradient-text mb-2 text-xl font-bold">Awesome</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Built with 💜💗💛 and maximum awesomeness
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-6 text-sm">
|
||||
<Link href="/legal" className="text-muted-foreground hover:text-primary">
|
||||
Legal
|
||||
</Link>
|
||||
<Link href="/disclaimer" className="text-muted-foreground hover:text-primary">
|
||||
Disclaimer
|
||||
</Link>
|
||||
<Link href="/imprint" className="text-muted-foreground hover:text-primary">
|
||||
Imprint
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/sindresorhus/awesome"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-primary"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export default async function ReadmePage({ params }: PageProps) {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ReadmeViewer content={data.content} />
|
||||
<ReadmeViewer content={data.content} repositoryUrl={data.metadata.url} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -192,7 +192,7 @@ export default function RepositoryDetailPage() {
|
||||
<div className="mx-auto max-w-5xl px-6 py-8">
|
||||
{data.readme?.content ? (
|
||||
<div className="rounded-xl border border-border bg-card p-8 shadow-sm">
|
||||
<ReadmeViewer content={data.readme.content} />
|
||||
<ReadmeViewer content={data.readme.content} repositoryUrl={data.url} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border bg-muted/30 p-12 text-center">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from 'react'
|
||||
import { Suspense } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { Search, Star, Filter, SlidersHorizontal, ExternalLink } from 'lucide-react'
|
||||
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'
|
||||
@@ -129,6 +129,48 @@ function SearchPageContent() {
|
||||
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  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)
|
||||
@@ -314,7 +356,7 @@ function SearchPageContent() {
|
||||
</div>
|
||||
|
||||
{result.description && (
|
||||
<p className="mb-3 text-muted-foreground">{result.description}</p>
|
||||
<p className="mb-3 text-muted-foreground">{stripMarkdown(result.description)}</p>
|
||||
)}
|
||||
|
||||
{result.snippet && (
|
||||
@@ -343,23 +385,43 @@ function SearchPageContent() {
|
||||
|
||||
{/* Pagination */}
|
||||
{results.totalPages > 1 && (
|
||||
<div className="mt-8 flex items-center justify-center gap-2">
|
||||
<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"
|
||||
>
|
||||
Previous
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="mx-4 text-sm text-muted-foreground">
|
||||
Page {results.page} of {results.totalPages}
|
||||
</span>
|
||||
|
||||
{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"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
37
components/layout/app-footer.tsx
Normal file
37
components/layout/app-footer.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export function AppFooter() {
|
||||
return (
|
||||
<footer className="border-t border-border/40 px-6 py-12 lg:px-8">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="flex flex-col items-center justify-between gap-4 sm:flex-row">
|
||||
<div className="text-center sm:text-left">
|
||||
<div className="gradient-text mb-2 text-xl font-bold">Awesome</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Built with 💜💗💛 and maximum awesomeness
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-6 text-sm">
|
||||
<Link href="/legal" className="text-muted-foreground hover:text-primary">
|
||||
Legal
|
||||
</Link>
|
||||
<Link href="/disclaimer" className="text-muted-foreground hover:text-primary">
|
||||
Disclaimer
|
||||
</Link>
|
||||
<Link href="/imprint" className="text-muted-foreground hover:text-primary">
|
||||
Imprint
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/sindresorhus/awesome"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-primary"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -8,9 +8,10 @@ import 'highlight.js/styles/github-dark.css'
|
||||
|
||||
interface ReadmeViewerProps {
|
||||
content: string
|
||||
repositoryUrl?: string
|
||||
}
|
||||
|
||||
export function ReadmeViewer({ content }: ReadmeViewerProps) {
|
||||
export function ReadmeViewer({ content, repositoryUrl }: ReadmeViewerProps) {
|
||||
const [html, setHtml] = React.useState('')
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -30,13 +31,59 @@ export function ReadmeViewer({ content }: ReadmeViewerProps) {
|
||||
breaks: true,
|
||||
})
|
||||
|
||||
// Custom renderer to fix relative image URLs
|
||||
if (repositoryUrl) {
|
||||
const renderer = {
|
||||
image(href: string, title: string | null, text: string) {
|
||||
let imgSrc = href
|
||||
|
||||
// Convert GitHub URLs to raw content URLs
|
||||
if (repositoryUrl.includes('github.com')) {
|
||||
const match = repositoryUrl.match(/github\.com\/([^\/]+)\/([^\/]+)/)
|
||||
if (match) {
|
||||
const [, owner, repo] = match
|
||||
const cleanRepo = repo.replace(/\.git$/, '')
|
||||
|
||||
// Handle relative URLs
|
||||
if (!imgSrc.startsWith('http://') && !imgSrc.startsWith('https://') && !imgSrc.startsWith('//')) {
|
||||
// Remove leading ./
|
||||
imgSrc = imgSrc.replace(/^\.\//, '')
|
||||
// Build raw GitHub URL (main/master branch assumed)
|
||||
imgSrc = `https://raw.githubusercontent.com/${owner}/${cleanRepo}/master/${imgSrc}`
|
||||
}
|
||||
// Handle GitHub blob URLs - convert to raw
|
||||
else if (imgSrc.includes('github.com') && imgSrc.includes('/blob/')) {
|
||||
imgSrc = imgSrc.replace('github.com', 'raw.githubusercontent.com').replace('/blob/', '/')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const titleAttr = title ? ` title="${title}"` : ''
|
||||
|
||||
// Check if it's a badge (shields.io, badgen, etc.)
|
||||
const isBadge = imgSrc.includes('shields.io') ||
|
||||
imgSrc.includes('badgen.net') ||
|
||||
imgSrc.includes('badge') ||
|
||||
imgSrc.includes('img.shields') ||
|
||||
imgSrc.match(/\/badges?\//)
|
||||
|
||||
if (isBadge) {
|
||||
return `<img src="${imgSrc}" alt="${text}" ${titleAttr} class="inline-block !my-0 !mx-1 align-middle" />`
|
||||
}
|
||||
|
||||
return `<img src="${imgSrc}" alt="${text}" ${titleAttr} />`
|
||||
}
|
||||
}
|
||||
marked.use({ renderer })
|
||||
}
|
||||
|
||||
// Parse markdown
|
||||
const parseMarkdown = async () => {
|
||||
const result = await marked.parse(content)
|
||||
setHtml(result)
|
||||
}
|
||||
parseMarkdown()
|
||||
}, [content])
|
||||
}, [content, repositoryUrl])
|
||||
|
||||
return (
|
||||
<article
|
||||
|
||||
Reference in New Issue
Block a user