From e85e135c8b5ee66edb87d760c66110130bc965e3 Mon Sep 17 00:00:00 2001 From: joungmin Date: Wed, 11 Mar 2026 20:42:25 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B2=80=EC=83=89/=ED=95=84=ED=84=B0=20UI=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0,=20=EC=B1=84=EB=84=90=20=EC=A0=95=EB=A0=AC,?= =?UTF-8?q?=20=EB=93=9C=EB=9E=98=EA=B7=B8=20=EC=8A=A4=ED=81=AC=EB=A1=A4,?= =?UTF-8?q?=20=EC=A7=80=EB=8F=84=EB=A7=81=ED=81=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 검색바: 아이콘 내장, 모드 select 제거 (hybrid 고정), 엔터 검색 - 필터 그룹화: [음식 장르·가격] [지역 나라·시·구] + X 해제 버튼 - 채널 필터: 드롭다운 → 유튜브 아이콘 토글 카드, 드래그 스크롤 - 채널 정렬: sort_order 컬럼 추가, 백오피스 순서 편집 - 채널+필터 동시 적용: API 호출 대신 클라이언트 필터링 - 내위치 ON 시 다른 필터 초기화, 역방향도 동일 - 전체보기 버튼: 모든 필터 일괄 해제 - 네이버맵: 한국 식당만, 식당명만 검색 - 구글맵: 식당명+주소/지역 검색 - 로그인 영역 데스크톱 Row 1 우측 배치 - scrollbar-hide CSS 추가 Co-Authored-By: Claude Opus 4.6 --- .../tasteby/controller/ChannelController.java | 5 +- .../main/java/com/tasteby/domain/Channel.java | 1 + .../com/tasteby/mapper/ChannelMapper.java | 7 +- .../com/tasteby/service/ChannelService.java | 4 +- .../mybatis/mapper/ChannelMapper.xml | 9 +- frontend/src/app/admin/page.tsx | 16 +- frontend/src/app/globals.css | 14 + frontend/src/app/page.tsx | 396 ++++++++++++------ frontend/src/components/RestaurantDetail.tsx | 20 +- frontend/src/components/SearchBar.tsx | 42 +- frontend/src/lib/api.ts | 3 +- 11 files changed, 342 insertions(+), 175 deletions(-) diff --git a/backend-java/src/main/java/com/tasteby/controller/ChannelController.java b/backend-java/src/main/java/com/tasteby/controller/ChannelController.java index f33a1db..933790b 100644 --- a/backend-java/src/main/java/com/tasteby/controller/ChannelController.java +++ b/backend-java/src/main/java/com/tasteby/controller/ChannelController.java @@ -77,9 +77,10 @@ public class ChannelController { } @PutMapping("/{id}") - public Map update(@PathVariable String id, @RequestBody Map body) { + public Map update(@PathVariable String id, @RequestBody Map body) { 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(); return Map.of("ok", true); } diff --git a/backend-java/src/main/java/com/tasteby/domain/Channel.java b/backend-java/src/main/java/com/tasteby/domain/Channel.java index ab68a42..3388627 100644 --- a/backend-java/src/main/java/com/tasteby/domain/Channel.java +++ b/backend-java/src/main/java/com/tasteby/domain/Channel.java @@ -16,6 +16,7 @@ public class Channel { private String titleFilter; private String description; private String tags; + private Integer sortOrder; private int videoCount; private String lastVideoAt; } diff --git a/backend-java/src/main/java/com/tasteby/mapper/ChannelMapper.java b/backend-java/src/main/java/com/tasteby/mapper/ChannelMapper.java index 448f626..abe5d7d 100644 --- a/backend-java/src/main/java/com/tasteby/mapper/ChannelMapper.java +++ b/backend-java/src/main/java/com/tasteby/mapper/ChannelMapper.java @@ -22,7 +22,8 @@ public interface ChannelMapper { Channel findByChannelId(@Param("channelId") String channelId); - void updateDescriptionTags(@Param("id") String id, - @Param("description") String description, - @Param("tags") String tags); + void updateChannel(@Param("id") String id, + @Param("description") String description, + @Param("tags") String tags, + @Param("sortOrder") Integer sortOrder); } diff --git a/backend-java/src/main/java/com/tasteby/service/ChannelService.java b/backend-java/src/main/java/com/tasteby/service/ChannelService.java index a37cc99..3240c41 100644 --- a/backend-java/src/main/java/com/tasteby/service/ChannelService.java +++ b/backend-java/src/main/java/com/tasteby/service/ChannelService.java @@ -39,7 +39,7 @@ public class ChannelService { return mapper.findByChannelId(channelId); } - public void update(String id, String description, String tags) { - mapper.updateDescriptionTags(id, description, tags); + public void update(String id, String description, String tags, Integer sortOrder) { + mapper.updateChannel(id, description, tags, sortOrder); } } diff --git a/backend-java/src/main/resources/mybatis/mapper/ChannelMapper.xml b/backend-java/src/main/resources/mybatis/mapper/ChannelMapper.xml index 4752508..6c669ca 100644 --- a/backend-java/src/main/resources/mybatis/mapper/ChannelMapper.xml +++ b/backend-java/src/main/resources/mybatis/mapper/ChannelMapper.xml @@ -9,17 +9,18 @@ + @@ -37,8 +38,8 @@ WHERE id = #{id} AND is_active = 1 - - UPDATE channels SET description = #{description}, tags = #{tags} + + UPDATE channels SET description = #{description}, tags = #{tags}, sort_order = #{sortOrder} WHERE id = #{id} diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index f39f2f8..a41be29 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -134,10 +134,11 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) { const [editingChannel, setEditingChannel] = useState(null); const [editDesc, setEditDesc] = useState(""); const [editTags, setEditTags] = useState(""); + const [editOrder, setEditOrder] = useState(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 }) { 제목 필터 설명 태그 + 순서 영상 수 {isAdmin && 액션} 스캔 결과 @@ -238,7 +240,7 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) { ) : ( { 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 || -} )} @@ -253,10 +255,18 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) { ) : ( { 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(", ") : -} )} + + {editingChannel === ch.id ? ( + 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} /> + ) : ( + {ch.sort_order ?? 99} + )} + {ch.video_count > 0 ? ( {ch.video_count}개 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 8f02384..7a860ec 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index bc73b71..3a969eb 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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(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 - {/* Desktop: search + filters — two rows */} -
- {/* Row 1: Search + dropdown filters */} -
-
+ {/* Desktop: search + filters */} +
+ {/* Row 1: Search + actions */} +
+
- - -
- {/* Row 2: Region filters + Toggle buttons + count */} -
- - {countryFilter && cities.length > 0 && ( - - )} - {cityFilter && districts.length > 0 && ( - - )} -
- + + {filteredRestaurants.length}개 + +
{user && ( <> )} - - {filteredRestaurants.length}개 - +
+ {/* Desktop user area */} + {authLoading ? null : user ? ( +
+ {user.avatar_url ? ( + + ) : ( +
+ {(user.nickname || user.email || "?").charAt(0).toUpperCase()} +
+ )} + + {user.nickname || user.email} + + +
+ ) : ( +
+ login(credential).catch(console.error)} /> +
+ )}
- {/* Row 3: Channel cards (toggle filter) - max 4 visible, scroll for rest */} + {/* Row 2: Channel cards (toggle filter) */} {!isSearchResult && channels.length > 0 && ( -
+
{channels.map((ch) => (
)} + {/* Row 3: Filters — grouped by category */} +
+ {/* 음식 필터 그룹 */} +
+ 음식 + +
+ + {(cuisineFilter || priceFilter) && ( + + )} +
+ {/* 지역 필터 그룹 */} +
+ 지역 + + {countryFilter && cities.length > 0 && ( + <> +
+ + + )} + {cityFilter && districts.length > 0 && ( + <> +
+ + + )} + {countryFilter && ( + + )} +
+ {/* 필터 전체 해제 */} + {(channelFilter || cuisineFilter || priceFilter || countryFilter) && ( + + )} + {/* 내위치 토글 */} + +
- {/* User area */} -
+ {/* User area (mobile only - desktop moved to Row 1) */} +
{authLoading ? null : user ? (
{user.avatar_url ? ( @@ -737,15 +876,6 @@ export default function Home() { {(user.nickname || user.email || "?").charAt(0).toUpperCase()}
)} - - {user.nickname || user.email} - -
) : ( login(credential).catch(console.error)} /> @@ -759,7 +889,7 @@ export default function Home() { {/* Channel cards - toggle filter */} {mobileTab === "home" && !isSearchResult && channels.length > 0 && ( -
+
{channels.map((ch) => ( + {isLoading && ( +
+
+
+ )} ); } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5730d8b..fa30fad 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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),