Skip to content

Commit

Permalink
feat: search functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
warmachine028 committed Oct 24, 2024
1 parent e7b47f7 commit 0eb3963
Show file tree
Hide file tree
Showing 5 changed files with 134 additions and 57 deletions.
Binary file modified client/bun.lockb
Binary file not shown.
1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"framer-motion": "^11.11.9",
"fuse.js": "^7.0.0",
"lucide-react": "^0.453.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
Expand Down
1 change: 1 addition & 0 deletions client/src/components/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export { Checkbox } from './checkbox'
export { ScrollArea } from './scroll-area'
export { Badge } from './badge'
export { Textarea } from './textarea'
export { Search } from './search'
22 changes: 22 additions & 0 deletions client/src/components/ui/search.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Search as SearchIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Input, type InputProps } from './input'

const Search = ({ className, iconClassName, ...props }: { iconClassName?: string } & InputProps) => {
return (
<div className="relative w-full">
<div className="absolute left-2 top-1/2 -translate-y-1/2 transform">
<SearchIcon size={18} className={cn('text-muted-foreground', iconClassName)} />
</div>
<Input
className={cn(
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-4 py-2 pl-8 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
)
}

export { Search }
167 changes: 110 additions & 57 deletions client/src/pages/Posts.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { useRef, useState } from 'react'
import { useRef, useEffect, useState } from 'react'
import { useInView } from 'react-intersection-observer'
import { Button, Input, Textarea, ScrollArea, Badge } from '@/components/ui'
import { Button, Input, Textarea, ScrollArea, Badge, Search } from '@/components/ui'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Loader2, Plus, RefreshCcw } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { useCreatePost, useGetPosts, useRefresh } from '@/hooks'
import { Post } from '@/components'
import { useStore } from '@/store'
import { Post as PostType } from '@/types'
import Fuse from 'fuse.js'
import { useSearchParams } from 'react-router-dom'

const CreatePost = () => {
const initialData = {
Expand Down Expand Up @@ -86,6 +89,7 @@ const CreatePost = () => {
<div className="flex items-center space-x-2">
<Input
type="file"
name="image"
onChange={handleChange}
className="w-full"
accept="image/*"
Expand All @@ -111,71 +115,120 @@ const CreatePost = () => {
)
}

const useSearch = (posts: PostType[]) => {
const [searchParams, setSearchParams] = useSearchParams()
const query = searchParams.get('q') || ''

const getSearchResults = (searchQuery: string) => {
if (!searchQuery) return posts

const fuse = new Fuse(posts, {
keys: ['title', 'body', 'tags'],
threshold: 0.3
})

const searchResults = fuse.search(searchQuery)
return searchResults.map((result) => result.item)
}

const results = getSearchResults(query)

const setQuery = (newQuery: string) => {
if (newQuery) {
setSearchParams({ q: newQuery })
} else {
setSearchParams({})
}
}

return { query, setQuery, results }
}


const Posts = () => {
const { ref, inView } = useInView()
const { optimisticPages } = useStore()
const { fetchNextPage, hasNextPage, isFetchingNextPage, status } = useGetPosts()
const {refresh, refreshing} = useRefresh()
const { refresh, refreshing } = useRefresh()

if (inView && hasNextPage) {
fetchNextPage()
}
const allPosts = optimisticPages.flatMap((page) => page.posts)
const { query, setQuery, results } = useSearch(allPosts)

return (
<div className="container mx-auto sm:p-4">
<CreatePost />
useEffect(() => {
if (inView && hasNextPage && !query) {
fetchNextPage()
}
}, [inView, hasNextPage, query, fetchNextPage])

<Card className="shadow-lg h-full sm:h-auto mt-6">
<CardHeader className="bg-primary text-primary-foreground">
<div className="flex items-center justify-between">
<CardTitle className="text-2xl font-bold">Posts</CardTitle>
<Button
variant="outline"
onClick={refresh}
className="bg-primary"
disabled={refreshing}
>
<RefreshCcw className={`${refreshing && 'animate-spin'}`} />
{refreshing && <Loader2 className="h-4 w-4 animate-spin" />}
</Button>
</div>
<Badge variant="secondary" className="ml-2">
{optimisticPages.flatMap((page) => page.posts).length || 0} posts
</Badge>
</CardHeader>
<CardContent className="p-6">
<ScrollArea className="h-96">
{status === 'pending' ? (
<div className="flex justify-center items-center h-full">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : status === 'error' ? (
<p className="text-center text-destructive">Error fetching posts</p>
) : (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 auto-rows-max"
return (
<div className="container mx-auto p-4 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="md:col-span-2">
<Card className="shadow-lg">
<CardHeader className="bg-primary text-primary-foreground">
<div className="flex items-center justify-between">
<CardTitle className="text-2xl font-bold">Posts</CardTitle>
<Button
variant="outline"
onClick={refresh}
className="bg-primary"
disabled={refreshing}
>
{optimisticPages.map((page) =>
page.posts.map((post) => <Post key={post.id} post={post} />)
)}
</motion.div>
</AnimatePresence>
)}
{isFetchingNextPage && (
<div className="flex justify-center mt-4">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
<RefreshCcw className={`${refreshing && 'animate-spin'} mr-2`} />
{refreshing ? 'Refreshing' : 'Refresh'}
</Button>
</div>
<Badge variant="secondary" className="mt-2">
{allPosts.length} posts
</Badge>
</CardHeader>
<CardContent className="p-6">
<div className="mb-4">
<Search
type="text"
placeholder="Search posts..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full"
/>
</div>
)}
<div ref={ref} className="h-1" />
</ScrollArea>
</CardContent>
</Card>
<ScrollArea className="h-[calc(100vh-20rem)]">
{status === 'pending' ? (
<div className="flex justify-center items-center h-full">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : status === 'error' ? (
<p className="text-center text-destructive">Error fetching posts</p>
) : (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="grid grid-cols-1 md:grid-cols-2 gap-6 auto-rows-max"
>
{results.map((post) => (
<Post key={post.id} post={post} />
))}
</motion.div>
</AnimatePresence>
)}
{isFetchingNextPage && (
<div className="flex justify-center mt-4">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
)}
<div ref={ref} className="h-1" />
</ScrollArea>
</CardContent>
</Card>
</div>
<div className="md:col-span-1">
<CreatePost />
</div>
</div>
</div>
)
}

export default Posts
export default Posts

0 comments on commit 0eb3963

Please sign in to comment.