UI/UX 개선: 모바일 네비게이션, 로그인 모달, 지도 기능, 캐치테이블 연동
- 모바일 하단 네비게이션(홈/식당목록/내주변/찜/내정보) 추가 - 로그인 버튼을 모달 방식으로 변경 (소셜 로그인 확장 가능) - 내위치 기반 정렬 및 영역 필터, 지도 내위치 버튼 추가 - 채널 필터 시 해당 채널만 마커/범례 표시 - 캐치테이블 검색/연동 (단건/벌크), NONE 저장 패턴 - 벌크 트랜스크립트 SSE (Playwright 브라우저 재사용) - 테이블링/캐치테이블 버튼 UI 차별화 - Google Maps 링크 모바일 호환, 초기화 버튼, 검색 라벨 개선 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1495,6 +1495,12 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
const [editForm, setEditForm] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [videos, setVideos] = useState<VideoLink[]>([]);
|
||||
const [tablingSearching, setTablingSearching] = useState(false);
|
||||
const [bulkTabling, setBulkTabling] = useState(false);
|
||||
const [bulkTablingProgress, setBulkTablingProgress] = useState({ current: 0, total: 0, name: "", linked: 0, notFound: 0 });
|
||||
const [catchtableSearching, setCatchtableSearching] = useState(false);
|
||||
const [bulkCatchtable, setBulkCatchtable] = useState(false);
|
||||
const [bulkCatchtableProgress, setBulkCatchtableProgress] = useState({ current: 0, total: 0, name: "", linked: 0, notFound: 0 });
|
||||
type RestSortKey = "name" | "region" | "cuisine_type" | "price_range" | "rating" | "business_status";
|
||||
const [sortKey, setSortKey] = useState<RestSortKey>("name");
|
||||
const [sortAsc, setSortAsc] = useState(true);
|
||||
@@ -1617,10 +1623,126 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (<>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const pending = await fetch(`/api/restaurants/tabling-pending`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("tasteby_token")}` },
|
||||
}).then(r => r.json());
|
||||
if (pending.count === 0) { alert("테이블링 미연결 식당이 없습니다"); return; }
|
||||
if (!confirm(`테이블링 미연결 식당 ${pending.count}개를 벌크 검색합니다.\n식당당 5~15초 소요됩니다. 진행할까요?`)) return;
|
||||
setBulkTabling(true);
|
||||
setBulkTablingProgress({ current: 0, total: pending.count, name: "", linked: 0, notFound: 0 });
|
||||
try {
|
||||
const res = await fetch("/api/restaurants/bulk-tabling", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("tasteby_token")}` },
|
||||
});
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^data:(.+)$/);
|
||||
if (!m) continue;
|
||||
const evt = JSON.parse(m[1]);
|
||||
if (evt.type === "processing" || evt.type === "done" || evt.type === "notfound" || evt.type === "error") {
|
||||
setBulkTablingProgress(p => ({
|
||||
...p, current: evt.current, total: evt.total || p.total, name: evt.name,
|
||||
linked: evt.type === "done" ? p.linked + 1 : p.linked,
|
||||
notFound: (evt.type === "notfound" || evt.type === "error") ? p.notFound + 1 : p.notFound,
|
||||
}));
|
||||
} else if (evt.type === "complete") {
|
||||
alert(`완료! 연결: ${evt.linked}개, 미발견: ${evt.notFound}개`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { alert("벌크 테이블링 실패: " + (e instanceof Error ? e.message : String(e))); }
|
||||
finally { setBulkTabling(false); load(); }
|
||||
}}
|
||||
disabled={bulkTabling}
|
||||
className="px-3 py-1.5 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50"
|
||||
>
|
||||
{bulkTabling ? `테이블링 검색 중 (${bulkTablingProgress.current}/${bulkTablingProgress.total})` : "벌크 테이블링 연결"}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const pending = await fetch(`/api/restaurants/catchtable-pending`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("tasteby_token")}` },
|
||||
}).then(r => r.json());
|
||||
if (pending.count === 0) { alert("캐치테이블 미연결 식당이 없습니다"); return; }
|
||||
if (!confirm(`캐치테이블 미연결 식당 ${pending.count}개를 벌크 검색합니다.\n식당당 5~15초 소요됩니다. 진행할까요?`)) return;
|
||||
setBulkCatchtable(true);
|
||||
setBulkCatchtableProgress({ current: 0, total: pending.count, name: "", linked: 0, notFound: 0 });
|
||||
try {
|
||||
const res = await fetch("/api/restaurants/bulk-catchtable", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("tasteby_token")}` },
|
||||
});
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^data:(.+)$/);
|
||||
if (!m) continue;
|
||||
const evt = JSON.parse(m[1]);
|
||||
if (evt.type === "processing" || evt.type === "done" || evt.type === "notfound" || evt.type === "error") {
|
||||
setBulkCatchtableProgress(p => ({
|
||||
...p, current: evt.current, total: evt.total || p.total, name: evt.name,
|
||||
linked: evt.type === "done" ? p.linked + 1 : p.linked,
|
||||
notFound: (evt.type === "notfound" || evt.type === "error") ? p.notFound + 1 : p.notFound,
|
||||
}));
|
||||
} else if (evt.type === "complete") {
|
||||
alert(`완료! 연결: ${evt.linked}개, 미발견: ${evt.notFound}개`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { alert("벌크 캐치테이블 실패: " + (e instanceof Error ? e.message : String(e))); }
|
||||
finally { setBulkCatchtable(false); load(); }
|
||||
}}
|
||||
disabled={bulkCatchtable}
|
||||
className="px-3 py-1.5 text-xs bg-violet-500 text-white rounded hover:bg-violet-600 disabled:opacity-50"
|
||||
>
|
||||
{bulkCatchtable ? `캐치테이블 검색 중 (${bulkCatchtableProgress.current}/${bulkCatchtableProgress.total})` : "벌크 캐치테이블 연결"}
|
||||
</button>
|
||||
</>)}
|
||||
<span className="text-sm text-gray-400 ml-auto">
|
||||
{nameSearch ? `${sorted.length} / ` : ""}총 {restaurants.length}개 식당
|
||||
</span>
|
||||
</div>
|
||||
{bulkTabling && bulkTablingProgress.name && (
|
||||
<div className="bg-orange-50 rounded p-3 mb-4 text-sm">
|
||||
<div className="flex justify-between mb-1">
|
||||
<span>{bulkTablingProgress.current}/{bulkTablingProgress.total} - {bulkTablingProgress.name}</span>
|
||||
<span className="text-xs text-gray-500">연결: {bulkTablingProgress.linked} / 미발견: {bulkTablingProgress.notFound}</span>
|
||||
</div>
|
||||
<div className="w-full bg-orange-200 rounded-full h-1.5">
|
||||
<div className="bg-orange-500 h-1.5 rounded-full transition-all" style={{ width: `${(bulkTablingProgress.current / bulkTablingProgress.total) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{bulkCatchtable && bulkCatchtableProgress.name && (
|
||||
<div className="bg-violet-50 rounded p-3 mb-4 text-sm">
|
||||
<div className="flex justify-between mb-1">
|
||||
<span>{bulkCatchtableProgress.current}/{bulkCatchtableProgress.total} - {bulkCatchtableProgress.name}</span>
|
||||
<span className="text-xs text-gray-500">연결: {bulkCatchtableProgress.linked} / 미발견: {bulkCatchtableProgress.notFound}</span>
|
||||
</div>
|
||||
<div className="w-full bg-violet-200 rounded-full h-1.5">
|
||||
<div className="bg-violet-500 h-1.5 rounded-full transition-all" style={{ width: `${(bulkCatchtableProgress.current / bulkCatchtableProgress.total) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
@@ -1730,6 +1852,109 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{/* 테이블링 연결 */}
|
||||
{isAdmin && (
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="text-xs font-semibold text-gray-500">테이블링</h4>
|
||||
{selected.tabling_url === "NONE" ? (
|
||||
<span className="text-xs text-gray-400">검색완료-없음</span>
|
||||
) : selected.tabling_url ? (
|
||||
<a href={selected.tabling_url} target="_blank" rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-xs">{selected.tabling_url}</a>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">미연결</span>
|
||||
)}
|
||||
<button
|
||||
onClick={async () => {
|
||||
setTablingSearching(true);
|
||||
try {
|
||||
const results = await api.searchTabling(selected.id);
|
||||
if (results.length === 0) {
|
||||
alert("테이블링에서 검색 결과가 없습니다");
|
||||
} else {
|
||||
const best = results[0];
|
||||
if (confirm(`"${best.title}"\n${best.url}\n\n이 테이블링 페이지를 연결할까요?`)) {
|
||||
await api.setTablingUrl(selected.id, best.url);
|
||||
setSelected({ ...selected, tabling_url: best.url });
|
||||
load();
|
||||
}
|
||||
}
|
||||
} catch (e) { alert("검색 실패: " + (e instanceof Error ? e.message : String(e))); }
|
||||
finally { setTablingSearching(false); }
|
||||
}}
|
||||
disabled={tablingSearching}
|
||||
className="px-2 py-0.5 text-[11px] bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50"
|
||||
>
|
||||
{tablingSearching ? "검색 중..." : "테이블링 검색"}
|
||||
</button>
|
||||
{selected.tabling_url && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
await api.setTablingUrl(selected.id, "");
|
||||
setSelected({ ...selected, tabling_url: null });
|
||||
load();
|
||||
}}
|
||||
className="px-2 py-0.5 text-[11px] text-red-500 border border-red-200 rounded hover:bg-red-50"
|
||||
>
|
||||
연결 해제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 캐치테이블 연결 */}
|
||||
{isAdmin && (
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="text-xs font-semibold text-gray-500">캐치테이블</h4>
|
||||
{selected.catchtable_url === "NONE" ? (
|
||||
<span className="text-xs text-gray-400">검색완료-없음</span>
|
||||
) : selected.catchtable_url ? (
|
||||
<a href={selected.catchtable_url} target="_blank" rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline text-xs">{selected.catchtable_url}</a>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">미연결</span>
|
||||
)}
|
||||
<button
|
||||
onClick={async () => {
|
||||
setCatchtableSearching(true);
|
||||
try {
|
||||
const results = await api.searchCatchtable(selected.id);
|
||||
if (results.length === 0) {
|
||||
alert("캐치테이블에서 검색 결과가 없습니다");
|
||||
} else {
|
||||
const best = results[0];
|
||||
if (confirm(`"${best.title}"\n${best.url}\n\n이 캐치테이블 페이지를 연결할까요?`)) {
|
||||
await api.setCatchtableUrl(selected.id, best.url);
|
||||
setSelected({ ...selected, catchtable_url: best.url });
|
||||
load();
|
||||
}
|
||||
}
|
||||
} catch (e) { alert("검색 실패: " + (e instanceof Error ? e.message : String(e))); }
|
||||
finally { setCatchtableSearching(false); }
|
||||
}}
|
||||
disabled={catchtableSearching}
|
||||
className="px-2 py-0.5 text-[11px] bg-violet-500 text-white rounded hover:bg-violet-600 disabled:opacity-50"
|
||||
>
|
||||
{catchtableSearching ? "검색 중..." : "캐치테이블 검색"}
|
||||
</button>
|
||||
{selected.catchtable_url && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
await api.setCatchtableUrl(selected.id, "");
|
||||
setSelected({ ...selected, catchtable_url: null });
|
||||
load();
|
||||
}}
|
||||
className="px-2 py-0.5 text-[11px] text-red-500 border border-red-200 rounded hover:bg-red-50"
|
||||
>
|
||||
연결 해제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{videos.length > 0 && (
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<h4 className="text-xs font-semibold text-gray-500 mb-2">연결된 영상 ({videos.length})</h4>
|
||||
|
||||
@@ -42,3 +42,8 @@ html, body, #__next {
|
||||
.gm-style .gm-style-iw-d {
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
/* Safe area for iOS bottom nav */
|
||||
.safe-area-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { GoogleLogin } from "@react-oauth/google";
|
||||
import LoginMenu from "@/components/LoginMenu";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Restaurant, Channel, Review } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
@@ -140,6 +141,7 @@ export default function Home() {
|
||||
const [cuisineFilter, setCuisineFilter] = useState("");
|
||||
const [priceFilter, setPriceFilter] = useState("");
|
||||
const [viewMode, setViewMode] = useState<"map" | "list">("list");
|
||||
const [mobileTab, setMobileTab] = useState<"home" | "list" | "nearby" | "favorites" | "profile">("home");
|
||||
const [showMobileFilters, setShowMobileFilters] = useState(false);
|
||||
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
|
||||
const [boundsFilterOn, setBoundsFilterOn] = useState(false);
|
||||
@@ -151,6 +153,7 @@ export default function Home() {
|
||||
const [showMyReviews, setShowMyReviews] = useState(false);
|
||||
const [myReviews, setMyReviews] = useState<(Review & { restaurant_id: string; restaurant_name: string | null })[]>([]);
|
||||
const [visits, setVisits] = useState<{ today: number; total: number } | null>(null);
|
||||
const [userLoc, setUserLoc] = useState<{ lat: number; lng: number }>({ lat: 37.498, lng: 127.0276 });
|
||||
const geoApplied = useRef(false);
|
||||
|
||||
const regionTree = useMemo(() => buildRegionTree(restaurants), [restaurants]);
|
||||
@@ -169,6 +172,8 @@ export default function Home() {
|
||||
}, [regionTree, countryFilter, cityFilter]);
|
||||
|
||||
const filteredRestaurants = useMemo(() => {
|
||||
const dist = (r: Restaurant) =>
|
||||
(r.latitude - userLoc.lat) ** 2 + (r.longitude - userLoc.lng) ** 2;
|
||||
return restaurants.filter((r) => {
|
||||
if (channelFilter && !(r.channels || []).includes(channelFilter)) return false;
|
||||
if (cuisineFilter && !matchCuisineFilter(r.cuisine_type, cuisineFilter)) return false;
|
||||
@@ -184,12 +189,23 @@ export default function Home() {
|
||||
if (r.longitude < mapBounds.west || r.longitude > mapBounds.east) return false;
|
||||
}
|
||||
return true;
|
||||
}).sort((a, b) => {
|
||||
const da = dist(a), db = dist(b);
|
||||
if (da !== db) return da - db;
|
||||
return (b.rating || 0) - (a.rating || 0);
|
||||
});
|
||||
}, [restaurants, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds]);
|
||||
}, [restaurants, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]);
|
||||
|
||||
// Set desktop default to map mode on mount
|
||||
// Set desktop default to map mode on mount + get user location
|
||||
useEffect(() => {
|
||||
if (window.innerWidth >= 768) setViewMode("map");
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
|
||||
() => {},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load channels + record visit on mount
|
||||
@@ -220,12 +236,9 @@ export default function Home() {
|
||||
if (match) {
|
||||
setCountryFilter(match.country);
|
||||
setCityFilter(match.city);
|
||||
const matched = restaurants.filter((r) => {
|
||||
const p = parseRegion(r.region);
|
||||
return p && p.country === match.country && p.city === match.city;
|
||||
});
|
||||
setRegionFlyTo(computeFlyTo(matched));
|
||||
}
|
||||
const mobile = window.innerWidth < 768;
|
||||
setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: mobile ? 13 : 16 });
|
||||
},
|
||||
() => { /* user denied or error — do nothing */ },
|
||||
{ timeout: 5000 },
|
||||
@@ -262,6 +275,21 @@ export default function Home() {
|
||||
setMapBounds(bounds);
|
||||
}, []);
|
||||
|
||||
const handleMyLocation = useCallback(() => {
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude });
|
||||
setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 16 });
|
||||
},
|
||||
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 16 }),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
} else {
|
||||
setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 16 });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCountryChange = useCallback((country: string) => {
|
||||
setCountryFilter(country);
|
||||
setCityFilter("");
|
||||
@@ -333,6 +361,75 @@ export default function Home() {
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleMobileTab = useCallback(async (tab: "home" | "list" | "nearby" | "favorites" | "profile") => {
|
||||
// 홈 탭 재클릭 = 리셋
|
||||
if (tab === "home" && mobileTab === "home") {
|
||||
handleReset();
|
||||
return;
|
||||
}
|
||||
|
||||
setMobileTab(tab);
|
||||
setShowDetail(false);
|
||||
setShowMobileFilters(false);
|
||||
setSelected(null);
|
||||
|
||||
if (tab === "nearby") {
|
||||
setBoundsFilterOn(true);
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 13 }),
|
||||
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 13 }),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
} else {
|
||||
setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 13 });
|
||||
}
|
||||
// 내주변에서 돌아올 때를 위해 favorites/reviews 해제
|
||||
if (showFavorites || showMyReviews) {
|
||||
setShowFavorites(false);
|
||||
setShowMyReviews(false);
|
||||
setMyReviews([]);
|
||||
api.getRestaurants({ limit: 500, channel: channelFilter || undefined }).then(setRestaurants);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setBoundsFilterOn(false);
|
||||
|
||||
if (tab === "favorites") {
|
||||
if (!user) return;
|
||||
setShowMyReviews(false);
|
||||
setMyReviews([]);
|
||||
try {
|
||||
const favs = await api.getMyFavorites();
|
||||
setRestaurants(favs);
|
||||
setShowFavorites(true);
|
||||
} catch { /* ignore */ }
|
||||
} else if (tab === "profile") {
|
||||
if (!user) return;
|
||||
setShowFavorites(false);
|
||||
try {
|
||||
const reviews = await api.getMyReviews();
|
||||
setMyReviews(reviews);
|
||||
setShowMyReviews(true);
|
||||
} catch { /* ignore */ }
|
||||
// 프로필에서는 식당 목록을 원래대로 복원
|
||||
if (showFavorites) {
|
||||
api.getRestaurants({ limit: 500, channel: channelFilter || undefined }).then(setRestaurants);
|
||||
}
|
||||
} else {
|
||||
// 홈 / 식당 목록 - 항상 전체 식당 목록으로 복원
|
||||
const needReload = showFavorites || showMyReviews;
|
||||
setShowFavorites(false);
|
||||
setShowMyReviews(false);
|
||||
setMyReviews([]);
|
||||
if (needReload) {
|
||||
const data = await api.getRestaurants({ limit: 500, channel: channelFilter || undefined });
|
||||
setRestaurants(data);
|
||||
}
|
||||
}
|
||||
}, [user, showFavorites, showMyReviews, channelFilter, mobileTab, handleReset]);
|
||||
|
||||
const handleToggleFavorites = async () => {
|
||||
if (showFavorites) {
|
||||
setShowFavorites(false);
|
||||
@@ -436,6 +533,13 @@ export default function Home() {
|
||||
<div className="w-96 shrink-0">
|
||||
<SearchBar onSearch={handleSearch} isLoading={loading} />
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
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={channelFilter}
|
||||
onChange={(e) => {
|
||||
@@ -445,7 +549,7 @@ export default function Home() {
|
||||
}}
|
||||
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>
|
||||
<option value="">📺 채널</option>
|
||||
{channels.map((ch) => (
|
||||
<option key={ch.id} value={ch.channel_name}>
|
||||
📺 {ch.channel_name}
|
||||
@@ -457,7 +561,7 @@ export default function Home() {
|
||||
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>
|
||||
<option value="">🍽 장르</option>
|
||||
{CUISINE_TAXONOMY.map((g) => (
|
||||
<optgroup key={g.category} label={`── ${g.category} ──`}>
|
||||
<option value={g.category}>🍽 {g.category} 전체</option>
|
||||
@@ -474,7 +578,7 @@ export default function Home() {
|
||||
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>
|
||||
<option value="">💰 가격</option>
|
||||
{PRICE_GROUPS.map((g) => (
|
||||
<option key={g.label} value={g.label}>{g.label}</option>
|
||||
))}
|
||||
@@ -487,7 +591,7 @@ export default function Home() {
|
||||
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>
|
||||
<option value="">🌍 나라</option>
|
||||
{countries.map((c) => (
|
||||
<option key={c} value={c}>🌍 {c}</option>
|
||||
))}
|
||||
@@ -518,15 +622,29 @@ export default function Home() {
|
||||
)}
|
||||
<div className="w-px h-5 bg-gray-200 dark:bg-gray-700" />
|
||||
<button
|
||||
onClick={() => setBoundsFilterOn(!boundsFilterOn)}
|
||||
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="지도 영역 내 식당만 표시"
|
||||
title="내 위치 주변 식당만 표시"
|
||||
>
|
||||
{boundsFilterOn ? "📍 영역 필터" : "📍 영역"}
|
||||
{boundsFilterOn ? "📍 내위치 ON" : "📍 내위치"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode(viewMode === "map" ? "list" : "map")}
|
||||
@@ -591,74 +709,32 @@ export default function Home() {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<GoogleLogin
|
||||
onSuccess={(credentialResponse) => {
|
||||
if (credentialResponse.credential) {
|
||||
login(credentialResponse.credential).catch(console.error);
|
||||
}
|
||||
}}
|
||||
onError={() => console.error("Google login failed")}
|
||||
size="small"
|
||||
/>
|
||||
<LoginMenu onGoogleSuccess={(credential) => login(credential).catch(console.error)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Header row 2 (mobile only): search + toolbar ── */}
|
||||
<div className="md:hidden px-4 pb-3 space-y-2">
|
||||
<div className={`md:hidden px-4 pb-3 space-y-2 ${mobileTab === "favorites" || mobileTab === "profile" ? "hidden" : ""}`}>
|
||||
{/* Row 1: Search */}
|
||||
<SearchBar onSearch={handleSearch} isLoading={loading} />
|
||||
{/* Row 2: Toolbar */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setViewMode(viewMode === "map" ? "list" : "map")}
|
||||
className={`px-3 py-1.5 text-xs border rounded-lg transition-colors ${
|
||||
viewMode === "map"
|
||||
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400"
|
||||
: "text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{viewMode === "map" ? "🗺 지도" : "☰ 리스트"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowMobileFilters(!showMobileFilters)}
|
||||
className={`px-3 py-1.5 text-xs border rounded-lg transition-colors relative ${
|
||||
showMobileFilters || channelFilter || cuisineFilter || priceFilter || countryFilter || boundsFilterOn
|
||||
showMobileFilters || channelFilter || cuisineFilter || priceFilter || countryFilter
|
||||
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400"
|
||||
: "text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{showMobileFilters ? "✕ 닫기" : "🔽 필터"}
|
||||
{!showMobileFilters && (channelFilter || cuisineFilter || priceFilter || countryFilter || boundsFilterOn) && (
|
||||
{!showMobileFilters && (channelFilter || cuisineFilter || priceFilter || countryFilter) && (
|
||||
<span className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-orange-500 text-white rounded-full text-[9px] flex items-center justify-center">
|
||||
{[channelFilter, cuisineFilter, priceFilter, countryFilter, boundsFilterOn].filter(Boolean).length}
|
||||
{[channelFilter, cuisineFilter, priceFilter, countryFilter].filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{user && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleToggleFavorites}
|
||||
className={`px-3 py-1.5 text-xs rounded-full 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"
|
||||
}`}
|
||||
>
|
||||
{showFavorites ? "♥ 찜" : "♡ 찜"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleMyReviews}
|
||||
className={`px-3 py-1.5 text-xs rounded-full 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"
|
||||
}`}
|
||||
>
|
||||
{showMyReviews ? "✎ 리뷰" : "✎ 리뷰"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<span className="text-xs text-gray-400 ml-auto">
|
||||
{filteredRestaurants.length}개
|
||||
</span>
|
||||
@@ -678,7 +754,7 @@ export default function Home() {
|
||||
}}
|
||||
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>
|
||||
{channels.map((ch) => (
|
||||
<option key={ch.id} value={ch.channel_name}>
|
||||
📺 {ch.channel_name}
|
||||
@@ -690,7 +766,7 @@ export default function Home() {
|
||||
onChange={(e) => setCuisineFilter(e.target.value)}
|
||||
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>
|
||||
{CUISINE_TAXONOMY.map((g) => (
|
||||
<optgroup key={g.category} label={`── ${g.category} ──`}>
|
||||
<option value={g.category}>🍽 {g.category} 전체</option>
|
||||
@@ -707,7 +783,7 @@ export default function Home() {
|
||||
onChange={(e) => setPriceFilter(e.target.value)}
|
||||
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>
|
||||
{PRICE_GROUPS.map((g) => (
|
||||
<option key={g.label} value={g.label}>💰 {g.label}</option>
|
||||
))}
|
||||
@@ -720,7 +796,7 @@ export default function Home() {
|
||||
onChange={(e) => handleCountryChange(e.target.value)}
|
||||
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>
|
||||
{countries.map((c) => (
|
||||
<option key={c} value={c}>🌍 {c}</option>
|
||||
))}
|
||||
@@ -753,14 +829,28 @@ export default function Home() {
|
||||
{/* Toggle buttons */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => setBoundsFilterOn(!boundsFilterOn)}
|
||||
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-2.5 py-1.5 text-xs 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"
|
||||
: "text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
{boundsFilterOn ? "📍 영역 ON" : "📍 영역"}
|
||||
{boundsFilterOn ? "📍 내위치 ON" : "📍 내위치"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -784,6 +874,8 @@ export default function Home() {
|
||||
onSelectRestaurant={handleSelectRestaurant}
|
||||
onBoundsChanged={handleBoundsChanged}
|
||||
flyTo={regionFlyTo}
|
||||
onMyLocation={handleMyLocation}
|
||||
activeChannel={channelFilter || undefined}
|
||||
/>
|
||||
{visits && (
|
||||
<div className="absolute bottom-1 right-2 bg-white/60 backdrop-blur-sm text-gray-700 text-[10px] px-2.5 py-1 rounded-lg shadow-sm">
|
||||
@@ -804,6 +896,8 @@ export default function Home() {
|
||||
onSelectRestaurant={handleSelectRestaurant}
|
||||
onBoundsChanged={handleBoundsChanged}
|
||||
flyTo={regionFlyTo}
|
||||
onMyLocation={handleMyLocation}
|
||||
activeChannel={channelFilter || undefined}
|
||||
/>
|
||||
{visits && (
|
||||
<div className="absolute bottom-1 right-2 bg-white/60 backdrop-blur-sm text-gray-700 text-[10px] px-2.5 py-1 rounded-lg shadow-sm">
|
||||
@@ -816,68 +910,115 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
{/* Mobile layout */}
|
||||
<div className="md:hidden flex-1 flex flex-col overflow-hidden">
|
||||
{viewMode === "map" ? (
|
||||
<>
|
||||
<div className="flex-1 relative">
|
||||
<div className="md:hidden flex-1 flex flex-col overflow-hidden pb-14">
|
||||
{/* Tab content — takes all remaining space above fixed nav */}
|
||||
{mobileTab === "nearby" ? (
|
||||
/* 내주변: 지도 + 리스트 분할, 영역필터 ON */
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="h-[45%] relative shrink-0">
|
||||
<MapView
|
||||
restaurants={filteredRestaurants}
|
||||
selected={selected}
|
||||
onSelectRestaurant={handleSelectRestaurant}
|
||||
onBoundsChanged={handleBoundsChanged}
|
||||
flyTo={regionFlyTo}
|
||||
onMyLocation={handleMyLocation}
|
||||
activeChannel={channelFilter || undefined}
|
||||
/>
|
||||
<div className="absolute top-2 left-2 bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm rounded-lg px-3 py-1.5 shadow-sm z-10">
|
||||
<span className="text-xs font-medium text-orange-600 dark:text-orange-400">
|
||||
내 주변 {filteredRestaurants.length}개
|
||||
</span>
|
||||
</div>
|
||||
{visits && (
|
||||
<div className="absolute bottom-1 right-2 bg-white/60 backdrop-blur-sm text-gray-700 text-[10px] px-2.5 py-1 rounded-lg shadow-sm z-10">
|
||||
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* List area — if selected, show single row; otherwise full list */}
|
||||
{selected ? (
|
||||
<div
|
||||
className="shrink-0 bg-white dark:bg-gray-950 border-b dark:border-gray-800 px-3 py-2 flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => { setSelected(null); setShowDetail(false); }}
|
||||
>
|
||||
<span className="text-base">{getCuisineIcon(selected.cuisine_type)}</span>
|
||||
<span className="font-semibold text-sm truncate flex-1">{selected.name}</span>
|
||||
{selected.rating && (
|
||||
<span className="text-xs text-gray-500"><span className="text-yellow-500">★</span> {selected.rating}</span>
|
||||
)}
|
||||
{selected.cuisine_type && (
|
||||
<span className="text-xs text-gray-400">{selected.cuisine_type}</span>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setSelected(null); setShowDetail(false); }}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-lg leading-none ml-1"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{mobileListContent}
|
||||
</div>
|
||||
</div>
|
||||
) : mobileTab === "profile" ? (
|
||||
/* 내정보 */
|
||||
<div className="flex-1 overflow-y-auto bg-white dark:bg-gray-950">
|
||||
{!user ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 px-6">
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">로그인하고 리뷰와 찜 목록을 관리하세요</p>
|
||||
<GoogleLogin
|
||||
onSuccess={(res) => {
|
||||
if (res.credential) login(res.credential).catch(console.error);
|
||||
}}
|
||||
onError={() => console.error("Google login failed")}
|
||||
size="large"
|
||||
text="signin_with"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[360px] bg-white dark:bg-gray-950 overflow-y-auto border-b dark:border-gray-800">
|
||||
{mobileListContent}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 프로필 헤더 */}
|
||||
<div className="flex items-center gap-3 pb-4 border-b dark:border-gray-800">
|
||||
{user.avatar_url ? (
|
||||
<img src={user.avatar_url} alt="" className="w-12 h-12 rounded-full border border-gray-200" />
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-orange-100 text-orange-700 flex items-center justify-center text-lg font-semibold border border-orange-200">
|
||||
{(user.nickname || user.email || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-sm dark:text-gray-100">{user.nickname || user.email}</p>
|
||||
{user.nickname && user.email && (
|
||||
<p className="text-xs text-gray-400">{user.email}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="px-3 py-1.5 text-xs text-gray-500 border border-gray-300 dark:border-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
{/* 내 리뷰 */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm mb-2 dark:text-gray-200">내 리뷰</h3>
|
||||
{myReviews.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">작성한 리뷰가 없습니다</p>
|
||||
) : (
|
||||
<MyReviewsList
|
||||
reviews={myReviews}
|
||||
onClose={() => {}}
|
||||
onSelectRestaurant={async (restaurantId) => {
|
||||
try {
|
||||
const r = await api.getRestaurant(restaurantId);
|
||||
handleSelectRestaurant(r);
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Map fills remaining space below the list */}
|
||||
<div className="flex-1 relative">
|
||||
<MapView
|
||||
restaurants={filteredRestaurants}
|
||||
selected={selected}
|
||||
onSelectRestaurant={handleSelectRestaurant}
|
||||
onBoundsChanged={handleBoundsChanged}
|
||||
flyTo={regionFlyTo}
|
||||
/>
|
||||
{visits && (
|
||||
<div className="absolute bottom-1 right-2 bg-white/60 backdrop-blur-sm text-gray-700 text-[10px] px-2.5 py-1 rounded-lg shadow-sm z-10">
|
||||
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
/* 홈 / 식당 목록 / 찜: 리스트 표시 */
|
||||
<div className="flex-1 overflow-y-auto bg-white dark:bg-gray-950">
|
||||
{mobileTab === "favorites" && !user ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 px-6">
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm">로그인하고 찜 목록을 확인하세요</p>
|
||||
<GoogleLogin
|
||||
onSuccess={(res) => {
|
||||
if (res.credential) login(res.credential).catch(console.error);
|
||||
}}
|
||||
onError={() => console.error("Google login failed")}
|
||||
size="large"
|
||||
text="signin_with"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
mobileListContent
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Bottom Sheet for restaurant detail */}
|
||||
@@ -888,8 +1029,44 @@ export default function Home() {
|
||||
</BottomSheet>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="shrink-0 border-t dark:border-gray-800 bg-white/60 dark:bg-gray-900/60 backdrop-blur-sm py-2.5 flex items-center justify-center gap-2 text-[11px] text-gray-400 dark:text-gray-500 group">
|
||||
{/* ── Mobile Bottom Nav (fixed) ── */}
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-40 border-t dark:border-gray-800 bg-white dark:bg-gray-950 safe-area-bottom">
|
||||
<div className="flex items-stretch h-14">
|
||||
{([
|
||||
{ key: "home", label: "홈", icon: (
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M12 3l9 8h-3v9h-5v-6h-2v6H6v-9H3z"/></svg>
|
||||
)},
|
||||
{ key: "list", label: "식당 목록", icon: (
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M3 4h18v2H3zm0 7h18v2H3zm0 7h18v2H3z"/></svg>
|
||||
)},
|
||||
{ key: "nearby", label: "내주변", icon: (
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-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.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>
|
||||
)},
|
||||
{ key: "favorites", label: "찜", icon: (
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
|
||||
)},
|
||||
{ key: "profile", label: "내정보", icon: (
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
|
||||
)},
|
||||
] as { key: "home" | "list" | "nearby" | "favorites" | "profile"; label: string; icon: React.ReactNode }[]).map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => handleMobileTab(tab.key)}
|
||||
className={`flex-1 flex flex-col items-center justify-center gap-0.5 py-2 transition-colors ${
|
||||
mobileTab === tab.key
|
||||
? "text-orange-600 dark:text-orange-400"
|
||||
: "text-gray-400 dark:text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
<span className="text-[10px] font-medium">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Desktop Footer */}
|
||||
<footer className="hidden md:flex shrink-0 border-t dark:border-gray-800 bg-white/60 dark:bg-gray-900/60 backdrop-blur-sm py-2.5 items-center justify-center gap-2 text-[11px] text-gray-400 dark:text-gray-500 group">
|
||||
<div className="relative">
|
||||
<img
|
||||
src="/icon.jpg"
|
||||
|
||||
60
frontend/src/components/LoginMenu.tsx
Normal file
60
frontend/src/components/LoginMenu.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { GoogleLogin } from "@react-oauth/google";
|
||||
|
||||
interface LoginMenuProps {
|
||||
onGoogleSuccess: (credential: string) => void;
|
||||
}
|
||||
|
||||
export default function LoginMenu({ onGoogleSuccess }: LoginMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="px-3 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-orange-600 dark:hover:text-orange-400 border border-gray-300 dark:border-gray-600 hover:border-orange-400 dark:hover:border-orange-500 rounded-lg transition-colors"
|
||||
>
|
||||
로그인
|
||||
</button>
|
||||
|
||||
{open && createPortal(
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
style={{ zIndex: 99999 }}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setOpen(false); }}
|
||||
>
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl p-6 mx-4 w-full max-w-xs space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold dark:text-gray-100">로그인</h3>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-lg leading-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">소셜 계정으로 간편 로그인</p>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<GoogleLogin
|
||||
onSuccess={(res) => {
|
||||
if (res.credential) {
|
||||
onGoogleSuccess(res.credential);
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
onError={() => console.error("Google login failed")}
|
||||
size="large"
|
||||
width="260"
|
||||
text="signin_with"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -57,33 +57,14 @@ interface MapViewProps {
|
||||
onSelectRestaurant?: (r: Restaurant) => void;
|
||||
onBoundsChanged?: (bounds: MapBounds) => void;
|
||||
flyTo?: FlyTo | null;
|
||||
onMyLocation?: () => void;
|
||||
activeChannel?: string;
|
||||
}
|
||||
|
||||
function MapContent({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo }: MapViewProps) {
|
||||
function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeChannel }: Omit<MapViewProps, "onMyLocation" | "onBoundsChanged">) {
|
||||
const map = useMap();
|
||||
const [infoTarget, setInfoTarget] = useState<Restaurant | null>(null);
|
||||
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
||||
const boundsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Report bounds on idle (debounced)
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
const listener = map.addListener("idle", () => {
|
||||
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
|
||||
boundsTimerRef.current = setTimeout(() => {
|
||||
const b = map.getBounds();
|
||||
if (b && onBoundsChanged) {
|
||||
const ne = b.getNorthEast();
|
||||
const sw = b.getSouthWest();
|
||||
onBoundsChanged({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
return () => {
|
||||
google.maps.event.removeListener(listener);
|
||||
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
|
||||
};
|
||||
}, [map, onBoundsChanged]);
|
||||
|
||||
const handleMarkerClick = useCallback(
|
||||
(r: Restaurant) => {
|
||||
@@ -113,7 +94,8 @@ function MapContent({ restaurants, selected, onSelectRestaurant, onBoundsChanged
|
||||
{restaurants.map((r) => {
|
||||
const isSelected = selected?.id === r.id;
|
||||
const isClosed = r.business_status === "CLOSED_PERMANENTLY";
|
||||
const chColor = r.channels?.[0] ? channelColors[r.channels[0]] : CHANNEL_COLORS[0];
|
||||
const chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0];
|
||||
const chColor = chKey ? channelColors[chKey] : CHANNEL_COLORS[0];
|
||||
const c = chColor || CHANNEL_COLORS[0];
|
||||
return (
|
||||
<AdvancedMarker
|
||||
@@ -208,29 +190,56 @@ function MapContent({ restaurants, selected, onSelectRestaurant, onBoundsChanged
|
||||
);
|
||||
}
|
||||
|
||||
export default function MapView({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo }: MapViewProps) {
|
||||
export default function MapView({ restaurants, selected, onSelectRestaurant, onBoundsChanged, flyTo, onMyLocation, activeChannel }: MapViewProps) {
|
||||
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
|
||||
const channelNames = useMemo(() => Object.keys(channelColors), [channelColors]);
|
||||
const channelNames = useMemo(() => {
|
||||
const names = Object.keys(channelColors);
|
||||
if (activeChannel) return names.filter((n) => n === activeChannel);
|
||||
return names;
|
||||
}, [channelColors, activeChannel]);
|
||||
const boundsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleCameraChanged = useCallback((ev: { detail: { bounds: { north: number; south: number; east: number; west: number } } }) => {
|
||||
if (!onBoundsChanged) return;
|
||||
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
|
||||
boundsTimerRef.current = setTimeout(() => {
|
||||
const { north, south, east, west } = ev.detail.bounds;
|
||||
onBoundsChanged({ north, south, east, west });
|
||||
}, 150);
|
||||
}, [onBoundsChanged]);
|
||||
|
||||
return (
|
||||
<APIProvider apiKey={API_KEY}>
|
||||
<Map
|
||||
defaultCenter={SEOUL_CENTER}
|
||||
defaultZoom={12}
|
||||
defaultZoom={13}
|
||||
mapId="tasteby-map"
|
||||
className="h-full w-full"
|
||||
colorScheme="LIGHT"
|
||||
mapTypeControl={false}
|
||||
fullscreenControl={false}
|
||||
onCameraChanged={handleCameraChanged}
|
||||
>
|
||||
<MapContent
|
||||
restaurants={restaurants}
|
||||
selected={selected}
|
||||
onSelectRestaurant={onSelectRestaurant}
|
||||
onBoundsChanged={onBoundsChanged}
|
||||
flyTo={flyTo}
|
||||
activeChannel={activeChannel}
|
||||
/>
|
||||
</Map>
|
||||
{channelNames.length > 1 && (
|
||||
{onMyLocation && (
|
||||
<button
|
||||
onClick={onMyLocation}
|
||||
className="absolute top-2 right-2 w-9 h-9 bg-white dark:bg-gray-900 rounded-lg shadow-md flex items-center justify-center text-gray-600 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-colors z-10"
|
||||
title="내 위치"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current">
|
||||
<path d="M12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm8.94 3A8.994 8.994 0 0013 3.06V1h-2v2.06A8.994 8.994 0 003.06 11H1v2h2.06A8.994 8.994 0 0011 20.94V23h2v-2.06A8.994 8.994 0 0020.94 13H23v-2h-2.06zM12 19c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{channelNames.length > 0 && (
|
||||
<div className="absolute bottom-2 left-2 bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm rounded-lg shadow px-2.5 py-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] z-10">
|
||||
{channelNames.map((ch) => (
|
||||
<div key={ch} className="flex items-center gap-1">
|
||||
|
||||
@@ -84,6 +84,7 @@ export default function RestaurantDetail({
|
||||
|
||||
{restaurant.rating && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-blue-500 dark:text-blue-400 font-medium text-xs">Google</span>
|
||||
<span className="text-yellow-500 dark:text-yellow-400">{"★".repeat(Math.round(restaurant.rating))}</span>
|
||||
<span className="font-medium dark:text-gray-200">{restaurant.rating}</span>
|
||||
{restaurant.rating_count && (
|
||||
@@ -124,7 +125,7 @@ export default function RestaurantDetail({
|
||||
{restaurant.google_place_id && (
|
||||
<p>
|
||||
<a
|
||||
href={`https://www.google.com/maps/place/?q=place_id:${restaurant.google_place_id}`}
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(restaurant.name)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-orange-600 dark:text-orange-400 hover:underline text-xs"
|
||||
@@ -135,6 +136,30 @@ export default function RestaurantDetail({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{restaurant.tabling_url && restaurant.tabling_url !== "NONE" && (
|
||||
<a
|
||||
href={restaurant.tabling_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 bg-rose-500 hover:bg-rose-600 text-white rounded-lg text-sm font-semibold transition-colors"
|
||||
>
|
||||
<span>T</span>
|
||||
<span>테이블링에서 줄서기</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{restaurant.catchtable_url && restaurant.catchtable_url !== "NONE" && (
|
||||
<a
|
||||
href={restaurant.catchtable_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 bg-violet-500 hover:bg-violet-600 text-white rounded-lg text-sm font-semibold transition-colors"
|
||||
>
|
||||
<span>C</span>
|
||||
<span>캐치테이블에서 예약하기</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm mb-2 dark:text-gray-200">관련 영상</h3>
|
||||
{loading ? (
|
||||
@@ -161,7 +186,8 @@ export default function RestaurantDetail({
|
||||
<div key={v.video_id} className="border dark:border-gray-700 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{v.channel_name && (
|
||||
<span className="inline-block px-1.5 py-0.5 bg-orange-50 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 rounded text-[10px] font-medium">
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400 rounded text-[10px] font-semibold">
|
||||
<span className="text-[9px]">▶</span>
|
||||
{v.channel_name}
|
||||
</span>
|
||||
)}
|
||||
@@ -175,8 +201,11 @@ export default function RestaurantDetail({
|
||||
href={v.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-sm font-medium text-orange-600 dark:text-orange-400 hover:underline"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-red-600 dark:text-red-400 hover:underline"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="w-4 h-4 flex-shrink-0 fill-current" aria-hidden="true">
|
||||
<path d="M23.5 6.2c-.3-1-1-1.8-2-2.1C19.6 3.5 12 3.5 12 3.5s-7.6 0-9.5.6c-1 .3-1.7 1.1-2 2.1C0 8.1 0 12 0 12s0 3.9.5 5.8c.3 1 1 1.8 2 2.1 1.9.6 9.5.6 9.5.6s7.6 0 9.5-.6c1-.3 1.7-1.1 2-2.1.5-1.9.5-5.8.5-5.8s0-3.9-.5-5.8zM9.5 15.5V8.5l6.3 3.5-6.3 3.5z"/>
|
||||
</svg>
|
||||
{v.title}
|
||||
</a>
|
||||
{v.foods_mentioned.length > 0 && (
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function SearchBar({ onSearch, isLoading }: SearchBarProps) {
|
||||
>
|
||||
<option value="hybrid">통합</option>
|
||||
<option value="keyword">키워드</option>
|
||||
<option value="semantic">의미</option>
|
||||
<option value="semantic">유사</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface Restaurant {
|
||||
cuisine_type: string | null;
|
||||
price_range: string | null;
|
||||
google_place_id: string | null;
|
||||
tabling_url: string | null;
|
||||
catchtable_url: string | null;
|
||||
business_status: string | null;
|
||||
rating: number | null;
|
||||
rating_count: number | null;
|
||||
@@ -322,6 +324,32 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
searchTabling(restaurantId: string) {
|
||||
return fetchApi<{ title: string; url: string }[]>(
|
||||
`/api/restaurants/${restaurantId}/tabling-search`
|
||||
);
|
||||
},
|
||||
|
||||
setTablingUrl(restaurantId: string, tablingUrl: string) {
|
||||
return fetchApi<{ ok: boolean }>(
|
||||
`/api/restaurants/${restaurantId}/tabling-url`,
|
||||
{ method: "PUT", body: JSON.stringify({ tabling_url: tablingUrl }) }
|
||||
);
|
||||
},
|
||||
|
||||
searchCatchtable(restaurantId: string) {
|
||||
return fetchApi<{ title: string; url: string }[]>(
|
||||
`/api/restaurants/${restaurantId}/catchtable-search`
|
||||
);
|
||||
},
|
||||
|
||||
setCatchtableUrl(restaurantId: string, catchtableUrl: string) {
|
||||
return fetchApi<{ ok: boolean }>(
|
||||
`/api/restaurants/${restaurantId}/catchtable-url`,
|
||||
{ method: "PUT", body: JSON.stringify({ catchtable_url: catchtableUrl }) }
|
||||
);
|
||||
},
|
||||
|
||||
deleteChannel(channelId: string) {
|
||||
return fetchApi<{ ok: boolean }>(`/api/channels/${channelId}`, {
|
||||
method: "DELETE",
|
||||
@@ -381,6 +409,13 @@ export const api = {
|
||||
);
|
||||
},
|
||||
|
||||
uploadTranscript(videoDbId: string, text: string, source: string = "browser") {
|
||||
return fetchApi<{ ok: boolean; length: number; source: string }>(
|
||||
`/api/videos/${videoDbId}/upload-transcript`,
|
||||
{ method: "POST", body: JSON.stringify({ text, source }) }
|
||||
);
|
||||
},
|
||||
|
||||
triggerProcessing(limit: number = 5) {
|
||||
return fetchApi<{ restaurants_extracted: number }>(
|
||||
`/api/videos/process?limit=${limit}`,
|
||||
|
||||
Reference in New Issue
Block a user