Compare commits
4 Commits
v0.1.9
...
18776b9b4b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18776b9b4b | ||
|
|
177532e6e7 | ||
|
|
64d58cb553 | ||
|
|
a766a74f20 |
@@ -101,3 +101,12 @@ html, body, #__next {
|
||||
.safe-area-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
/* Filter sheet slide-up animation */
|
||||
@keyframes slide-up {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.25s ease-out;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import RestaurantList from "@/components/RestaurantList";
|
||||
import RestaurantDetail from "@/components/RestaurantDetail";
|
||||
import MyReviewsList from "@/components/MyReviewsList";
|
||||
import BottomSheet from "@/components/BottomSheet";
|
||||
import FilterSheet, { FilterOption } from "@/components/FilterSheet";
|
||||
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||
import Icon from "@/components/Icon";
|
||||
|
||||
@@ -187,6 +188,7 @@ export default function Home() {
|
||||
const [countryFilter, setCountryFilter] = useState("");
|
||||
const [cityFilter, setCityFilter] = useState("");
|
||||
const [districtFilter, setDistrictFilter] = useState("");
|
||||
const [openSheet, setOpenSheet] = useState<"cuisine" | "price" | "country" | "city" | "district" | null>(null);
|
||||
const [regionFlyTo, setRegionFlyTo] = useState<FlyTo | null>(null);
|
||||
const [showFavorites, setShowFavorites] = useState(false);
|
||||
const [showMyReviews, setShowMyReviews] = useState(false);
|
||||
@@ -247,6 +249,34 @@ export default function Home() {
|
||||
});
|
||||
}, [restaurants, isSearchResult, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]);
|
||||
|
||||
// FilterSheet option builders
|
||||
const cuisineOptions = useMemo<FilterOption[]>(() => {
|
||||
const opts: FilterOption[] = [];
|
||||
for (const g of CUISINE_TAXONOMY) {
|
||||
opts.push({ label: `${g.category} 전체`, value: g.category, group: g.category });
|
||||
for (const item of g.items) {
|
||||
opts.push({ label: item, value: `${g.category}|${item}`, group: g.category });
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}, []);
|
||||
|
||||
const priceOptions = useMemo<FilterOption[]>(() =>
|
||||
PRICE_GROUPS.map((g) => ({ label: g.label, value: g.label })),
|
||||
[]);
|
||||
|
||||
const countryOptions = useMemo<FilterOption[]>(() =>
|
||||
countries.map((c) => ({ label: c, value: c })),
|
||||
[countries]);
|
||||
|
||||
const cityOptions = useMemo<FilterOption[]>(() =>
|
||||
cities.map((c) => ({ label: c, value: c })),
|
||||
[cities]);
|
||||
|
||||
const districtOptions = useMemo<FilterOption[]>(() =>
|
||||
districts.map((d) => ({ label: d, value: d })),
|
||||
[districts]);
|
||||
|
||||
// Set desktop default to map mode on mount + get user location
|
||||
useEffect(() => {
|
||||
if (window.innerWidth >= 768) setViewMode("map");
|
||||
@@ -344,7 +374,7 @@ export default function Home() {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude });
|
||||
setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 16 });
|
||||
setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 17 });
|
||||
},
|
||||
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 16 }),
|
||||
{ timeout: 5000 },
|
||||
@@ -957,37 +987,30 @@ export default function Home() {
|
||||
<div className="space-y-1.5">
|
||||
{/* Line 1: 음식 장르 + 가격 + 결과수 */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<select
|
||||
value={cuisineFilter}
|
||||
onChange={(e) => { setCuisineFilter(e.target.value); if (e.target.value) setBoundsFilterOn(false); }}
|
||||
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
|
||||
cuisineFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
|
||||
<button
|
||||
onClick={() => setOpenSheet("cuisine")}
|
||||
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
|
||||
cuisineFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<option value="">🍽 장르</option>
|
||||
{CUISINE_TAXONOMY.map((g) => (
|
||||
<optgroup key={g.category} label={`── ${g.category} ──`}>
|
||||
<option value={g.category}>{g.category} 전체</option>
|
||||
{g.items.map((item) => (
|
||||
<option key={`${g.category}|${item}`} value={`${g.category}|${item}`}>
|
||||
{item}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={priceFilter}
|
||||
onChange={(e) => { setPriceFilter(e.target.value); if (e.target.value) setBoundsFilterOn(false); }}
|
||||
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
|
||||
priceFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
|
||||
<Icon name="restaurant" size={14} className={`mr-1 ${cuisineFilter ? "text-brand-500" : "text-gray-400"}`} />
|
||||
<span className={cuisineFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
|
||||
{cuisineFilter ? (cuisineFilter.includes("|") ? cuisineFilter.split("|")[1] : cuisineFilter) : "장르"}
|
||||
</span>
|
||||
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpenSheet("price")}
|
||||
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
|
||||
priceFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<option value="">💰 가격</option>
|
||||
{PRICE_GROUPS.map((g) => (
|
||||
<option key={g.label} value={g.label}>{g.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<Icon name="payments" size={14} className={`mr-1 ${priceFilter ? "text-brand-500" : "text-gray-400"}`} />
|
||||
<span className={priceFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
|
||||
{priceFilter || "가격"}
|
||||
</span>
|
||||
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
|
||||
</button>
|
||||
{(cuisineFilter || priceFilter) && (
|
||||
<button onClick={() => { setCuisineFilter(""); setPriceFilter(""); }} className="text-gray-400 hover:text-brand-500">
|
||||
<Icon name="close" size={14} />
|
||||
@@ -997,45 +1020,43 @@ export default function Home() {
|
||||
</div>
|
||||
{/* Line 2: 나라 + 시 + 구 + 내위치 */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<select
|
||||
value={countryFilter}
|
||||
onChange={(e) => handleCountryChange(e.target.value)}
|
||||
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
|
||||
countryFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
|
||||
<button
|
||||
onClick={() => setOpenSheet("country")}
|
||||
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
|
||||
countryFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<option value="">🌍 나라</option>
|
||||
{countries.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
<Icon name="public" size={14} className={`mr-1 ${countryFilter ? "text-brand-500" : "text-gray-400"}`} />
|
||||
<span className={countryFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
|
||||
{countryFilter || "나라"}
|
||||
</span>
|
||||
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
|
||||
</button>
|
||||
{countryFilter && cities.length > 0 && (
|
||||
<select
|
||||
value={cityFilter}
|
||||
onChange={(e) => handleCityChange(e.target.value)}
|
||||
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
|
||||
cityFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
|
||||
<button
|
||||
onClick={() => setOpenSheet("city")}
|
||||
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
|
||||
cityFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<option value="">시/도</option>
|
||||
{cities.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className={cityFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
|
||||
{cityFilter || "시/도"}
|
||||
</span>
|
||||
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
{cityFilter && districts.length > 0 && (
|
||||
<select
|
||||
value={districtFilter}
|
||||
onChange={(e) => handleDistrictChange(e.target.value)}
|
||||
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
|
||||
districtFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
|
||||
<button
|
||||
onClick={() => setOpenSheet("district")}
|
||||
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
|
||||
districtFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<option value="">구/군</option>
|
||||
{districts.map((d) => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className={districtFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
|
||||
{districtFilter || "구/군"}
|
||||
</span>
|
||||
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
{countryFilter && (
|
||||
<button onClick={() => { setCountryFilter(""); setCityFilter(""); setDistrictFilter(""); setRegionFlyTo(null); }} className="text-gray-400 hover:text-brand-500">
|
||||
@@ -1285,6 +1306,47 @@ export default function Home() {
|
||||
SDJ Labs Co., Ltd.
|
||||
</span>
|
||||
</footer>
|
||||
{/* Mobile Filter Sheets */}
|
||||
<FilterSheet
|
||||
open={openSheet === "cuisine"}
|
||||
onClose={() => setOpenSheet(null)}
|
||||
title="음식 장르"
|
||||
options={cuisineOptions}
|
||||
value={cuisineFilter}
|
||||
onChange={(v) => { setCuisineFilter(v); if (v) setBoundsFilterOn(false); }}
|
||||
/>
|
||||
<FilterSheet
|
||||
open={openSheet === "price"}
|
||||
onClose={() => setOpenSheet(null)}
|
||||
title="가격대"
|
||||
options={priceOptions}
|
||||
value={priceFilter}
|
||||
onChange={(v) => { setPriceFilter(v); if (v) setBoundsFilterOn(false); }}
|
||||
/>
|
||||
<FilterSheet
|
||||
open={openSheet === "country"}
|
||||
onClose={() => setOpenSheet(null)}
|
||||
title="나라"
|
||||
options={countryOptions}
|
||||
value={countryFilter}
|
||||
onChange={handleCountryChange}
|
||||
/>
|
||||
<FilterSheet
|
||||
open={openSheet === "city"}
|
||||
onClose={() => setOpenSheet(null)}
|
||||
title="시/도"
|
||||
options={cityOptions}
|
||||
value={cityFilter}
|
||||
onChange={handleCityChange}
|
||||
/>
|
||||
<FilterSheet
|
||||
open={openSheet === "district"}
|
||||
onClose={() => setOpenSheet(null)}
|
||||
title="구/군"
|
||||
options={districtOptions}
|
||||
value={districtFilter}
|
||||
onChange={handleDistrictChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
112
frontend/src/components/FilterSheet.tsx
Normal file
112
frontend/src/components/FilterSheet.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import Icon from "@/components/Icon";
|
||||
|
||||
export interface FilterOption {
|
||||
label: string;
|
||||
value: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
interface FilterSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
options: FilterOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function FilterSheet({ open, onClose, title, options, value, onChange }: FilterSheetProps) {
|
||||
const sheetRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, [open]);
|
||||
|
||||
// Group options by group field
|
||||
const grouped = options.reduce<Record<string, FilterOption[]>>((acc, opt) => {
|
||||
const key = opt.group || "";
|
||||
if (!acc[key]) acc[key] = [];
|
||||
acc[key].push(opt);
|
||||
return acc;
|
||||
}, {});
|
||||
const groups = Object.keys(grouped);
|
||||
|
||||
const handleSelect = (v: string) => {
|
||||
onChange(v);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-[60] bg-black/30 md:hidden"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Sheet */}
|
||||
<div
|
||||
ref={sheetRef}
|
||||
className="fixed bottom-0 left-0 right-0 z-[61] md:hidden bg-surface rounded-t-2xl shadow-2xl max-h-[70vh] flex flex-col animate-slide-up"
|
||||
>
|
||||
{/* Handle */}
|
||||
<div className="flex justify-center pt-2 pb-1 shrink-0">
|
||||
<div className="w-10 h-1 bg-gray-300 dark:bg-gray-600 rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-100 dark:border-gray-800 shrink-0">
|
||||
<h3 className="font-bold text-base text-gray-900 dark:text-gray-100">{title}</h3>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600">
|
||||
<Icon name="close" size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain pb-safe">
|
||||
{/* 전체(초기화) */}
|
||||
<button
|
||||
onClick={() => handleSelect("")}
|
||||
className={`w-full text-left px-4 py-3 flex items-center justify-between border-b border-gray-50 dark:border-gray-800/50 ${
|
||||
!value ? "text-brand-600 dark:text-brand-400 font-medium bg-brand-50/50 dark:bg-brand-900/20" : "text-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span className="text-[15px]">전체</span>
|
||||
{!value && <Icon name="check" size={18} className="text-brand-500" />}
|
||||
</button>
|
||||
|
||||
{groups.map((group) => (
|
||||
<div key={group}>
|
||||
{group && (
|
||||
<div className="px-4 py-2.5 text-xs font-semibold text-gray-400 dark:text-gray-500 tracking-wider bg-gray-50 dark:bg-gray-800/50 sticky top-0">
|
||||
{group}
|
||||
</div>
|
||||
)}
|
||||
{grouped[group].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handleSelect(opt.value)}
|
||||
className={`w-full text-left px-4 py-3 flex items-center justify-between border-b border-gray-50 dark:border-gray-800/50 active:bg-gray-100 dark:active:bg-gray-800 ${
|
||||
value === opt.value
|
||||
? "text-brand-600 dark:text-brand-400 font-medium bg-brand-50/50 dark:bg-brand-900/20"
|
||||
: "text-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span className="text-[15px]">{opt.label}</span>
|
||||
{value === opt.value && <Icon name="check" size={18} className="text-brand-500" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -44,28 +44,44 @@ export default function RestaurantList({
|
||||
: "bg-surface border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="font-bold text-[15px] text-gray-900 dark:text-gray-100">
|
||||
{/* 1줄: 식당명 + 지역 + 별점 (전체 폭) */}
|
||||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||
<h4 className="font-bold text-[15px] text-gray-900 dark:text-gray-100 shrink-0">
|
||||
<Icon name={getCuisineIcon(r.cuisine_type)} size={16} className="mr-0.5 text-brand-600" />
|
||||
{r.name}
|
||||
</h4>
|
||||
{r.region && (
|
||||
<span className="text-[11px] text-gray-400 dark:text-gray-500 truncate">{r.region}</span>
|
||||
)}
|
||||
{r.rating && (
|
||||
<span className="text-xs text-yellow-600 dark:text-yellow-400 font-medium whitespace-nowrap shrink-0">
|
||||
★ {r.rating}
|
||||
</span>
|
||||
<span className="text-xs text-yellow-600 dark:text-yellow-400 font-medium whitespace-nowrap shrink-0">★ {r.rating}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-2 gap-y-0.5 mt-1.5 text-xs">
|
||||
{r.cuisine_type && (
|
||||
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400">{r.cuisine_type}</span>
|
||||
)}
|
||||
{r.price_range && (
|
||||
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400">{r.price_range}</span>
|
||||
{/* 2줄: 종류/가격(왼) + 유튜브채널(우) */}
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<div className="flex gap-x-2 text-xs flex-1 min-w-0">
|
||||
{r.cuisine_type && (
|
||||
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400 shrink-0">{r.cuisine_type}</span>
|
||||
)}
|
||||
{r.price_range && (
|
||||
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400 truncate min-w-0">{r.price_range}</span>
|
||||
)}
|
||||
</div>
|
||||
{r.channels && r.channels.length > 0 && (
|
||||
<div className="shrink-0 flex flex-wrap gap-1 justify-end">
|
||||
{r.channels.map((ch) => (
|
||||
<span
|
||||
key={ch}
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 rounded-full text-[10px] font-medium truncate max-w-[120px]"
|
||||
>
|
||||
<Icon name="play_circle" size={11} filled className="shrink-0 text-red-400" />
|
||||
<span className="truncate">{ch}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{r.region && (
|
||||
<p className="mt-1 text-[11px] text-gray-400 dark:text-gray-500 truncate">{r.region}</p>
|
||||
)}
|
||||
{/* 3줄: 태그 (전체 폭) */}
|
||||
{r.foods_mentioned && r.foods_mentioned.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{r.foods_mentioned.slice(0, 5).map((f, i) => (
|
||||
@@ -81,19 +97,6 @@ export default function RestaurantList({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{r.channels && r.channels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{r.channels.map((ch) => (
|
||||
<span
|
||||
key={ch}
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 rounded-full text-[10px] font-medium"
|
||||
>
|
||||
<Icon name="play_circle" size={11} filled className="shrink-0 text-red-400" />
|
||||
{ch}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user