검색/필터 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

@@ -77,9 +77,10 @@ public class ChannelController {
} }
@PutMapping("/{id}") @PutMapping("/{id}")
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, String> body) { public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
AuthUtil.requireAdmin(); AuthUtil.requireAdmin();
channelService.update(id, body.get("description"), body.get("tags")); Integer sortOrder = body.get("sort_order") != null ? ((Number) body.get("sort_order")).intValue() : null;
channelService.update(id, (String) body.get("description"), (String) body.get("tags"), sortOrder);
cache.flush(); cache.flush();
return Map.of("ok", true); return Map.of("ok", true);
} }

View File

@@ -16,6 +16,7 @@ public class Channel {
private String titleFilter; private String titleFilter;
private String description; private String description;
private String tags; private String tags;
private Integer sortOrder;
private int videoCount; private int videoCount;
private String lastVideoAt; private String lastVideoAt;
} }

View File

@@ -22,7 +22,8 @@ public interface ChannelMapper {
Channel findByChannelId(@Param("channelId") String channelId); Channel findByChannelId(@Param("channelId") String channelId);
void updateDescriptionTags(@Param("id") String id, void updateChannel(@Param("id") String id,
@Param("description") String description, @Param("description") String description,
@Param("tags") String tags); @Param("tags") String tags,
@Param("sortOrder") Integer sortOrder);
} }

View File

@@ -39,7 +39,7 @@ public class ChannelService {
return mapper.findByChannelId(channelId); return mapper.findByChannelId(channelId);
} }
public void update(String id, String description, String tags) { public void update(String id, String description, String tags, Integer sortOrder) {
mapper.updateDescriptionTags(id, description, tags); mapper.updateChannel(id, description, tags, sortOrder);
} }
} }

View File

@@ -9,17 +9,18 @@
<result property="titleFilter" column="title_filter"/> <result property="titleFilter" column="title_filter"/>
<result property="description" column="description"/> <result property="description" column="description"/>
<result property="tags" column="tags"/> <result property="tags" column="tags"/>
<result property="sortOrder" column="sort_order"/>
<result property="videoCount" column="video_count"/> <result property="videoCount" column="video_count"/>
<result property="lastVideoAt" column="last_video_at"/> <result property="lastVideoAt" column="last_video_at"/>
</resultMap> </resultMap>
<select id="findAllActive" resultMap="channelResultMap"> <select id="findAllActive" resultMap="channelResultMap">
SELECT c.id, c.channel_id, c.channel_name, c.title_filter, c.description, c.tags, SELECT c.id, c.channel_id, c.channel_name, c.title_filter, c.description, c.tags, c.sort_order,
(SELECT COUNT(*) FROM videos v WHERE v.channel_id = c.id) AS video_count, (SELECT COUNT(*) FROM videos v WHERE v.channel_id = c.id) AS video_count,
(SELECT MAX(v.published_at) FROM videos v WHERE v.channel_id = c.id) AS last_video_at (SELECT MAX(v.published_at) FROM videos v WHERE v.channel_id = c.id) AS last_video_at
FROM channels c FROM channels c
WHERE c.is_active = 1 WHERE c.is_active = 1
ORDER BY c.channel_name ORDER BY c.sort_order, c.channel_name
</select> </select>
<insert id="insert"> <insert id="insert">
@@ -37,8 +38,8 @@
WHERE id = #{id} AND is_active = 1 WHERE id = #{id} AND is_active = 1
</update> </update>
<update id="updateDescriptionTags"> <update id="updateChannel">
UPDATE channels SET description = #{description}, tags = #{tags} UPDATE channels SET description = #{description}, tags = #{tags}, sort_order = #{sortOrder}
WHERE id = #{id} WHERE id = #{id}
</update> </update>

View File

