Skip to content

Commit f0fee64

Browse files
authored
PR.29 feature/searchbox
feat: implement search UI with responsive mobile and desktop experiences
2 parents 0cf3324 + fc07694 commit f0fee64

15 files changed

Lines changed: 761 additions & 241 deletions

app/(app)/search/page.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
type SearchPageProps = {
2+
searchParams: Promise<{ q?: string }>;
3+
};
4+
5+
export default async function SearchPage({ searchParams }: SearchPageProps) {
6+
const { q } = await searchParams;
7+
8+
return (
9+
<div className="space-y-6">
10+
<h1 className="text-heading-lg">Search Results</h1>
11+
{q ? (
12+
<p className="text-muted-foreground">
13+
Showing results for: <strong className="text-foreground">{q}</strong>
14+
</p>
15+
) : (
16+
<p className="text-muted-foreground">Enter a search query to begin.</p>
17+
)}
18+
19+
{/* TODO: Implement search results */}
20+
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
21+
Do Search on mock data (+TDD) → then Convex.
22+
</div>
23+
</div>
24+
);
25+
}

app/globals.css

Lines changed: 42 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
--color-sidebar-border: var(--sidebar-border);
6262
--color-sidebar-ring: var(--sidebar-ring);
6363

64-
/* Custom */
64+
/* Custom: Overlay */
6565
--color-overlay: var(--overlay);
6666
}
6767

@@ -75,23 +75,23 @@
7575
--radius: 0.625rem;
7676

7777
/* shadcn/ui: Core */
78-
--background: oklch(0.99 0 0); /* #FDFDFD */
78+
--background: oklch(0.99 0 0);
7979
--foreground: oklch(0.129 0.042 264.695);
80-
--card: oklch(0.9722 0.0034 247.86); /* #F4F6F8 */
81-
--card-foreground: oklch(0.129 0.042 264.695);
82-
--popover: oklch(1 0 0);
83-
--popover-foreground: oklch(0.129 0.042 264.695);
80+
--card: oklch(0.9722 0.0034 247.86);
81+
--card-foreground: var(--foreground);
82+
--popover: var(--card);
83+
--popover-foreground: var(--foreground);
8484
--primary: oklch(0.7089 0.1967 46.81);
8585
--primary-foreground: oklch(1 0 0);
86-
--secondary: oklch(0.9147 0.0205 264.47); /* #DCE3F1 - Figma */
86+
--secondary: oklch(0.9147 0.0205 264.47);
8787
--secondary-foreground: oklch(0.208 0.042 265.755);
8888
--muted: oklch(0.968 0.007 247.896);
8989
--muted-foreground: oklch(0.554 0.046 257.417);
90-
--accent: oklch(0.968 0.007 247.896);
91-
--accent-foreground: oklch(0.208 0.042 265.755);
90+
--accent: var(--muted);
91+
--accent-foreground: var(--secondary-foreground);
9292
--destructive: oklch(0.577 0.245 27.325);
9393
--border: oklch(0.929 0.013 255.508);
94-
--input: oklch(0.929 0.013 255.508);
94+
--input: var(--border);
9595
--ring: oklch(0.704 0.04 256.788);
9696

9797
/* shadcn/ui: Charts */
@@ -102,55 +102,52 @@
102102
--chart-5: oklch(0.769 0.188 70.08);
103103

104104
/* shadcn/ui: Sidebar */
105-
--sidebar: oklch(1 0 0); /* Figma #ffffff */
106-
--sidebar-foreground: oklch(0.2102 0.0185 270.39); /* #151821 */
107-
--sidebar-primary: oklch(0.208 0.042 265.755);
105+
--sidebar: oklch(1 0 0);
106+
--sidebar-foreground: oklch(0.2102 0.0185 270.39);
107+
--sidebar-primary: var(--secondary-foreground);
108108
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
109-
--sidebar-accent: oklch(0.968 0.007 247.896);
110-
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
111-
--sidebar-border: oklch(0.9722 0.0034 247.86); /* #F4F6F8 */
112-
--sidebar-ring: oklch(0.704 0.04 256.788);
109+
--sidebar-accent: var(--muted);
110+
--sidebar-accent-foreground: var(--secondary-foreground);
111+
--sidebar-border: var(--card);
112+
--sidebar-ring: var(--ring);
113113

