검색/필터 UI 개선, 채널 정렬, 드래그 스크롤, 지도링크 수정
- 검색바: 아이콘 내장, 모드 select 제거 (hybrid 고정), 엔터 검색 - 필터 그룹화: [음식 장르·가격] [지역 나라·시·구] + X 해제 버튼 - 채널 필터: 드롭다운 → 유튜브 아이콘 토글 카드, 드래그 스크롤 - 채널 정렬: sort_order 컬럼 추가, 백오피스 순서 편집 - 채널+필터 동시 적용: API 호출 대신 클라이언트 필터링 - 내위치 ON 시 다른 필터 초기화, 역방향도 동일 - 전체보기 버튼: 모든 필터 일괄 해제 - 네이버맵: 한국 식당만, 식당명만 검색 - 구글맵: 식당명+주소/지역 검색 - 로그인 영역 데스크톱 Row 1 우측 배치 - scrollbar-hide CSS 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -134,10 +134,11 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
const [editingChannel, setEditingChannel] = useState<string | null>(null);
|
||||
const [editDesc, setEditDesc] = useState("");
|
||||
const [editTags, setEditTags] = useState("");
|
||||
const [editOrder, setEditOrder] = useState<number>(99);
|
||||
|
||||
const handleSaveChannel = async (id: string) => {
|
||||
try {
|
||||
await api.updateChannel(id, { description: editDesc, tags: editTags });
|
||||
await api.updateChannel(id, { description: editDesc, tags: editTags, sort_order: editOrder });
|
||||
setEditingChannel(null);
|
||||
load();
|
||||
} catch {
|
||||
@@ -210,6 +211,7 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<th className="text-left px-4 py-3">제목 필터</th>
|
||||
<th className="text-left px-4 py-3">설명</th>
|
||||
<th className="text-left px-4 py-3">태그</th>
|
||||
<th className="text-center px-4 py-3">순서</th>
|
||||
<th className="text-right px-4 py-3">영상 수</th>
|
||||
{isAdmin && <th className="text-left px-4 py-3">액션</th>}
|
||||
<th className="text-left px-4 py-3">스캔 결과</th>
|
||||
@@ -238,7 +240,7 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
) : (
|
||||
<span className="text-gray-600 cursor-pointer" onClick={() => {
|
||||
if (!isAdmin) return;
|
||||
setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || "");
|
||||
setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || ""); setEditOrder(ch.sort_order ?? 99);
|
||||
}}>{ch.description || <span className="text-gray-400">-</span>}</span>
|
||||
)}
|
||||
</td>
|
||||
@@ -253,10 +255,18 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
) : (
|
||||
<span className="text-gray-500 cursor-pointer" onClick={() => {
|
||||
if (!isAdmin) return;
|
||||
setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || "");
|
||||
setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || ""); setEditOrder(ch.sort_order ?? 99);
|
||||
}}>{ch.tags ? ch.tags.split(",").map(t => t.trim()).join(", ") : <span className="text-gray-400">-</span>}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-xs">
|
||||
{editingChannel === ch.id ? (
|
||||
<input type="number" value={editOrder} onChange={(e) => setEditOrder(Number(e.target.value))}
|
||||
className="border rounded px-2 py-1 text-xs w-14 text-center bg-white text-gray-900" min={1} />
|
||||
) : (
|
||||
<span className="text-gray-500">{ch.sort_order ?? 99}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-medium">
|
||||
{ch.video_count > 0 ? (
|
||||
<span className="px-2 py-0.5 bg-green-50 text-green-700 rounded text-xs">{ch.video_count}개</span>
|
||||
|
||||
@@ -43,6 +43,20 @@ html, body, #__next {
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
/* Hide scrollbar but keep scrolling */
|
||||
@layer utilities {
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none !important;
|
||||
scrollbar-width: none !important;
|
||||
overflow: -moz-scrollbars-none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Safe area for iOS bottom nav */
|
||||
.safe-area-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
|
||||
@@ -14,6 +14,44 @@ import MyReviewsList from "@/components/MyReviewsList";
|
||||
import BottomSheet from "@/components/BottomSheet";
|
||||
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||
|
||||
function useDragScroll() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const drag = useRef({ active: false, startX: 0, sl: 0, moved: false });
|
||||
|
||||
const onMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
drag.current = { active: true, startX: e.clientX, sl: el.scrollLeft, moved: false };
|
||||
el.style.cursor = "grabbing";
|
||||
}, []);
|
||||
|
||||
const onMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
const d = drag.current;
|
||||
if (!d.active) return;
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const dx = e.clientX - d.startX;
|
||||
if (Math.abs(dx) > 5) d.moved = true;
|
||||
el.scrollLeft = d.sl - dx;
|
||||
}, []);
|
||||
|
||||
const onMouseUp = useCallback(() => {
|
||||
drag.current.active = false;
|
||||
const el = ref.current;
|
||||
if (el) el.style.cursor = "grab";
|
||||
}, []);
|
||||
|
||||
const onClickCapture = useCallback((e: React.MouseEvent) => {
|
||||
if (drag.current.moved) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
drag.current.moved = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { ref, onMouseDown, onMouseMove, onMouseUp, onMouseLeave: onMouseUp, onClickCapture, style: { cursor: "grab" } as const };
|
||||
}
|
||||
|
||||
const CUISINE_TAXONOMY: { category: string; items: string[] }[] = [
|
||||
{ category: "한식", items: ["백반/한정식", "국밥/해장국", "찌개/전골/탕", "삼겹살/돼지구이", "소고기/한우구이", "곱창/막창", "닭/오리구이", "족발/보쌈", "회/횟집", "해산물", "분식", "면", "죽/죽집", "순대/순대국", "장어/민물", "주점/포차", "파인다이닝/코스"] },
|
||||
{ category: "일식", items: ["스시/오마카세", "라멘", "돈카츠", "텐동/튀김", "이자카야", "야키니쿠", "카레", "소바/우동", "파인다이닝/코스"] },
|
||||
@@ -157,6 +195,8 @@ export default function Home() {
|
||||
const [isSearchResult, setIsSearchResult] = useState(false);
|
||||
const [resetCount, setResetCount] = useState(0);
|
||||
const geoApplied = useRef(false);
|
||||
const dd = useDragScroll();
|
||||
const dm = useDragScroll();
|
||||
|
||||
const regionTree = useMemo(() => buildRegionTree(restaurants), [restaurants]);
|
||||
const countries = useMemo(() => [...regionTree.keys()].sort(), [regionTree]);
|
||||
@@ -223,16 +263,16 @@ export default function Home() {
|
||||
api.recordVisit().then(() => api.getVisits().then(setVisits)).catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Load restaurants on mount and when channel filter changes
|
||||
// Load all restaurants on mount
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setIsSearchResult(false);
|
||||
api
|
||||
.getRestaurants({ limit: 500, channel: channelFilter || undefined })
|
||||
.getRestaurants({ limit: 500 })
|
||||
.then(setRestaurants)
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, [channelFilter]);
|
||||
}, []);
|
||||
|
||||
// Auto-select region from user's geolocation (once)
|
||||
useEffect(() => {
|
||||
@@ -316,6 +356,7 @@ export default function Home() {
|
||||
setCountryFilter(country);
|
||||
setCityFilter("");
|
||||
setDistrictFilter("");
|
||||
setBoundsFilterOn(false);
|
||||
if (!country) { setRegionFlyTo(null); return; }
|
||||
const matched = restaurants.filter((r) => {
|
||||
const p = parseRegion(r.region);
|
||||
@@ -413,7 +454,7 @@ export default function Home() {
|
||||
setShowFavorites(false);
|
||||
setShowMyReviews(false);
|
||||
setMyReviews([]);
|
||||
api.getRestaurants({ limit: 500, channel: channelFilter || undefined }).then(setRestaurants);
|
||||
api.getRestaurants({ limit: 500 }).then(setRestaurants);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -439,7 +480,7 @@ export default function Home() {
|
||||
} catch { /* ignore */ }
|
||||
// 프로필에서는 식당 목록을 원래대로 복원
|
||||
if (showFavorites) {
|
||||
api.getRestaurants({ limit: 500, channel: channelFilter || undefined }).then(setRestaurants);
|
||||
api.getRestaurants({ limit: 500 }).then(setRestaurants);
|
||||
}
|
||||
} else {
|
||||
// 홈 / 식당 목록 - 항상 전체 식당 목록으로 복원
|
||||
@@ -448,16 +489,16 @@ export default function Home() {
|
||||
setShowMyReviews(false);
|
||||
setMyReviews([]);
|
||||
if (needReload) {
|
||||
const data = await api.getRestaurants({ limit: 500, channel: channelFilter || undefined });
|
||||
const data = await api.getRestaurants({ limit: 500 });
|
||||
setRestaurants(data);
|
||||
}
|
||||
}
|
||||
}, [user, showFavorites, showMyReviews, channelFilter, mobileTab, handleReset]);
|
||||
}, [user, showFavorites, showMyReviews, mobileTab, handleReset]);
|
||||
|
||||
const handleToggleFavorites = async () => {
|
||||
if (showFavorites) {
|
||||
setShowFavorites(false);
|
||||
const data = await api.getRestaurants({ limit: 500, channel: channelFilter || undefined });
|
||||
const data = await api.getRestaurants({ limit: 500 });
|
||||
setRestaurants(data);
|
||||
} else {
|
||||
try {
|
||||
@@ -550,11 +591,11 @@ export default function Home() {
|
||||
Tasteby
|
||||
</button>
|
||||
|
||||
{/* Desktop: search + filters — two rows */}
|
||||
<div className="hidden md:flex flex-col gap-2.5 mx-6">
|
||||
{/* Row 1: Search + dropdown filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-96 shrink-0">
|
||||
{/* Desktop: search + filters */}
|
||||
<div className="hidden md:flex flex-col gap-2 mx-6 flex-1 min-w-0">
|
||||
{/* Row 1: Search + actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-80 shrink-0">
|
||||
<SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} />
|
||||
</div>
|
||||
<button
|
||||
@@ -562,136 +603,76 @@ export default function Home() {
|
||||
className="p-1.5 text-gray-400 dark:text-gray-500 hover:text-orange-500 dark:hover:text-orange-400 transition-colors"
|
||||
title="초기화"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
</button>
|
||||
<select
|
||||
value={cuisineFilter}
|
||||
onChange={(e) => setCuisineFilter(e.target.value)}
|
||||
className="border dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 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)}
|
||||
className="border dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">💰 가격</option>
|
||||
{PRICE_GROUPS.map((g) => (
|
||||
<option key={g.label} value={g.label}>{g.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* Row 2: Region filters + Toggle buttons + count */}
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={countryFilter}
|
||||
onChange={(e) => handleCountryChange(e.target.value)}
|
||||
className="border dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">🌍 나라</option>
|
||||
{countries.map((c) => (
|
||||
<option key={c} value={c}>🌍 {c}</option>
|
||||
))}
|
||||
</select>
|
||||
{countryFilter && cities.length > 0 && (
|
||||
<select
|
||||
value={cityFilter}
|
||||
onChange={(e) => handleCityChange(e.target.value)}
|
||||
className="border dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">🏙 전체 시/도</option>
|
||||
{cities.map((c) => (
|
||||
<option key={c} value={c}>🏙 {c}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{cityFilter && districts.length > 0 && (
|
||||
<select
|
||||
value={districtFilter}
|
||||
onChange={(e) => handleDistrictChange(e.target.value)}
|
||||
className="border dark:border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 dark:bg-gray-800"
|
||||
>
|
||||
<option value="">🏘 전체 구/군</option>
|
||||
{districts.map((d) => (
|
||||
<option key={d} value={d}>🏘 {d}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<div className="w-px h-5 bg-gray-200 dark:bg-gray-700" />
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = !boundsFilterOn;
|
||||
setBoundsFilterOn(next);
|
||||
if (next) {
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }),
|
||||
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 }),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
} else {
|
||||
setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 });
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-1.5 text-sm border rounded-lg transition-colors ${
|
||||
boundsFilterOn
|
||||
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400"
|
||||
: "hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400"
|
||||
}`}
|
||||
title="내 위치 주변 식당만 표시"
|
||||
>
|
||||
{boundsFilterOn ? "📍 내위치 ON" : "📍 내위치"}
|
||||
<svg viewBox="0 0 24 24" className="w-4.5 h-4.5 fill-current"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
</button>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 tabular-nums">
|
||||
{filteredRestaurants.length}개
|
||||
</span>
|
||||
<div className="w-px h-4 bg-gray-200 dark:bg-gray-700" />
|
||||
<button
|
||||
onClick={() => setViewMode(viewMode === "map" ? "list" : "map")}
|
||||
className="px-3 py-1.5 text-sm border rounded-lg transition-colors hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400"
|
||||
title={viewMode === "map" ? "리스트 우선" : "지도 우선"}
|
||||
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
|
||||
viewMode === "map"
|
||||
? "bg-blue-50 dark:bg-blue-900/30 border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-blue-300 hover:text-blue-500"
|
||||
}`}
|
||||
>
|
||||
{viewMode === "map" ? "🗺 지도" : "☰ 리스트"}
|
||||
{viewMode === "map" ? "🗺 지도우선" : "☰ 목록우선"}
|
||||
</button>
|
||||
{user && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleToggleFavorites}
|
||||
className={`px-3.5 py-1.5 text-sm rounded-full border transition-colors ${
|
||||
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
|
||||
showFavorites
|
||||
? "bg-rose-50 dark:bg-rose-900/30 border-rose-300 dark:border-rose-700 text-rose-600 dark:text-rose-400"
|
||||
: "border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-100"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-rose-300 hover:text-rose-500"
|
||||
}`}
|
||||
>
|
||||
{showFavorites ? "♥ 내 찜" : "♡ 찜"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleMyReviews}
|
||||
className={`px-3.5 py-1.5 text-sm rounded-full border transition-colors ${
|
||||
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
|
||||
showMyReviews
|
||||
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400"
|
||||
: "border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-100"
|
||||
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-orange-300 hover:text-orange-500"
|
||||
}`}
|
||||
>
|
||||
{showMyReviews ? "✎ 내 리뷰" : "✎ 리뷰"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<span className="text-sm text-gray-500 whitespace-nowrap">
|
||||
{filteredRestaurants.length}개
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
{/* Desktop user area */}
|
||||
{authLoading ? null : user ? (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{user.avatar_url ? (
|
||||
<img src={user.avatar_url} alt="" className="w-7 h-7 rounded-full border border-gray-200" />
|
||||
) : (
|
||||
<div className="w-7 h-7 rounded-full bg-orange-100 text-orange-700 flex items-center justify-center text-xs font-semibold border border-orange-200">
|
||||
{(user.nickname || user.email || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-300 max-w-[80px] truncate">
|
||||
{user.nickname || user.email}
|
||||
</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="px-2 py-0.5 text-[10px] text-gray-400 dark:text-gray-500 border border-gray-200 dark:border-gray-700 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="shrink-0">
|
||||
<LoginMenu onGoogleSuccess={(credential) => login(credential).catch(console.error)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Row 3: Channel cards (toggle filter) - max 4 visible, scroll for rest */}
|
||||
{/* Row 2: Channel cards (toggle filter) */}
|
||||
{!isSearchResult && channels.length > 0 && (
|
||||
<div className="overflow-x-auto scrollbar-hide" style={{ maxWidth: `${4 * 200 + 3 * 8}px` }}>
|
||||
<div ref={dd.ref} onMouseDown={dd.onMouseDown} onMouseMove={dd.onMouseMove} onMouseUp={dd.onMouseUp} onMouseLeave={dd.onMouseLeave} onClickCapture={dd.onClickCapture} style={dd.style} className="overflow-x-auto scrollbar-hide select-none">
|
||||
<div className="flex gap-2">
|
||||
{channels.map((ch) => (
|
||||
<button
|
||||
@@ -720,10 +701,168 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Row 3: Filters — grouped by category */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{/* 음식 필터 그룹 */}
|
||||
<div className="flex items-center gap-1.5 bg-gray-50 dark:bg-gray-800/50 rounded-lg px-2 py-1">
|
||||
<span className="text-gray-400 dark:text-gray-500 text-[10px] font-medium shrink-0">음식</span>
|
||||
<select
|
||||
value={cuisineFilter}
|
||||
onChange={(e) => { setCuisineFilter(e.target.value); if (e.target.value) setBoundsFilterOn(false); }}
|
||||
className={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
|
||||
cuisineFilter ? "text-orange-600 dark:text-orange-400 font-medium" : "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
<div className="w-px h-3 bg-gray-200 dark:bg-gray-700" />
|
||||
<select
|
||||
value={priceFilter}
|
||||
onChange={(e) => { setPriceFilter(e.target.value); if (e.target.value) setBoundsFilterOn(false); }}
|
||||
className={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
|
||||
priceFilter ? "text-orange-600 dark:text-orange-400 font-medium" : "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<option value="">가격</option>
|
||||
{PRICE_GROUPS.map((g) => (
|
||||
<option key={g.label} value={g.label}>{g.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{(cuisineFilter || priceFilter) && (
|
||||
<button
|
||||
onClick={() => { setCuisineFilter(""); setPriceFilter(""); }}
|
||||
className="text-gray-400 hover:text-orange-500 transition-colors"
|
||||
title="음식 필터 초기화"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="w-3 h-3 fill-current"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 지역 필터 그룹 */}
|
||||
<div className="flex items-center gap-1.5 bg-gray-50 dark:bg-gray-800/50 rounded-lg px-2 py-1">
|
||||
<span className="text-gray-400 dark:text-gray-500 text-[10px] font-medium shrink-0">지역</span>
|
||||
<select
|
||||
value={countryFilter}
|
||||
onChange={(e) => handleCountryChange(e.target.value)}
|
||||
className={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
|
||||
countryFilter ? "text-orange-600 dark:text-orange-400 font-medium" : "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<option value="">나라</option>
|
||||
{countries.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
{countryFilter && cities.length > 0 && (
|
||||
<>
|
||||
<div className="w-px h-3 bg-gray-200 dark:bg-gray-700" />
|
||||
<select
|
||||
value={cityFilter}
|
||||
onChange={(e) => handleCityChange(e.target.value)}
|
||||
className={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
|
||||
cityFilter ? "text-orange-600 dark:text-orange-400 font-medium" : "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<option value="">시/도</option>
|
||||
{cities.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
{cityFilter && districts.length > 0 && (
|
||||
<>
|
||||
<div className="w-px h-3 bg-gray-200 dark:bg-gray-700" />
|
||||
<select
|
||||
value={districtFilter}
|
||||
onChange={(e) => handleDistrictChange(e.target.value)}
|
||||
className={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
|
||||
districtFilter ? "text-orange-600 dark:text-orange-400 font-medium" : "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<option value="">구/군</option>
|
||||
{districts.map((d) => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
{countryFilter && (
|
||||
<button
|
||||
onClick={() => { setCountryFilter(""); setCityFilter(""); setDistrictFilter(""); setRegionFlyTo(null); }}
|
||||
className="text-gray-400 hover:text-orange-500 transition-colors"
|
||||
title="지역 필터 초기화"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="w-3 h-3 fill-current"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 필터 전체 해제 */}
|
||||
{(channelFilter || cuisineFilter || priceFilter || countryFilter) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setChannelFilter("");
|
||||
setCuisineFilter("");
|
||||
setPriceFilter("");
|
||||
setCountryFilter("");
|
||||
setCityFilter("");
|
||||
setDistrictFilter("");
|
||||
setRegionFlyTo(null);
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-lg px-2 py-1 bg-gray-50 dark:bg-gray-800/50 text-gray-500 dark:text-gray-400 hover:text-orange-500 transition-colors"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="w-3 h-3 fill-current"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||
<span>전체보기</span>
|
||||
</button>
|
||||
)}
|
||||
{/* 내위치 토글 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = !boundsFilterOn;
|
||||
setBoundsFilterOn(next);
|
||||
if (next) {
|
||||
// 내위치 ON 시 다른 필터 초기화
|
||||
setCuisineFilter("");
|
||||
setPriceFilter("");
|
||||
setCountryFilter("");
|
||||
setCityFilter("");
|
||||
setDistrictFilter("");
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }),
|
||||
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 }),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
} else {
|
||||
setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 15 });
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`flex items-center gap-1 rounded-lg px-2 py-1 transition-colors ${
|
||||
boundsFilterOn
|
||||
? "bg-orange-50 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400"
|
||||
: "bg-gray-50 dark:bg-gray-800/50 text-gray-500 dark:text-gray-400 hover:text-orange-500"
|
||||
}`}
|
||||
title="내 위치 주변 식당만 표시"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 fill-current"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 010-5 2.5 2.5 0 010 5z"/></svg>
|
||||
<span>{boundsFilterOn ? "내위치 ON" : "내위치"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User area */}
|
||||
<div className="shrink-0 flex items-center gap-3 ml-auto">
|
||||
{/* User area (mobile only - desktop moved to Row 1) */}
|
||||
<div className="shrink-0 flex items-center gap-3 ml-auto md:hidden">
|
||||
{authLoading ? null : user ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{user.avatar_url ? (
|
||||
@@ -737,15 +876,6 @@ export default function Home() {
|
||||
{(user.nickname || user.email || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="hidden sm:inline text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{user.nickname || user.email}
|
||||
</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="ml-1 px-2.5 py-1 text-xs text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<LoginMenu onGoogleSuccess={(credential) => login(credential).catch(console.error)} />
|
||||
@@ -759,7 +889,7 @@ export default function Home() {
|
||||
<SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} />
|
||||
{/* Channel cards - toggle filter */}
|
||||
{mobileTab === "home" && !isSearchResult && channels.length > 0 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 -mx-1 px-1 scrollbar-hide">
|
||||
<div ref={dm.ref} onMouseDown={dm.onMouseDown} onMouseMove={dm.onMouseMove} onMouseUp={dm.onMouseUp} onMouseLeave={dm.onMouseLeave} onClickCapture={dm.onClickCapture} style={dm.style} className="flex gap-2 overflow-x-auto pb-1 -mx-1 px-1 scrollbar-hide select-none">
|
||||
{channels.map((ch) => (
|
||||
<button
|
||||
key={ch.id}
|
||||
@@ -823,7 +953,7 @@ export default function Home() {
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<select
|
||||
value={cuisineFilter}
|
||||
onChange={(e) => setCuisineFilter(e.target.value)}
|
||||
onChange={(e) => { setCuisineFilter(e.target.value); if (e.target.value) setBoundsFilterOn(false); }}
|
||||
className="border dark:border-gray-700 rounded-lg px-2.5 py-1.5 text-xs text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800"
|
||||
>
|
||||
<option value="">🍽 장르</option>
|
||||
@@ -840,7 +970,7 @@ export default function Home() {
|
||||
</select>
|
||||
<select
|
||||
value={priceFilter}
|
||||
onChange={(e) => setPriceFilter(e.target.value)}
|
||||
onChange={(e) => { setPriceFilter(e.target.value); if (e.target.value) setBoundsFilterOn(false); }}
|
||||
className="border dark:border-gray-700 rounded-lg px-2.5 py-1.5 text-xs text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800"
|
||||
>
|
||||
<option value="">💰 가격</option>
|
||||
@@ -893,6 +1023,12 @@ export default function Home() {
|
||||
const next = !boundsFilterOn;
|
||||
setBoundsFilterOn(next);
|
||||
if (next) {
|
||||
// 내위치 ON 시 다른 필터 초기화
|
||||
setCuisineFilter("");
|
||||
setPriceFilter("");
|
||||
setCountryFilter("");
|
||||
setCityFilter("");
|
||||
setDistrictFilter("");
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }),
|
||||
|
||||
Reference in New Issue
Block a user