@@ -134,10 +134,11 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
const [editingChannel, setEditingChannel] = useState<string | null>(null); const [editingChannel, setEditingChannel] = useState<string | null>(null);
const [editDesc, setEditDesc] = useState(""); const [editDesc, setEditDesc] = useState("");
const [editTags, setEditTags] = useState(""); const [editTags, setEditTags] = useState("");
const [editOrder, setEditOrder] = useState<number>(99);
const handleSaveChannel = async (id: string) => { const handleSaveChannel = async (id: string) => {
try { try {
await api.updateChannel(id, { description: editDesc, tags: editTags }); await api.updateChannel(id, { description: editDesc, tags: editTags, sort_order: editOrder });
setEditingChannel(null); setEditingChannel(null);
load(); load();
} catch { } 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-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> <th className="text-right px-4 py-3"> </th>
{isAdmin && <th className="text-left px-4 py-3"></th>} {isAdmin && <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>
@@ -238,7 +240,7 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
) : ( ) : (
<span className="text-gray-600 cursor-pointer" onClick={() => { <span className="text-gray-600 cursor-pointer" onClick={() => {
if (!isAdmin) return; 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> }}>{ch.description || <span className="text-gray-400">-</span>}</span>
)} )}
</td> </td>
@@ -253,10 +255,18 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
) : ( ) : (
<span className="text-gray-500 cursor-pointer" onClick={() => { <span className="text-gray-500 cursor-pointer" onClick={() => {
if (!isAdmin) return; 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> }}>{ch.tags ? ch.tags.split(",").map(t => t.trim()).join(", ") : <span className="text-gray-400">-</span>}</span>
)} )}
</td> </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"> <td className="px-4 py-3 text-right font-medium">
{ch.video_count > 0 ? ( {ch.video_count > 0 ? (
<span className="px-2 py-0.5 bg-green-50 text-green-700 rounded text-xs">{ch.video_count}</span> <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; 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 for iOS bottom nav */
.safe-area-bottom { .safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom, 0px); 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 BottomSheet from "@/components/BottomSheet";
import { getCuisineIcon } from "@/lib/cuisine-icons"; 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[] }[] = [ const CUISINE_TAXONOMY: { category: string; items: string[] }[] = [
{ category: "한식", items: ["백반/한정식", "국밥/해장국", "찌개/전골/탕", "삼겹살/돼지구이", "소고기/한우구이", "곱창/막창", "닭/오리구이", "족발/보쌈", "회/횟집", "해산물", "분식", "면", "죽/죽집", "순대/순대국", "장어/민물", "주점/포차", "파인다이닝/코스"] }, { category: "한식", items: ["백반/한정식", "국밥/해장국", "찌개/전골/탕", "삼겹살/돼지구이", "소고기/한우구이", "곱창/막창", "닭/오리구이", "족발/보쌈", "회/횟집", "해산물", "분식", "면", "죽/죽집", "순대/순대국", "장어/민물", "주점/포차", "파인다이닝/코스"] },
{ category: "일식", items: ["스시/오마카세", "라멘", "돈카츠", "텐동/튀김", "이자카야", "야키니쿠", "카레", "소바/우동", "파인다이닝/코스"] }, { category: "일식", items: ["스시/오마카세", "라멘", "돈카츠", "텐동/튀김", "이자카야", "야키니쿠", "카레", "소바/우동", "파인다이닝/코스"] },
@@ -157,6 +195,8 @@ export default function Home() {
const [isSearchResult, setIsSearchResult] = useState(false); const [isSearchResult, setIsSearchResult] = useState(false);
const [resetCount, setResetCount] = useState(0); const [resetCount, setResetCount] = useState(0);
const geoApplied = useRef(false); const geoApplied = useRef(false);
const dd = useDragScroll();
const dm = useDragScroll();
const regionTree = useMemo(() => buildRegionTree(restaurants), [restaurants]); const regionTree = useMemo(() => buildRegionTree(restaurants), [restaurants]);
const countries = useMemo(() => [...regionTree.keys()].sort(), [regionTree]); 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); 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(() => { useEffect(() => {
setLoading(true); setLoading(true);
setIsSearchResult(false); setIsSearchResult(false);
api api
.getRestaurants({ limit: 500, channel: channelFilter || undefined }) .getRestaurants({ limit: 500 })
.then(setRestaurants) .then(setRestaurants)
.catch(console.error) .catch(console.error)
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [channelFilter]); }, []);
// Auto-select region from user's geolocation (once) // Auto-select region from user's geolocation (once)
useEffect(() => { useEffect(() => {
@@ -316,6 +356,7 @@ export default function Home() {
setCountryFilter(country); setCountryFilter(country);
setCityFilter(""); setCityFilter("");
setDistrictFilter(""); setDistrictFilter("");
setBoundsFilterOn(false);
if (!country) { setRegionFlyTo(null); return; } if (!country) { setRegionFlyTo(null); return; }
const matched = restaurants.filter((r) => { const matched = restaurants.filter((r) => {
const p = parseRegion(r.region); const p = parseRegion(r.region);
@@ -413,7 +454,7 @@ export default function Home() {
setShowFavorites(false); setShowFavorites(false);
setShowMyReviews(false); setShowMyReviews(false);
setMyReviews([]); setMyReviews([]);
api.getRestaurants({ limit: 500, channel: channelFilter || undefined }).then(setRestaurants); api.getRestaurants({ limit: 500 }).then(setRestaurants);
} }
return; return;
} }
@@ -439,7 +480,7 @@ export default function Home() {
} catch { /* ignore */ } } catch { /* ignore */ }
// 프로필에서는 식당 목록을 원래대로 복원 // 프로필에서는 식당 목록을 원래대로 복원
if (showFavorites) { if (showFavorites) {
api.getRestaurants({ limit: 500, channel: channelFilter || undefined }).then(setRestaurants); api.getRestaurants({ limit: 500 }).then(setRestaurants);
} }
} else { } else {
// 홈 / 식당 목록 - 항상 전체 식당 목록으로 복원 // 홈 / 식당 목록 - 항상 전체 식당 목록으로 복원
@@ -448,16 +489,16 @@ export default function Home() {
setShowMyReviews(false); setShowMyReviews(false);
setMyReviews([]); setMyReviews([]);
if (needReload) { if (needReload) {
const data = await api.getRestaurants({ limit: 500, channel: channelFilter || undefined }); const data = await api.getRestaurants({ limit: 500 });
setRestaurants(data); setRestaurants(data);
} }
} }
}, [user, showFavorites, showMyReviews, channelFilter, mobileTab, handleReset]); }, [user, showFavorites, showMyReviews, mobileTab, handleReset]);
const handleToggleFavorites = async () => { const handleToggleFavorites = async () => {
if (showFavorites) { if (showFavorites) {
setShowFavorites(false); setShowFavorites(false);
const data = await api.getRestaurants({ limit: 500, channel: channelFilter || undefined }); const data = await api.getRestaurants({ limit: 500 });
setRestaurants(data); setRestaurants(data);
} else { } else {
try { try {
@@ -550,11 +591,11 @@ export default function Home() {
Tasteby Tasteby
</button> </button>
{/* Desktop: search + filters — two rows */} {/* Desktop: search + filters */}
<div className="hidden md:flex flex-col gap-2.5 mx-6"> <div className="hidden md:flex flex-col gap-2 mx-6 flex-1 min-w-0">
{/* Row 1: Search + dropdown filters */} {/* Row 1: Search + actions */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<div className="w-96 shrink-0"> <div className="w-80 shrink-0">
<SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} /> <SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} />
</div> </div>
<button <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" className="p-1.5 text-gray-400 dark:text-gray-500 hover:text-orange-500 dark:hover:text-orange-400 transition-colors"
title="초기화" 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> <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>
<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" : "📍 내위치"}
</button> </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 <button
onClick={() => setViewMode(viewMode === "map" ? "list" : "map")} 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" className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
title={viewMode === "map" ? "리스트 우선" : "지도 우선"} 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> </button>
{user && ( {user && (
<> <>
<button <button
onClick={handleToggleFavorites} 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 showFavorites
? "bg-rose-50 dark:bg-rose-900/30 border-rose-300 dark:border-rose-700 text-rose-600 dark:text-rose-400" ? "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 ? "♥ 내 찜" : "♡ 찜"} {showFavorites ? "♥ 내 찜" : "♡ 찜"}
</button> </button>
<button <button
onClick={handleToggleMyReviews} 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 showMyReviews
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400" ? "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 ? "✎ 내 리뷰" : "✎ 리뷰"} {showMyReviews ? "✎ 내 리뷰" : "✎ 리뷰"}
</button> </button>
</> </>
)} )}
<span className="text-sm text-gray-500 whitespace-nowrap"> <div className="flex-1" />
{filteredRestaurants.length} {/* Desktop user area */}
</span> {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> </div>
{/* Row 3: Channel cards (toggle filter) - max 4 visible, scroll for rest */} {/* Row 2: Channel cards (toggle filter) */}
{!isSearchResult && channels.length > 0 && ( {!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"> <div className="flex gap-2">
{channels.map((ch) => ( {channels.map((ch) => (
<button <button
@@ -720,10 +701,168 @@ export default function Home() {
</div> </div>
</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> </div>
{/* User area */} {/* User area (mobile only - desktop moved to Row 1) */}
<div className="shrink-0 flex items-center gap-3 ml-auto"> <div className="shrink-0 flex items-center gap-3 ml-auto md:hidden">
{authLoading ? null : user ? ( {authLoading ? null : user ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{user.avatar_url ? ( {user.avatar_url ? (
@@ -737,15 +876,6 @@ export default function Home() {
{(user.nickname || user.email || "?").charAt(0).toUpperCase()} {(user.nickname || user.email || "?").charAt(0).toUpperCase()}
</div> </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> </div>
) : ( ) : (
<LoginMenu onGoogleSuccess={(credential) => login(credential).catch(console.error)} /> <LoginMenu onGoogleSuccess={(credential) => login(credential).catch(console.error)} />
@@ -759,7 +889,7 @@ export default function Home() {
<SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} /> <SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} />
{/* Channel cards - toggle filter */} {/* Channel cards - toggle filter */}
{mobileTab === "home" && !isSearchResult && channels.length > 0 && ( {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) => ( {channels.map((ch) => (
<button <button
key={ch.id} key={ch.id}
@@ -823,7 +953,7 @@ export default function Home() {
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<select <select
value={cuisineFilter} 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" 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> <option value="">🍽 </option>
@@ -840,7 +970,7 @@ export default function Home() {
</select> </select>
<select <select
value={priceFilter} 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" 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> <option value="">💰 </option>
@@ -893,6 +1023,12 @@ export default function Home() {
const next = !boundsFilterOn; const next = !boundsFilterOn;
setBoundsFilterOn(next); setBoundsFilterOn(next);
if (next) { if (next) {
// 내위치 ON 시 다른 필터 초기화
setCuisineFilter("");
setPriceFilter("");
setCountryFilter("");
setCityFilter("");
setDistrictFilter("");
if (navigator.geolocation) { if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }), (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 && ( {restaurant.google_place_id && (
<p className="flex gap-3"> <p className="flex gap-3">
<a <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" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-orange-600 dark:text-orange-400 hover:underline text-xs" className="text-orange-600 dark:text-orange-400 hover:underline text-xs"
> >
Google Maps에서 Google Maps에서
</a> </a>
<a {(!restaurant.region || restaurant.region.split("|")[0] === "한국") && (
href={`https://map.naver.com/v5/search/${encodeURIComponent(restaurant.name)}`} <a
target="_blank" href={`https://map.naver.com/v5/search/${encodeURIComponent(restaurant.name)}`}
rel="noopener noreferrer" target="_blank"
className="text-green-600 dark:text-green-400 hover:underline text-xs" rel="noopener noreferrer"
> className="text-green-600 dark:text-green-400 hover:underline text-xs"
>
</a>
</a>
)}
</p> </p>
)} )}
</div> </div>

View File

@@ -9,40 +9,40 @@ interface SearchBarProps {
export default function SearchBar({ onSearch, isLoading }: SearchBarProps) { export default function SearchBar({ onSearch, isLoading }: SearchBarProps) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [mode, setMode] = useState<"keyword" | "semantic" | "hybrid">("hybrid");
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (query.trim()) { if (query.trim()) {
onSearch(query.trim(), mode); onSearch(query.trim(), "hybrid");
} }
}; };
return ( 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 <input
type="text" type="text"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="식당, 지역, 음식..." 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" 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 {isLoading && (
value={mode} <div className="absolute right-3 top-1/2 -translate-y-1/2">
onChange={(e) => setMode(e.target.value as typeof mode)} <div className="w-4 h-4 border-2 border-orange-400 border-t-transparent rounded-full animate-spin" />
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" </div>
> )}
<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>
</form> </form>
); );
} }

View File

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