114114
/* Custom: Gradients */
115-
--primary-gradient-to: oklch(0.7434 0.115 58.23); /* #E2995F */
115+
--primary-gradient-to: oklch(0.7434 0.115 58.23);
116116
--gradient-primary: linear-gradient(
117117
93.22deg,
118118
var(--primary) -13.95%,
119119
var(--primary-gradient-to) 99.54%
120120
);
121121

122122
/* Custom: Overlay */
123-
--overlay: oklch(0 0 0); /* black, both modes */
123+
--overlay: oklch(0 0 0);
124124

125125
/* Custom: Assets (theme-aware URLs) */
126126
--logo-full-themed: url("/images/logo-light.svg");
127127
--auth-bg: url("/images/auth-bg-light.webp");
128128

129129
/* Custom: Tag badges */
130-
--tag-bg: oklch(0.9722 0.0034 247.86); /* #F4F6F8 */
131-
--tag-text: oklch(0.6504 0.0475 272.34); /* #858EAD */
130+
--tag-bg: var(--card);
131+
--tag-text: oklch(0.6504 0.0475 272.34);
132132
}
133133

134134
/* Dark mode overrides — these values take precedence over :root when .dark is active */
135135
.dark {
136-
/* Base */
137-
--radius: 0.625rem; /* same as root */
138-
139136
/* shadcn/ui: Core */
140-
--background: oklch(0 0 0); /* #000000 */
137+
--background: oklch(0 0 0);
141138
--foreground: oklch(0.984 0.003 247.858);
142-
--card: oklch(0.1783 0.0128 270.6); /* #0F1117 */
143-
--card-foreground: oklch(0.984 0.003 247.858);
144-
--popover: oklch(0.208 0.042 265.755);
145-
--popover-foreground: oklch(0.984 0.003 247.858);
139+
--card: oklch(0.1783 0.0128 270.6);
140+
--card-foreground: var(--foreground);
141+
--popover: oklch(0.2741 0.023 270.44);
142+
--popover-foreground: var(--foreground);
146143
--primary: oklch(0.7089 0.1967 46.81);
147144
--primary-foreground: oklch(1 0 0);
148-
--secondary: oklch(0.2728 0.0257 265.4); /* #212734 - Figma */
149-
--secondary-foreground: oklch(0.984 0.003 247.858);
145+
--secondary: oklch(0.2728 0.0257 265.4);
146+
--secondary-foreground: var(--foreground);
150147
--muted: oklch(0.279 0.041 260.031);
151148
--muted-foreground: oklch(0.704 0.04 256.788);
152-
--accent: oklch(0.279 0.041 260.031);
153-
--accent-foreground: oklch(0.984 0.003 247.858);
149+
--accent: var(--muted);
150+
--accent-foreground: var(--foreground);
154151
--destructive: oklch(0.704 0.191 22.216);
155152
--border: oklch(1 0 0 / 10%);
156153
--input: oklch(1 0 0 / 15%);
@@ -164,33 +161,33 @@
164161
--chart-5: oklch(0.645 0.246 16.439);
165162

166163
/* shadcn/ui: Sidebar */
167-
--sidebar: oklch(0.1783 0.0128 270.6); /* Figma #0F1117 */
168-
--sidebar-foreground: oklch(1 0 0); /* #FFFFFF */
164+
--sidebar: var(--card);
165+
--sidebar-foreground: oklch(1 0 0);
169166
--sidebar-primary: oklch(0.488 0.243 264.376);
170-
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
171-
--sidebar-accent: oklch(0.279 0.041 260.031);
172-
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
167+
--sidebar-primary-foreground: var(--foreground);
168+
--sidebar-accent: var(--muted);
169+
--sidebar-accent-foreground: var(--foreground);
173170
--sidebar-border: transparent;
174-
--sidebar-ring: oklch(0.551 0.027 264.364);
171+
--sidebar-ring: var(--ring);
175172

176173
/* Custom: Gradients */
177-
--primary-gradient-to: oklch(0.7434 0.115 58.23); /* same as root */
174+
--primary-gradient-to: oklch(0.7434 0.115 58.23);
178175
--gradient-primary: linear-gradient(
179176
93.22deg,
180177
var(--primary) -13.95%,
181178
var(--primary-gradient-to) 99.54%
182-
); /* same as root */
179+
);
183180

