a new start
This commit is contained in:
177
components/layout/app-header.tsx
Normal file
177
components/layout/app-header.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { Search, Home, BookOpen, Menu, List as ListIcon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ThemeSwitcher } from '@/components/theme/theme-switcher'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { AwesomeIcon } from '@/components/ui/awesome-icon'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet'
|
||||
import { usePersonalListStore } from '@/lib/personal-list-store'
|
||||
|
||||
export function AppHeader() {
|
||||
const pathname = usePathname()
|
||||
const [isScrolled, setIsScrolled] = React.useState(false)
|
||||
const { items } = usePersonalListStore()
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 10)
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
name: 'Home',
|
||||
href: '/',
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
name: 'Search',
|
||||
href: '/search',
|
||||
icon: Search,
|
||||
},
|
||||
{
|
||||
name: 'Browse',
|
||||
href: '/browse',
|
||||
icon: BookOpen,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'sticky top-0 z-50 w-full border-b transition-all duration-300',
|
||||
isScrolled
|
||||
? 'border-border/40 bg-background/95 shadow-lg backdrop-blur-xl'
|
||||
: 'border-transparent bg-background/80 backdrop-blur-sm'
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between gap-4 px-6">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center gap-2 transition-transform hover:scale-105">
|
||||
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg bg-background p-1.5 shadow-lg ring-1 ring-primary/10 transition-all hover:ring-primary/30 hover:shadow-xl hover:shadow-primary/20">
|
||||
<AwesomeIcon size={32} className="drop-shadow-sm" />
|
||||
</div>
|
||||
<span className="gradient-text hidden text-xl font-bold sm:inline">
|
||||
Awesome
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden items-center gap-1 md:flex">
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = pathname === item.href
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:bg-primary/5 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Right Side */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Search Button - Hidden on search page */}
|
||||
{pathname !== '/search' && (
|
||||
<Link href="/search">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hidden gap-2 border-primary/20 sm:inline-flex"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
<span className="hidden lg:inline">Search</span>
|
||||
<kbd className="hidden rounded bg-muted px-1.5 py-0.5 text-xs lg:inline">
|
||||
⌘K
|
||||
</kbd>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Personal List Button */}
|
||||
<Link href="/my-list">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hidden gap-2 border-primary/20 sm:inline-flex"
|
||||
>
|
||||
<ListIcon className="h-4 w-4" />
|
||||
<span className="hidden lg:inline">My List</span>
|
||||
{items.length > 0 && (
|
||||
<Badge variant="default" className="h-5 min-w-5 px-1 text-xs">
|
||||
{items.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* Theme Switcher */}
|
||||
<ThemeSwitcher />
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<Sheet>
|
||||
<SheetTrigger asChild className="md:hidden">
|
||||
<Button variant="outline" size="icon" className="border-primary/20">
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Toggle menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-[300px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<AwesomeIcon size={20} />
|
||||
<span className="gradient-text">Awesome</span>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="mt-6 flex flex-col gap-2">
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = pathname === item.href
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-4 py-3 text-sm font-medium transition-all',
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:bg-primary/5 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{item.name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
265
components/layout/app-sidebar.tsx
Normal file
265
components/layout/app-sidebar.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import {
|
||||
ChevronRight,
|
||||
Home,
|
||||
Search,
|
||||
Star,
|
||||
BookOpen,
|
||||
Code,
|
||||
Layers,
|
||||
Package,
|
||||
Globe,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AwesomeIcon } from '@/components/ui/awesome-icon'
|
||||
|
||||
interface Category {
|
||||
name: string
|
||||
icon: React.ReactNode
|
||||
lists: ListItem[]
|
||||
expanded?: boolean
|
||||
}
|
||||
|
||||
interface ListItem {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
stars?: number
|
||||
}
|
||||
|
||||
export function AppSidebar() {
|
||||
const pathname = usePathname()
|
||||
const [searchQuery, setSearchQuery] = React.useState('')
|
||||
const [expandedCategories, setExpandedCategories] = React.useState<Set<string>>(
|
||||
new Set(['Front-end Development'])
|
||||
)
|
||||
|
||||
// Mock data - will be replaced with actual API call
|
||||
const categories: Category[] = [
|
||||
{
|
||||
name: 'Front-end Development',
|
||||
icon: <Code className="h-4 w-4" />,
|
||||
lists: [
|
||||
{ id: 'react', name: 'React', url: '/list/react', stars: 45000 },
|
||||
{ id: 'vue', name: 'Vue.js', url: '/list/vue', stars: 38000 },
|
||||
{ id: 'angular', name: 'Angular', url: '/list/angular', stars: 32000 },
|
||||
{ id: 'svelte', name: 'Svelte', url: '/list/svelte', stars: 28000 },
|
||||
{ id: 'css', name: 'CSS', url: '/list/css', stars: 25000 },
|
||||
{ id: 'tailwind', name: 'Tailwind CSS', url: '/list/tailwind', stars: 22000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Back-end Development',
|
||||
icon: <Layers className="h-4 w-4" />,
|
||||
lists: [
|
||||
{ id: 'nodejs', name: 'Node.js', url: '/list/nodejs', stars: 38000 },
|
||||
{ id: 'python', name: 'Python', url: '/list/python', stars: 52000 },
|
||||
{ id: 'go', name: 'Go', url: '/list/go', stars: 35000 },
|
||||
{ id: 'rust', name: 'Rust', url: '/list/rust', stars: 30000 },
|
||||
{ id: 'java', name: 'Java', url: '/list/java', stars: 28000 },
|
||||
{ id: 'dotnet', name: '.NET', url: '/list/dotnet', stars: 24000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Programming Languages',
|
||||
icon: <Code className="h-4 w-4" />,
|
||||
lists: [
|
||||
{ id: 'javascript', name: 'JavaScript', url: '/list/javascript', stars: 48000 },
|
||||
{ id: 'typescript', name: 'TypeScript', url: '/list/typescript', stars: 42000 },
|
||||
{ id: 'python-lang', name: 'Python', url: '/list/python-lang', stars: 52000 },
|
||||
{ id: 'rust-lang', name: 'Rust', url: '/list/rust-lang', stars: 30000 },
|
||||
{ id: 'go-lang', name: 'Go', url: '/list/go-lang', stars: 35000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Platforms',
|
||||
icon: <Globe className="h-4 w-4" />,
|
||||
lists: [
|
||||
{ id: 'docker', name: 'Docker', url: '/list/docker', stars: 40000 },
|
||||
{ id: 'kubernetes', name: 'Kubernetes', url: '/list/kubernetes', stars: 38000 },
|
||||
{ id: 'aws', name: 'AWS', url: '/list/aws', stars: 35000 },
|
||||
{ id: 'azure', name: 'Azure', url: '/list/azure', stars: 28000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Tools',
|
||||
icon: <Package className="h-4 w-4" />,
|
||||
lists: [
|
||||
{ id: 'vscode', name: 'VS Code', url: '/list/vscode', stars: 45000 },
|
||||
{ id: 'git', name: 'Git', url: '/list/git', stars: 42000 },
|
||||
{ id: 'vim', name: 'Vim', url: '/list/vim', stars: 38000 },
|
||||
{ id: 'cli', name: 'CLI', url: '/list/cli', stars: 35000 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const toggleCategory = (categoryName: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(categoryName)) {
|
||||
next.delete(categoryName)
|
||||
} else {
|
||||
next.add(categoryName)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const filteredCategories = React.useMemo(() => {
|
||||
if (!searchQuery) return categories
|
||||
|
||||
return categories
|
||||
.map((category) => ({
|
||||
...category,
|
||||
lists: category.lists.filter((list) =>
|
||||
list.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
),
|
||||
}))
|
||||
.filter((category) => category.lists.length > 0)
|
||||
}, [searchQuery])
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarContent>
|
||||
{/* Header */}
|
||||
<SidebarGroup>
|
||||
<div className="px-4 py-4">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<AwesomeIcon size={24} />
|
||||
<span className="gradient-text text-xl font-bold">Awesome</span>
|
||||
</Link>
|
||||
</div>
|
||||
</SidebarGroup>
|
||||
|
||||
{/* Search Input */}
|
||||
<SidebarGroup>
|
||||
<div className="px-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search lists..."
|
||||
className="pl-9"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarGroup>
|
||||
|
||||
{/* Main Navigation */}
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={pathname === '/'}>
|
||||
<Link href="/">
|
||||
<Home className="h-4 w-4" />
|
||||
<span>Home</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={pathname === '/search'}>
|
||||
<Link href="/search">
|
||||
<Search className="h-4 w-4" />
|
||||
<span>Search</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={pathname === '/browse'}>
|
||||
<Link href="/browse">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
<span>Browse</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{/* Categories */}
|
||||
<ScrollArea className="flex-1">
|
||||
{filteredCategories.map((category) => {
|
||||
const isExpanded = expandedCategories.has(category.name)
|
||||
|
||||
return (
|
||||
<SidebarGroup key={category.name}>
|
||||
<SidebarGroupLabel asChild>
|
||||
<button
|
||||
onClick={() => toggleCategory(category.name)}
|
||||
className="group flex w-full items-center justify-between px-2 py-1.5 text-sm font-semibold transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{category.icon}
|
||||
<span>{category.name}</span>
|
||||
</div>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-4 w-4 transition-transform',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</SidebarGroupLabel>
|
||||
{isExpanded && (
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenuSub>
|
||||
{category.lists.map((list) => (
|
||||
<SidebarMenuSubItem key={list.id}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={pathname === list.url}
|
||||
>
|
||||
<Link href={list.url}>
|
||||
<span className="flex-1">{list.name}</span>
|
||||
{list.stars && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Star className="h-3 w-3 fill-current" />
|
||||
{(list.stars / 1000).toFixed(0)}k
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</SidebarGroupContent>
|
||||
)}
|
||||
</SidebarGroup>
|
||||
)
|
||||
})}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer */}
|
||||
<SidebarGroup className="mt-auto border-t">
|
||||
<div className="px-4 py-3 text-xs text-muted-foreground">
|
||||
<div className="mb-1 font-semibold">Built with 💜💗💛</div>
|
||||
<div>Updated every 6 hours</div>
|
||||
</div>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
171
components/layout/command-menu.tsx
Normal file
171
components/layout/command-menu.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
import { Search, Star, BookOpen, Home, FileText, Code } from 'lucide-react'
|
||||
|
||||
interface CommandMenuProps {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
|
||||
export function CommandMenu({ open, setOpen }: CommandMenuProps) {
|
||||
const router = useRouter()
|
||||
const [search, setSearch] = React.useState('')
|
||||
const [results, setResults] = React.useState([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
|
||||
// declare the async data fetching function
|
||||
const fetchData = React.useCallback(async () => {
|
||||
const response = await fetch(`/api/search?q=${encodeURIComponent(search)}`)
|
||||
const data = await response.json()
|
||||
setResults(...data.results);
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
setOpen(!open)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', down)
|
||||
return () => document.removeEventListener('keydown', down)
|
||||
}, [open, setOpen])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!search) {
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
fetchData()
|
||||
console.log(results)
|
||||
setLoading(false)
|
||||
}, [search])
|
||||
|
||||
const runCommand = React.useCallback((command: () => void) => {
|
||||
setOpen(false)
|
||||
command()
|
||||
}, [setOpen])
|
||||
|
||||
const pages = [
|
||||
{
|
||||
id: 'home',
|
||||
type: 'page',
|
||||
title: 'Home',
|
||||
url: '/',
|
||||
},
|
||||
{
|
||||
id: 'browse',
|
||||
type: 'page',
|
||||
title: 'Browse Collections',
|
||||
url: '/browse',
|
||||
},
|
||||
{
|
||||
id: 'search',
|
||||
type: 'page',
|
||||
title: 'Search',
|
||||
url: '/search',
|
||||
},
|
||||
]
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'list':
|
||||
return <Star className="mr-2 h-4 w-4" />
|
||||
case 'repo':
|
||||
return <Code className="mr-2 h-4 w-4" />
|
||||
case 'page':
|
||||
return <FileText className="mr-2 h-4 w-4" />
|
||||
default:
|
||||
return <BookOpen className="mr-2 h-4 w-4" />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput
|
||||
placeholder="Search awesome lists, repos, and more..."
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<div className="spinner-awesome h-8 w-8" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-6 text-center text-sm">
|
||||
No results found for "{search}"
|
||||
</div>
|
||||
)}
|
||||
</CommandEmpty>
|
||||
|
||||
{!search && (
|
||||
<React.Fragment key="pages-group">
|
||||
<CommandGroup heading="Pages">
|
||||
{pages.map((page) => (
|
||||
<CommandItem
|
||||
key={page.id}
|
||||
value={page.title}
|
||||
onSelect={() => runCommand(() => router.push(page.url))}
|
||||
>
|
||||
{getIcon(page.type)}
|
||||
<span>{page.title}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<CommandGroup heading="Search Results">
|
||||
{results.map((result: any) => (
|
||||
<CommandItem
|
||||
key={result.repository_id}
|
||||
value={result.repository_name}
|
||||
onSelect={() => runCommand(() => router.push(result.url))}
|
||||
>
|
||||
{getIcon(result.type)}
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{result.title}</span>
|
||||
{result.stars && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Star className="h-3 w-3 fill-current" />
|
||||
{result.stars.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{result.description && (
|
||||
<span className="text-xs text-muted-foreground line-clamp-1">
|
||||
{result.description}
|
||||
</span>
|
||||
)}
|
||||
{result.category && (
|
||||
<span className="text-xs text-primary">
|
||||
{result.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user