Skip to content

Commit fcec510

Browse files
committed
Live search results
1 parent c109838 commit fcec510

File tree

4 files changed

+97
-8
lines changed

4 files changed

+97
-8
lines changed

app/controllers/application_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
class ApplicationController < ActionController::Base
22
include Pagy::Method
3-
3+
include Searchable
44
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
55
allow_browser versions: :modern
66
layout :set_layout
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
module Searchable
2+
extend ActiveSupport::Concern
3+
4+
included do
5+
inertia_share search_results: -> {
6+
return {
7+
topics: [],
8+
query: params[:q]
9+
} unless params[:q].present?
10+
11+
topics = Topic
12+
.includes(:user, :category, messages: :user)
13+
.left_joins(:messages)
14+
.where("topics.title LIKE :q OR messages.body LIKE :q", q: "%#{params[:q]}%")
15+
.order(created_at: :desc)
16+
.distinct
17+
.as_json(include: [
18+
:user,
19+
:category,
20+
messages: { include: :user }
21+
])
22+
23+
{
24+
topics: topics,
25+
q: params[:q].to_s
26+
}
27+
}
28+
end
29+
end

app/frontend/layouts/AppLayout.tsx

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,51 @@
1-
import { ReactNode, useState } from 'react'
1+
import { ReactNode, useState, useCallback, useRef } from 'react'
22
import { Link, usePage, router, Form } from '@inertiajs/react'
33
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'
44
import {
55
Bars3Icon,
66
MagnifyingGlassIcon,
77
ChevronDownIcon
88
} from '@heroicons/react/24/outline'
9-
import { User } from '@/types'
9+
10+
import { Topic } from '../types'
1011
import Sidebar from "@/components/Sidebar";
12+
import { debounce } from '../utils/debounce'
1113

1214
interface AppLayoutProps {
1315
children: ReactNode
1416
}
1517

1618
export default function AppLayout({ children }: AppLayoutProps) {
1719
const [sidebarOpen, setSidebarOpen] = useState(true)
18-
const {props: {current_user: currentUser}} = usePage()
20+
const {
21+
props: {
22+
current_user: currentUser,
23+
search_results: searchResults
24+
},
25+
} = usePage()
26+
27+
const { topics, q } = searchResults
28+
const { username } = currentUser || {}
29+
30+
const debouncedSearch = useCallback(
31+
debounce((q: string) => {
32+
router.reload({ only: ['search_results'], data: { q }, preserveUrl: true })
33+
}, 500),
34+
[]
35+
)
1936

20-
const {username} = currentUser || {} as User
37+
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
38+
const value = e.target.value
39+
debouncedSearch(value)
40+
}
41+
42+
const inputRef = useRef<HTMLInputElement>(null)
43+
44+
const handleEscapeKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
45+
if (e.key === 'Escape') {
46+
inputRef.current?.blur()
47+
}
48+
}
2149

2250
return (
2351
<div className="flex h-screen bg-gray-50">
@@ -32,8 +60,12 @@ export default function AppLayout({ children }: AppLayoutProps) {
3260
>
3361
<Bars3Icon className="h-6 w-6" />
3462
</button>
35-
<div className="flex-1 max-w-lg">
36-
<Form action="/search" method="get">
63+
<div className="group flex-1 max-w-lg relative">
64+
<Form
65+
action="/search"
66+
method="get"
67+
resetOnSuccess
68+
>
3769
<div className="relative">
3870
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
3971
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
@@ -42,10 +74,38 @@ export default function AppLayout({ children }: AppLayoutProps) {
4274
type="text"
4375
name="query"
4476
placeholder="Search"
77+
onChange={handleSearchChange}
78+
onKeyDown={handleEscapeKey}
79+
ref={inputRef}
80+
defaultValue={q}
4581
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-sky-500 focus:border-sky-500 sm:text-sm"
4682
/>
4783
</div>
4884
</Form>
85+
86+
<div className="absolute z-50 mt-1 w-full rounded-md bg-white shadow-lg ring-1 ring-black/5 max-h-96 overflow-y-auto hidden group-focus-within:block">
87+
<div className="py-1">
88+
{topics && topics.map((topic: Topic) => (
89+
<Link
90+
key={topic.id}
91+
href={`/topics/${topic.id}`}
92+
className="block px-4 py-3 hover:bg-gray-50 transition-colors"
93+
>
94+
<div className="text-sm font-medium text-sky-700">
95+
{topic.title}
96+
</div>
97+
<div className="text-xs text-gray-500 mt-1">
98+
{topic.category.name}
99+
</div>
100+
</Link>
101+
))}
102+
{topics.length === 0 && (
103+
<div className="block px-4 py-3 text-sm text-gray-500">
104+
No results found
105+
</div>
106+
)}
107+
</div>
108+
</div>
49109
</div>
50110
</div>
51111

app/frontend/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,4 @@ export interface Meta {
5252
head_key?: string,
5353
inner_content?: string | object,
5454
[key: string]: any
55-
}
55+
}

0 commit comments

Comments
 (0)