184181
/* Custom: Overlay */
185-
--overlay: oklch(0 0 0); /* same as root */
182+
--overlay: oklch(0 0 0);
186183

187184
/* Custom: Assets (theme-aware URLs) */
188185
--logo-full-themed: url("/images/logo-dark.svg");
189186
--auth-bg: url("/images/auth-bg-dark.webp");
190187

191188
/* Custom: Tag badges */
192-
--tag-bg: oklch(0.2102 0.0185 270.39); /* #151821 */
193-
--tag-text: oklch(0.6547 0.0897 269.9); /* #7B8EC8 */
189+
--tag-bg: oklch(0.2102 0.0185 270.39);
190+
--tag-text: oklch(0.6547 0.0897 269.9);
194191
}
195192

196193
@layer base {
@@ -257,11 +254,8 @@
257254
@apply invert dark:invert-0;
258255
}
259256

260-
/* Subtle directional shadow for navigation edges (light mode only) */
257+
/* Subtle directional shadow for navigation edges */
261258
.shadow-light {
262259
box-shadow: -10px 10px 20px 0px oklch(0.877 0.006 17.26 / 10%);
263260
}
264-
.dark .shadow-light {
265-
box-shadow: none;
266-
}
267261
}

components/navigation/desktop-topbar.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { SignedOut, SignInButton, SignUpButton } from "@clerk/nextjs";
2-
import { Searchbox } from "@/components/search/searchbox";
2+
import { DesktopSearch } from "@/components/search/desktop-search";
33
import { Button } from "@/components/ui/button";
44

