Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/web/src/app/(backButton)/search/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'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>
)
}
15 changes: 15 additions & 0 deletions apps/web/src/app/(backButton)/search/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// apps/web/src/app/(backButton)/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} />
}
16 changes: 15 additions & 1 deletion apps/web/src/components/events/event.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ export default function Event({ event }: Props) {
)
}

const toDate = (value: Date | string | undefined) => {
if (!value) return undefined
const d = value instanceof Date ? value : new Date(value)
return Number.isNaN(d.getTime()) ? undefined : d
}

const formatRange = (start: Date | string | undefined, end: Date | string | undefined) => {
const s = toDate(start)
const e = toDate(end)
if (!s && !e) return '날짜 미정'
const render = (d?: Date) => (d ? format(d, 'yyyy-MM-dd') : '미정')
return `${render(s)}~${render(e)}`
}

const { setEvent } = eventStore()
const { setTitle } = titleStore()
const onClick = () => {
Expand All @@ -39,7 +53,7 @@ export default function Event({ event }: Props) {
>
<div className="flex flex-col min-h-32 w-2/3 ps-2">
<div className="text-lg font-semibold">{event.name}</div>
<div className="text-base text-gray-600">{`${format(event.startDate, 'yyyy-MM-dd')}~${format(event.endDate, 'yyyy-MM-dd')}`}</div>
<div className="text-base text-gray-600">{formatRange(event.startDate, event.endDate)}</div>
<div className="text-base text-gray-600">{event.address}</div>
</div>
<div className="flex-1 h-36 bg-[#D9D9D9] overflow-hidden mt-1 me-2 rounded">
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/events/photoUploader.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable prettier/prettier */
import React, { useRef } from 'react'
import { ClipLoader } from 'react-spinners'

Expand Down Expand Up @@ -57,6 +58,7 @@ const ImagePreview: React.FC<{ s3Key: string; onRemove: () => void }> = ({ s3Key

return (
<div className="relative w-16 h-16 border border-gray-300 rounded-lg overflow-hidden flex-shrink-0">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={imageUrl} alt={`Uploaded image`} className="w-full h-full object-cover" />
<button
type="button"
Expand Down
49 changes: 49 additions & 0 deletions apps/web/src/components/icon/Categoryicon/index.ts
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저희 icon을 svg로 관리하고 있는걸로 알고 있는데 png로 가져신 이유가 궁금합니다!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저희 행사 카테고리 중 해당 카테고리들을 선택한 이유가 있을까요?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

화면만 구성하려고 임시로 다 넣은거라 나중에 추가하려고 했습니다!!

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
131 changes: 131 additions & 0 deletions apps/web/src/components/search/CategoryTabs.tsx
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" />
Comment thread
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' }}
/>
)
}
48 changes: 48 additions & 0 deletions apps/web/src/components/search/SearchResultCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use client'

import { useMemo } from 'react'
import { ClipLoader } from 'react-spinners'

import { useImageLoader } from '@/hooks/s3/useImageLoader'

const FALLBACK_IMAGE = 'https://picsum.photos/seed/fallback/600/400'

export default function SearchResultCard({
title,
region,
period,
image,
imageHeight,
}: {
title: string
region: string
period: string
image: string
imageHeight?: number
}) {
const { imageUrl, isLoading } = useImageLoader(image)

const resolvedSrc = useMemo(() => {
if (imageUrl) return imageUrl
return FALLBACK_IMAGE
}, [imageUrl])

return (
<article className="w-full">
<div className="rounded-2xl overflow-hidden" style={{ height: imageHeight }}>
{isLoading ? (
<div className="flex h-full items-center justify-center bg-gray-100">
<ClipLoader color="#FF6B6B" size={28} />
</div>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img src={resolvedSrc} alt={title} className="w-full h-full object-cover" 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>
)
}
Loading