-
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 7 commits
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,15 @@ | ||
| 'use client' | ||
|
|
||
| import { ClipLoader } from 'react-spinners' | ||
|
|
||
| export default function Loading() { | ||
| return ( | ||
| <div className="mx-auto w-full max-w-[420px] min-h-screen bg-white overflow-x-hidden"> | ||
| <div className="flex h-[60vh] items-center justify-center"> | ||
| {' '} | ||
| <ClipLoader color="#FF6B6B" size={50} /> | ||
| </div> | ||
| <div className="h-16" /> | ||
| </div> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| // apps/web/src/app/(navigation)/search/page.tsx | ||
| import SearchScreen from '@/components/search/SearchScreen' | ||
|
|
||
| type SearchParams = Promise<Record<string, string | string[] | undefined>> | ||
|
|
||
| export default async function Page({ searchParams }: { searchParams?: SearchParams }) { | ||
| const sp = (await searchParams) ?? {} | ||
|
|
||
| const first = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : (v ?? '')) | ||
|
|
||
| const initialQuery = first(sp.query) | ||
| const initialCategory = first(sp.category) | ||
|
|
||
| return <SearchScreen initialQuery={initialQuery} initialCategory={initialCategory} /> | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import type { StaticImageData } from 'next/image' | ||
|
|
||
| import iconHealth from './건강.png' | ||
| import iconGame from './게임.png' | ||
| import iconScience from './과학.png' | ||
| import iconEducation from './교육.png' | ||
| import iconTechnology from './기술.png' | ||
| import iconOthers from './기타.png' | ||
| import iconMyEvent from './내가 등록한 행사.png' | ||
| import iconVehicles from './모빌리티.png' | ||
| import iconArts from './문화예술.png' | ||
| import iconBusiness from './비지니스, 창업.png' | ||
| import iconSports from './스포츠.png' | ||
| import iconAnime from './애니메이션.png' | ||
| import iconMovieTv from './영화, 드라마.png' | ||
| import iconFoodDrinks from './음식.png' | ||
| import iconMusic from './음악.png' | ||
| import iconHotEvent from './인기 행사.png' | ||
| import iconHome from './인테리어.png' | ||
| import iconNatureOutdoors from './자연, 야외활동.png' | ||
| import iconGarden from './정원.png' | ||
| import iconCareer from './취업.png' | ||
| import iconPopup from './팝업 행사.png' | ||
| import iconFashionBeauty from './패션, 뷰티.png' | ||
|
Comment on lines
+3
to
+24
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. 저희 icon을 svg로 관리하고 있는걸로 알고 있는데 png로 가져신 이유가 궁금합니다!
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. 그... 제가 가져온게 무료 flaticon 소스라 SVG를 못 받았고, 자동 변환은 테두리/색이 깨져서 PNG로 유지했습니다!! |
||
|
|
||
| export const CATEGORY_ICON: Record<string, StaticImageData> = { | ||
| 건강: iconHealth, | ||
| 게임: iconGame, | ||
| 과학: iconScience, | ||
| 교육: iconEducation, | ||
| 기술: iconTechnology, | ||
| 기타: iconOthers, | ||
| '내가 등록한 행사': iconMyEvent, | ||
| 모빌리티: iconVehicles, | ||
| 문화예술: iconArts, | ||
| '비지니스, 창업': iconBusiness, | ||
| 스포츠: iconSports, | ||
| 애니메이션: iconAnime, | ||
| '영화, 드라마': iconMovieTv, | ||
| 음식: iconFoodDrinks, | ||
| 음악: iconMusic, | ||
| '인기 행사': iconHotEvent, | ||
| 인테리어: iconHome, | ||
| '자연, 야외활동': iconNatureOutdoors, | ||
| 정원: iconGarden, | ||
| 취업: iconCareer, | ||
| '팝업 행사': iconPopup, | ||
| '패션, 뷰티': iconFashionBeauty, | ||
| } | ||
|
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,131 @@ | ||
| 'use client' | ||
|
|
||
| import { useCallback, useEffect, useMemo, useRef, useState } from 'react' | ||
| import Image from 'next/image' | ||
| import { CATEGORY_ICON } from '@/components/icon/Categoryicon' | ||
| import type { StaticImageData } from 'next/image' | ||
|
|
||
| // 서버 타이틀을 그대로 사용 | ||
| const ALIAS: Record<string, string> = {} | ||
|
|
||
| function normalizeList(list: string[]): string[] { | ||
| const mapped = list.map(n => ALIAS[n] ?? n) | ||
| return Array.from(new Set(mapped)) | ||
| } | ||
|
|
||
| function normalizeOne(name?: string): string | undefined { | ||
| if (!name) return undefined | ||
| return ALIAS[name] ?? name | ||
| } | ||
|
|
||
| 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" | ||
| > | ||
| {hasIcon(name) ? <div className="mb-2 leading-none">{iconByName(name, active)}</div> : null} | ||
| <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 hasIcon(name: string) { | ||
| return Object.prototype.hasOwnProperty.call(CATEGORY_ICON, name) | ||
| } | ||
|
|
||
| function iconByName(name: string, active: boolean) { | ||
| const src: StaticImageData | undefined = CATEGORY_ICON[name as keyof typeof CATEGORY_ICON] | ||
| if (!src) return null | ||
| const size = 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,28 @@ | ||
| 'use client' | ||
|
|
||
| export default function SearchResultCard({ | ||
| title, | ||
| region, | ||
| period, | ||
| image, | ||
| imageHeight, | ||
| }: { | ||
| title: string | ||
| region: string | ||
| period: string | ||
| image: string | ||
| imageHeight?: number | ||
| }) { | ||
| 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 object-cover" style={{ height: imageHeight }} loading="lazy" /> | ||
| </div> | ||
|
|
||
| <h2 className="mt-3 text-[16px] font-semibold">{title}</h2> | ||
| <p className="mt-1 text-[13px] text-gray-400">{region}</p> | ||
| <p className="mt-1 text-[13px] text-gray-500">{period}</p> | ||
| </article> | ||
| ) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
검색 결과 화면은 하단의 네비게이션 바가 나오는 것보다 상단의 뒤로가기 버튼이 보이도록 하는 것이 더 적절해 보입니다.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
이 부분이 반영되지 않은 것 같아요. 반영하지 않은 이유가 있으실까요?
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
이런 느낌맞을까요
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@HeesooJun 넵 맞습니다:) 다만 검색 창이 header 부분에 있는게 디자인적으로 더 자연스러울 것 같은데, 그렇게 할려면 코드 전반을 다 수정해야겠네요..ㅠ