55
export function DesktopTopBar() {
66
return (
77
<header className="sticky top-0 z-40 hidden h-(--top-bar-height) items-center bg-background md:flex">
8-
{/* Left section: matches main content structure (padding + max-w-5xl centering) */}
8+
{/* Left section: search bar narrower than content (max-w-2xl vs content's max-w-5xl) */}
99
<div className="flex flex-1 items-center md:px-8 lg:px-10 xl:px-14">
10-
<div className="mx-auto w-full max-w-5xl">
11-
<Searchbox />
10+
<div className="mx-auto w-full max-w-2xl">
11+
<DesktopSearch />
1212
</div>
1313
</div>
1414

components/navigation/mobile-topbar.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Link from "next/link";
22
import { MobileNav } from "@/components/navigation/mobile-nav";
3+
import { MobileSearch } from "@/components/search/mobile-search";
34

45
export function MobileTopBar() {
56
return (
@@ -10,7 +11,10 @@ export function MobileTopBar() {
1011
<img src="/images/site-logo.svg" className="size-8" />
1112
</Link>
1213

13-
<MobileNav />
14+
<div className="flex items-center">
15+
<MobileSearch />
16+
<MobileNav />
17+
</div>
1418
</header>
1519
);
1620
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"use client";
2+
3+
import { SearchHints } from "@/components/search/search-hints";
4+
import { SearchInput } from "@/components/search/search-input";
5+
import { SearchPopoverContent } from "@/components/search/search-popover-content";
6+
import { Popover, PopoverAnchor } from "@/components/ui/popover";
7+
import { useSearch } from "@/hooks/use-search";
8+
9+
export function DesktopSearch() {
10+
const {
11+
isOpen,
12+
query,
13+
setQuery,
14+
inputRef,
15+
open,
16+
close,
17+
handleSubmit,
18+
handleKeyDown,
19+
} = useSearch();
20+
21+
return (
22+
<Popover open={isOpen} onOpenChange={(open) => !open && close()}>
23+
<PopoverAnchor asChild>
24+
<form data-slot="searchbox" onSubmit={handleSubmit} className="w-full">
25+
<SearchInput
26+
ref={inputRef}
27+
value={query}
28+
onChange={setQuery}
29+
onFocus={open}
30+
onKeyDown={handleKeyDown}
31+
highlighted={isOpen}
32+
/>
33+
</form>
34+
</PopoverAnchor>
35+
<SearchPopoverContent>
36+
<SearchHints variant="desktop" onClose={close} />
37+
</SearchPopoverContent>
38+
</Popover>
39+
);
40+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"use client";
2+
3+
import { Search } from "lucide-react";
4+
import { useEffect, useState } from "react";
5+
import { SearchHints } from "@/components/search/search-hints";
6+
import { SearchInput } from "@/components/search/search-input";
7+
import { SearchPopoverContent } from "@/components/search/search-popover-content";
8+
import { Button } from "@/components/ui/button";
9+
import { Popover, PopoverAnchor } from "@/components/ui/popover";
10+
import { useSearch } from "@/hooks/use-search";
11+
import { cn } from "@/lib/utils";
12+
13+
export function MobileSearch() {
14+
const [showHints, setShowHints] = useState(false);
15+
16+
const {
17+
isOpen,
18+
query,
19+
setQuery,
20+
inputRef,
21+
open,
22+
reset,
23+
handleSubmit,
24+
handleKeyDown,
25+
} = useSearch({
26+
onClose: () => setShowHints(false),
27+
});
28+
29+
// Auto-focus input when overlay opens
30+
useEffect(() => {
31+
if (isOpen) {
32+
const timer = setTimeout(() => inputRef.current?.focus(), 50);
33+
return () => clearTimeout(timer);
34+
}
35+
}, [isOpen, inputRef]);
36+
37+
// 0.5s delay before showing hints
38+
useEffect(() => {
39+
if (!isOpen) {
40+
setShowHints(false);
41+
return;
42+
}
43+
44+
const timer = setTimeout(() => setShowHints(true), 500);
45+
return () => clearTimeout(timer);
46+
}, [isOpen]);
47+
48+
return (
49+
<>
50+
{/* Search icon trigger button */}
51+
<Button
52+
variant="ghost"
53+
size="icon-lg"
54+
onClick={open}
55+
className={cn(
56+
"text-foreground md:hidden",
57+
isOpen && "bg-accent text-accent-foreground",
58+
)}
59+
aria-label="Search"
60+
>
61+
<Search className="size-6" />
62+
</Button>
63+
64+
{/* Overlay and search panel (only when active) */}
65+
{isOpen && (
66+
<>
67+
{/* Backdrop overlay - click to close */}
68+
<button
69+
type="button"
70+
tabIndex={-1}
71+
data-slot="mobile-search-overlay"
72+
className="animate-in fade-in-0 fixed inset-0 z-50 cursor-default bg-overlay/50 duration-300 md:hidden"
73+
onClick={reset}
74+
aria-label="Close search"
75+
/>
76+
77+
{/* Search input bar - separate card floating below topbar */}
78+
<div
79+
data-slot="mobile-search-input-bar"
80+
className="animate-in slide-in-from-top-2 fade-in-0 fixed left-0 right-0 top-(--top-bar-height) z-50 border-b bg-card px-4 py-3 shadow-sm duration-300 md:hidden"
81+
>
82+
<Popover open={showHints} onOpenChange={setShowHints}>
83+
<PopoverAnchor asChild>
84+
<form data-slot="mobile-search-form" onSubmit={handleSubmit}>
85+
<SearchInput
86+
ref={inputRef}
87+
value={query}
88+
onChange={setQuery}
89+
onKeyDown={handleKeyDown}
90+
highlighted={isOpen}
91+
/>
92+
</form>
93+
</PopoverAnchor>
94+
95+
{/* Hints panel - separate floating card below input */}
96+
<SearchPopoverContent sideOffset={12}>
97+
<SearchHints variant="mobile" onClose={reset} />
98+
</SearchPopoverContent>
99+
</Popover>
100+
</div>
101+
</>
102+
)}
103+
</>
104+
);
105+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export const SEARCH_HINTS = [
2+
{ syntax: "[tag]", description: "search within a tag" },
3+
{ syntax: "user:1234", description: "search by author" },
4+
{ syntax: '"words here"', description: "exact phrase" },
5+
{ syntax: "answers:0", description: "unanswered questions" },
6+
{ syntax: "score:3", description: "posts with a 3+ score" },
7+
{ syntax: "is:question", description: "type of post" },
8+
] as const;
9+
10+
export type SearchHint = (typeof SEARCH_HINTS)[number];

0 commit comments

Comments
 (0)