검색/필터 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:
joungmin
2026-03-11 20:42:25 +09:00
parent 2a0ee1d2cc
commit e85e135c8b
11 changed files with 342 additions and 175 deletions

View File

@@ -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>

View File

@@ -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);

View File

@@ -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}`}>
&nbsp;&nbsp;{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}`}>
&nbsp;&nbsp;{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 }),

View File

@@ -125,21 +125,23 @@ export default function RestaurantDetail({
{restaurant.google_place_id && (
<p className="flex gap-3">
<a
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(restaurant.name)}`}
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(restaurant.name + (restaurant.address ? " " + restaurant.address : restaurant.region ? " " + restaurant.region.replace(/\|/g, " ") : ""))}`}
target="_blank"
rel="noopener noreferrer"
className="text-orange-600 dark:text-orange-400 hover:underline text-xs"
>
Google Maps에서
</a>
<a
href={`https://map.naver.com/v5/search/${encodeURIComponent(restaurant.name)}`}
target="_blank"
rel="noopener noreferrer"
className="text-green-600 dark:text-green-400 hover:underline text-xs"
>
</a>
{(!restaurant.region || restaurant.region.split("|")[0] === "한국") && (
<a
href={`https://map.naver.com/v5/search/${encodeURIComponent(restaurant.name)}`}
target="_blank"
rel="noopener noreferrer"
className="text-green-600 dark:text-green-400 hover:underline text-xs"
>
</a>
)}
</p>
)}
</div>

View File

@@ -9,40 +9,40 @@ interface SearchBarProps {
export default function SearchBar({ onSearch, isLoading }: SearchBarProps) {
const [query, setQuery] = useState("");
const [mode, setMode] = useState<"keyword" | "semantic" | "hybrid">("hybrid");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (query.trim()) {
onSearch(query.trim(), mode);
onSearch(query.trim(), "hybrid");
}
};
return (
<form onSubmit={handleSubmit} className="flex gap-1.5 items-center">
<form onSubmit={handleSubmit} className="relative">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500 pointer-events-none"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="식당, 지역, 음식..."
className="flex-1 min-w-0 px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-400 text-sm bg-white dark:bg-gray-800 dark:text-gray-200 dark:placeholder-gray-500"
placeholder="식당, 지역, 음식 검색..."
className="w-full pl-9 pr-3 py-2 bg-gray-100 dark:bg-gray-800 border border-transparent focus:border-orange-400 focus:bg-white dark:focus:bg-gray-900 rounded-xl text-sm outline-none transition-all dark:text-gray-200 dark:placeholder-gray-500"
/>
<select
value={mode}
onChange={(e) => setMode(e.target.value as typeof mode)}
className="shrink-0 px-2 py-2 border border-gray-300 dark:border-gray-700 rounded-lg text-sm bg-white dark:bg-gray-800 dark:text-gray-300"
>
<option value="hybrid"></option>
<option value="keyword"></option>
<option value="semantic"></option>
</select>
<button
type="submit"
disabled={isLoading || !query.trim()}
className="shrink-0 px-3 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 disabled:opacity-50 text-sm"
>
{isLoading ? "..." : "검색"}
</button>
{isLoading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="w-4 h-4 border-2 border-orange-400 border-t-transparent rounded-full animate-spin" />
</div>
)}
</form>
);
}

View File

@@ -72,6 +72,7 @@ export interface Channel {
title_filter: string | null;
description: string | null;
tags: string | null;
sort_order: number | null;
video_count: number;
last_scanned_at: string | null;
}
@@ -352,7 +353,7 @@ export const api = {
);
},
updateChannel(id: string, data: { description?: string; tags?: string }) {
updateChannel(id: string, data: { description?: string; tags?: string; sort_order?: number }) {
return fetchApi<{ ok: boolean }>(`/api/channels/${id}`, {
method: "PUT",
body: JSON.stringify(data),