-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add Search UI #267
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add Search UI #267
Changes from 1 commit
7b096c0
373cadd
d1bac81
ce245f5
5a2e48a
aa26102
4719ede
3e73a5e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| 'use client' | ||
|
|
||
| import { ClipLoader } from 'react-spinners' | ||
|
|
||
| export default function Loading() { | ||
| return ( | ||
| <div className="flex h-screen items-center justify-center bg-white"> | ||
| <ClipLoader color="#FF6B6B" size={50} /> | ||
| </div> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import SearchScreen from '@/components/search/SearchScreen' | ||
|
|
||
| type Props = { searchParams?: { query?: string; category?: string } } | ||
|
|
||
| export default function Page({ searchParams }: Props) { | ||
| const initialQuery = (searchParams?.query ?? '').toString() | ||
| const initialCategory = (searchParams?.category ?? '').toString() | ||
|
HeesooJun marked this conversation as resolved.
Outdated
|
||
|
|
||
| return <SearchScreen initialQuery={initialQuery} initialCategory={initialCategory} /> | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import type { StaticImageData } from 'next/image' | ||
|
|
||
| import 문화행사 from './문화행사.png' | ||
| import 스포츠 from './스포츠.png' | ||
| import 야외활동 from './야외활동.png' | ||
| import 음악 from './음악.png' | ||
| import 취업 from './취업.png' | ||
| import 팝업행사 from './팝업행사.png' | ||
| import 패션 from './패션.png' | ||
|
|
||
| export const CATEGORY_ICON: Record<string, StaticImageData> = { | ||
| 문화행사, | ||
| 스포츠, | ||
| 야외활동, | ||
| 음악, | ||
| 취업, | ||
| 팝업행사, | ||
| 패션, | ||
| } | ||
|
Comment on lines
+26
to
+49
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 저희 행사 카테고리 중 해당 카테고리들을 선택한 이유가 있을까요?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 화면만 구성하려고 임시로 다 넣은거라 나중에 추가하려고 했습니다!! |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| 'use client' | ||
|
|
||
| import { useCallback, useEffect, useMemo, useRef, useState } from 'react' | ||
| import Image from 'next/image' | ||
| import { CATEGORY_ICON } from '@/components/icon/Categoryicon' | ||
|
|
||
| // 1) 허용 카테고리(아이콘이 있는 것만) | ||
| const VALID = ['문화행사', '스포츠', '야외활동', '음악', '취업', '팝업행사', '패션'] as const | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 저희 기존 카테고리 이름들이 있는데, 이렇게 사용하는 이유가 있을까요? |
||
| type CategoryName = (typeof VALID)[number] | ||
|
|
||
| // 2) 별칭(들어오면 매핑해서 사용) — 필요 시 추가 | ||
| const ALIAS: Record<string, CategoryName> = { | ||
| 취미: '문화행사', | ||
| 레저: '야외활동', | ||
| 공연음악: '음악', | ||
| } | ||
|
|
||
| const VALID_SET = new Set<string>(VALID) | ||
|
|
||
| function normalizeList(list: string[]): CategoryName[] { | ||
| // 별칭→정규명 → 유효한 것만 → 중복 제거 | ||
| const mapped = list | ||
| .map(n => ALIAS[n] ?? n) // 별칭 치환 | ||
| .filter((n): n is CategoryName => VALID_SET.has(n)) // 유효만 | ||
| return Array.from(new Set(mapped)) | ||
| } | ||
|
|
||
| function normalizeOne(name?: string): CategoryName | undefined { | ||
| if (!name) return undefined | ||
| const n = ALIAS[name] ?? name | ||
| return VALID_SET.has(n) ? (n as CategoryName) : undefined | ||
| } | ||
|
|
||
| export default function CategoryTabs({ items, current, onChange }: { items: string[]; current?: string; onChange: (value: string) => void }) { | ||
| const wrapRef = useRef<HTMLDivElement>(null) | ||
| const rowRef = useRef<HTMLDivElement>(null) | ||
| const btnRefs = useRef<(HTMLButtonElement | null)[]>([]) | ||
| const lblRefs = useRef<(HTMLSpanElement | null)[]>([]) | ||
|
|
||
| // 정규화된 목록/현재값 | ||
| const normItems = useMemo(() => normalizeList(items), [items]) | ||
| const normCurrent = useMemo(() => normalizeOne(current), [current]) | ||
|
|
||
| const INDICATOR_W = 60 | ||
| const [left, setLeft] = useState(0) | ||
|
|
||
| const activeIndex = useMemo(() => { | ||
| const idx = normItems.findIndex(x => x === normCurrent) | ||
| return idx >= 0 ? idx : 0 | ||
| }, [normItems, normCurrent]) | ||
|
|
||
| const measure = useCallback(() => { | ||
| const row = rowRef.current | ||
| const btn = btnRefs.current[activeIndex] | ||
| const lbl = lblRefs.current[activeIndex] | ||
| if (!row || !btn || !lbl) return | ||
|
|
||
| const labelW = lbl.offsetWidth | ||
| const labelCenter = (btn.offsetWidth - labelW) / 2 + labelW / 2 | ||
| const x = btn.offsetLeft - row.scrollLeft + labelCenter - INDICATOR_W / 2 | ||
| setLeft(x) | ||
| }, [activeIndex]) | ||
|
|
||
| useEffect(() => { | ||
| const row = rowRef.current | ||
| const wrap = wrapRef.current | ||
| if (!row || !wrap) return | ||
|
|
||
| const onScroll = () => requestAnimationFrame(measure) | ||
| const ro = new ResizeObserver(() => requestAnimationFrame(measure)) | ||
|
|
||
| ro.observe(wrap) | ||
| ro.observe(row) | ||
| row.addEventListener('scroll', onScroll, { passive: true }) | ||
|
|
||
| const raf = requestAnimationFrame(measure) | ||
|
|
||
| return () => { | ||
| cancelAnimationFrame(raf) | ||
| ro.disconnect() | ||
| row.removeEventListener('scroll', onScroll) | ||
| } | ||
| }, [measure, activeIndex]) | ||
|
|
||
| return ( | ||
| <div className="select-none"> | ||
| <div ref={wrapRef} className="relative px-8"> | ||
| <div ref={rowRef} className="flex items-center gap-6 overflow-x-auto pb-3 no-scrollbar"> | ||
| {normItems.map((name, i) => { | ||
| const active = normCurrent === name | ||
| return ( | ||
| <button | ||
| key={name} | ||
| ref={el => { | ||
| btnRefs.current[i] = el | ||
| }} | ||
| onClick={() => onChange(active ? '' : name)} | ||
| className="flex flex-col items-center px-2 py-1 shrink-0" | ||
| > | ||
| <div className="mb-2 leading-none">{iconByName(name, active)}</div> | ||
| <span | ||
| ref={el => { | ||
| lblRefs.current[i] = el | ||
| }} | ||
| className={`text-[12px] ${active ? 'font-semibold text-black' : 'text-gray-500'}`} | ||
| > | ||
| {name} | ||
| </span> | ||
| </button> | ||
| ) | ||
| })} | ||
| </div> | ||
|
|
||
| {/* 회색 베이스 라인 */} | ||
| <div className="absolute left-[-24px] right-[-24px] bottom-0 h-[2px] bg-gray-100 z-0" /> | ||
|
HeesooJun marked this conversation as resolved.
|
||
|
|
||
| {/* 주황 인디케이터 라인 */} | ||
| <span | ||
| className="absolute bottom-0 h-[3px] rounded-full bg-[#FF9575] z-10 transition-[left] duration-200" | ||
| style={{ left, width: INDICATOR_W }} | ||
| /> | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| function iconByName(name: CategoryName, active: boolean) { | ||
| const src = CATEGORY_ICON[name] ?? CATEGORY_ICON['문화행사'] | ||
| const size = name === '팝업행사' ? 21 : 20 | ||
|
|
||
| return ( | ||
| <Image | ||
| src={src} | ||
| alt="" | ||
| width={size} | ||
| height={size} | ||
| draggable={false} | ||
| priority={false} | ||
| className={`block ${active ? '' : 'opacity-45'}`} | ||
| style={{ imageRendering: 'crisp-edges' }} | ||
| /> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| 'use client' | ||
|
|
||
| export default function SearchResultCard({ title, region, period, image }: { title: string; region: string; period: string; image: string }) { | ||
| return ( | ||
| <article className="w-full"> | ||
| <div className="rounded-2xl overflow-hidden"> | ||
| {/* eslint-disable-next-line @next/next/no-img-element */} | ||
| <img src={image} alt={title} className="w-full h-[200px] object-cover" loading="lazy" /> | ||
| </div> | ||
|
HeesooJun marked this conversation as resolved.
Outdated
|
||
| <h2 className="mt-3 text-[16px] font-semibold">{title}</h2> | ||
| <p className="mt-1 text-[13px] text-gray-500">{region}</p> | ||
| <p className="mt-1 text-[13px] text-gray-500">{period}</p> | ||
| </article> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| 'use client' | ||
|
|
||
| import { useEffect, useMemo, useState } from 'react' | ||
| import CategoryTabs from '@/components/search/CategoryTabs' | ||
| import { SearchIcon } from '@/components/icon' | ||
| import { ClipLoader } from 'react-spinners' | ||
|
|
||
| type Props = { initialQuery?: string; initialCategory?: string } | ||
|
|
||
| type EventItem = { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 기존 행사 데이터와 동일한 형태로 예시를 수정하는 것이 좋아보여요.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 수정해보겠습니다
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이부분도 반영되지 않은 것 같습니다 |
||
| id: string | ||
| title: string | ||
| region: string | ||
| period: { start: string; end: string } | ||
| image: string | ||
| category: string | ||
| } | ||
|
|
||
| const MOCK: EventItem[] = [ | ||
| { | ||
| id: '1', | ||
| title: '진해 벚꽃 축제', | ||
| region: '경남 창원시 진해구', | ||
| period: { start: '2025-04-01', end: '2025-04-10' }, | ||
| image: 'https://www.hygn.go.kr/_res/tour/img/sub/04/CherryBlossom_img02.jpg', | ||
| category: '팝업행사', | ||
| }, | ||
| { | ||
| id: '2', | ||
| title: '서울 세계 불꽃 축제', | ||
| region: '서울 송파구', | ||
| period: { start: '2025-05-20', end: '2025-06-02' }, | ||
| image: 'https://www.hanwha.co.kr/upload/news/press/2024/10/02/1727831937311_62.jpg', | ||
| category: '문화행사', | ||
| }, | ||
| { | ||
| id: '3', | ||
| title: '벚꽃', | ||
| region: '부산 금정구', | ||
| period: { start: '2025-04-05', end: '2025-04-07' }, | ||
| image: 'https://mediahub.seoul.go.kr/uploads/mediahub/2025/04/auKJgrjhemWLuzyMwmtLrYSuxKfuhNYk.jpg', | ||
| category: '패션', | ||
| }, | ||
| ] | ||
|
|
||
| const CATEGORIES = ['팝업행사', '패션', '스포츠', '야외활동', '문화행사', '음악', '취미'] as const | ||
| const fmtPeriod = ({ start, end }: { start: string; end: string }) => `${start.replaceAll('-', '.')} ~ ${end.replaceAll('-', '.')}` | ||
|
|
||
| //주소창 업데이트 | ||
| const shallowSetParam = (next: Record<string, string | ''>) => { | ||
| const url = new URL(window.location.href) | ||
| Object.entries(next).forEach(([k, v]) => { | ||
| if (v) url.searchParams.set(k, v as string) | ||
| else url.searchParams.delete(k) | ||
| }) | ||
| window.history.replaceState(null, '', url.toString()) | ||
| } | ||
|
|
||
| export default function SearchScreen({ initialQuery = '', initialCategory = '' }: Props) { | ||
| const [query, setQuery] = useState(initialQuery) | ||
| const [category, setCategory] = useState(initialCategory) | ||
|
|
||
| // 결과 데이터 & 로딩 상태 | ||
| const [items, setItems] = useState<EventItem[]>([]) | ||
| const [isLoading, setIsLoading] = useState(true) | ||
|
|
||
| // 필터 함수 (실제 API 연동 시 fetch 대체) | ||
| const filterLocal = (q: string, c: string): EventItem[] => { | ||
| const ql = q.trim().toLowerCase() | ||
| let filtered = MOCK.filter(e => { | ||
| const byQ = !ql || e.title.toLowerCase().includes(ql) || e.region.toLowerCase().includes(ql) | ||
| const byC = !c || e.category === c | ||
| return byQ && byC | ||
| }) | ||
| if (filtered.length < 3) { | ||
| const extra = MOCK.filter(m => !filtered.some(f => f.id === m.id)) | ||
| filtered = [...filtered, ...extra].slice(0, 3) | ||
| } | ||
| return filtered | ||
| } | ||
|
|
||
| // 카테고리/검색어 변경 시 | ||
| useEffect(() => { | ||
| let alive = true | ||
| setIsLoading(true) | ||
| const id = setTimeout(() => { | ||
| if (!alive) return | ||
| const next = filterLocal(query, category) | ||
| setItems(next) | ||
| setIsLoading(false) | ||
| }, 300) | ||
| return () => { | ||
| alive = false | ||
| clearTimeout(id) | ||
| } | ||
| }, [query, category]) | ||
|
|
||
| // 검색 제출 | ||
| const onSubmit = (e: React.FormEvent) => { | ||
| e.preventDefault() | ||
| shallowSetParam({ query, category }) | ||
| } | ||
|
|
||
| // “검색 결과 없음” 판단 | ||
| const hasNoResult = useMemo(() => !isLoading && items.length === 0, [isLoading, items]) | ||
|
|
||
| return ( | ||
| <div className="mx-auto w-full max-w-[420px] min-h-screen bg-white overflow-x-hidden"> | ||
| <form onSubmit={onSubmit} className="sticky top-0 z-10 bg-white px-12 pt-4 pb-2 mb-4"> | ||
| <div className="flex items-center gap-3 px-4 py-2 rounded-full bg-[#F5F5F5]"> | ||
| <input | ||
| value={query} | ||
| onChange={e => setQuery(e.target.value)} | ||
| placeholder="검색어를 입력하세요" | ||
| className="flex-1 bg-transparent outline-none text-[15px] indent-4" | ||
| aria-label="검색어" | ||
| /> | ||
| <button type="submit" className="shrink-0 rounded-full p-1" aria-label="검색"> | ||
| <SearchIcon width="20" height="20" /> | ||
| </button> | ||
| </div> | ||
| </form> | ||
|
|
||
| {/* 카테고리 탭*/} | ||
| <CategoryTabs | ||
| items={CATEGORIES as unknown as string[]} | ||
|
HeesooJun marked this conversation as resolved.
Outdated
|
||
| current={category} | ||
| onChange={c => { | ||
| const next = c === category ? '' : c | ||
| setCategory(next) | ||
| shallowSetParam({ category: next, query }) | ||
| }} | ||
| /> | ||
|
|
||
| {/* 카테고리와 결과 사이 간격 */} | ||
| <div className="h-2" /> | ||
|
|
||
| <main className="px-12 py-4"> | ||
| {isLoading ? ( | ||
| <div className="flex justify-center py-10"> | ||
| <ClipLoader color="#FF6B6B" size={28} /> | ||
| </div> | ||
| ) : hasNoResult ? ( | ||
| <p className="mt-12 text-center text-gray-500">검색 결과가 없습니다.</p> | ||
| ) : ( | ||
| <ul className="space-y-8"> | ||
| {items.map(it => ( | ||
| <li key={it.id}> | ||
| <article className="w-full mt-2"> | ||
| <div className="rounded-2xl overflow-hidden mt-1"> | ||
| {/* eslint-disable-next-line @next/next/no-img-element */} | ||
| <img src={it.image} alt={it.title} className="w-full h-[300px] object-cover" loading="lazy" /> | ||
| </div> | ||
| <h2 className="mt-3 text-[16px] font-semibold">{it.title}</h2> | ||
| <p className="mt-1 text-[13px] text-gray-500">{it.region}</p> | ||
| <p className="mt-1 text-[13px] text-gray-500">{fmtPeriod(it.period)}</p> | ||
| </article> | ||
| </li> | ||
|
HeesooJun marked this conversation as resolved.
Outdated
|
||
| ))} | ||
| </ul> | ||
| )} | ||
| </main> | ||
|
|
||
| <div className="h-16" /> | ||
| </div> | ||
| ) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.