Add admin features, responsive UI, user reviews, visit stats, and channel-colored markers
- Admin: video management with Google Maps match status, manual restaurant mapping, restaurant remap on name change - Admin: user management tab with favorites/reviews detail - Admin: channel deletion fix for IDs with slashes - Frontend: responsive mobile layout (map top, list bottom, 2-row header) - Frontend: channel-colored map markers with legend - Frontend: my reviews list, favorites toggle, visit counter overlay - Frontend: force light mode for dark theme devices - Backend: visit tracking (site_visits table), user reviews endpoint - Backend: bulk transcript/extract streaming, geocode key fixes - Nginx config for production deployment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,12 +3,13 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { GoogleLogin } from "@react-oauth/google";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Restaurant } from "@/lib/api";
|
||||
import type { Restaurant, Channel, Review } from "@/lib/api";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import MapView from "@/components/MapView";
|
||||
import SearchBar from "@/components/SearchBar";
|
||||
import RestaurantList from "@/components/RestaurantList";
|
||||
import RestaurantDetail from "@/components/RestaurantDetail";
|
||||
import MyReviewsList from "@/components/MyReviewsList";
|
||||
|
||||
export default function Home() {
|
||||
const { user, login, logout, isLoading: authLoading } = useAuth();
|
||||
@@ -16,14 +17,26 @@ export default function Home() {
|
||||
const [selected, setSelected] = useState<Restaurant | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [channels, setChannels] = useState<Channel[]>([]);
|
||||
const [channelFilter, setChannelFilter] = useState("");
|
||||
const [showFavorites, setShowFavorites] = useState(false);
|
||||
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);
|
||||
|
||||
// Load all restaurants on mount
|
||||
// Load channels + record visit on mount
|
||||
useEffect(() => {
|
||||
api.getChannels().then(setChannels).catch(console.error);
|
||||
api.recordVisit().then(() => api.getVisits().then(setVisits)).catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Load restaurants on mount and when channel filter changes
|
||||
useEffect(() => {
|
||||
api
|
||||
.getRestaurants({ limit: 200 })
|
||||
.getRestaurants({ limit: 200, channel: channelFilter || undefined })
|
||||
.then(setRestaurants)
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
}, [channelFilter]);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
async (query: string, mode: "keyword" | "semantic" | "hybrid") => {
|
||||
@@ -53,6 +66,9 @@ export default function Home() {
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setLoading(true);
|
||||
setChannelFilter("");
|
||||
setShowFavorites(false);
|
||||
setShowMyReviews(false);
|
||||
api
|
||||
.getRestaurants({ limit: 200 })
|
||||
.then((data) => {
|
||||
@@ -64,77 +80,260 @@ export default function Home() {
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleToggleFavorites = async () => {
|
||||
if (showFavorites) {
|
||||
setShowFavorites(false);
|
||||
const data = await api.getRestaurants({ limit: 200, channel: channelFilter || undefined });
|
||||
setRestaurants(data);
|
||||
} else {
|
||||
try {
|
||||
const favs = await api.getMyFavorites();
|
||||
setRestaurants(favs);
|
||||
setShowFavorites(true);
|
||||
setShowMyReviews(false);
|
||||
setMyReviews([]);
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleMyReviews = async () => {
|
||||
if (showMyReviews) {
|
||||
setShowMyReviews(false);
|
||||
setMyReviews([]);
|
||||
} else {
|
||||
try {
|
||||
const reviews = await api.getMyReviews();
|
||||
setMyReviews(reviews);
|
||||
setShowMyReviews(true);
|
||||
setShowFavorites(false);
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
};
|
||||
|
||||
const sidebarContent = showMyReviews ? (
|
||||
<MyReviewsList
|
||||
reviews={myReviews}
|
||||
onClose={() => { setShowMyReviews(false); setMyReviews([]); }}
|
||||
onSelectRestaurant={async (restaurantId) => {
|
||||
try {
|
||||
const r = await api.getRestaurant(restaurantId);
|
||||
handleSelectRestaurant(r);
|
||||
setShowMyReviews(false);
|
||||
setMyReviews([]);
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
/>
|
||||
) : showDetail && selected ? (
|
||||
<RestaurantDetail
|
||||
restaurant={selected}
|
||||
onClose={handleCloseDetail}
|
||||
/>
|
||||
) : (
|
||||
<RestaurantList
|
||||
restaurants={restaurants}
|
||||
selectedId={selected?.id}
|
||||
onSelect={handleSelectRestaurant}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b px-4 py-3 flex items-center gap-4 shrink-0">
|
||||
<button onClick={handleReset} className="text-lg font-bold whitespace-nowrap">
|
||||
Tasteby
|
||||
</button>
|
||||
<div className="flex-1 max-w-xl">
|
||||
<SearchBar onSearch={handleSearch} isLoading={loading} />
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">
|
||||
{restaurants.length}개 식당
|
||||
</span>
|
||||
<div className="shrink-0">
|
||||
{authLoading ? null : user ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{user.avatar_url && (
|
||||
<img
|
||||
src={user.avatar_url}
|
||||
alt=""
|
||||
className="w-7 h-7 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm">{user.nickname || user.email}</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-xs text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<GoogleLogin
|
||||
onSuccess={(credentialResponse) => {
|
||||
if (credentialResponse.credential) {
|
||||
login(credentialResponse.credential).catch(console.error);
|
||||
}
|
||||
{/* ── Header row 1: Logo + User ── */}
|
||||
<header className="bg-white border-b shrink-0">
|
||||
<div className="px-4 py-2 flex items-center justify-between">
|
||||
<button onClick={handleReset} className="text-lg font-bold whitespace-nowrap">
|
||||
Tasteby
|
||||
</button>
|
||||
|
||||
{/* Desktop: search inline */}
|
||||
<div className="hidden md:block flex-1 max-w-xl mx-4">
|
||||
<SearchBar onSearch={handleSearch} isLoading={loading} />
|
||||
</div>
|
||||
|
||||
{/* Desktop: filters inline */}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<select
|
||||
value={channelFilter}
|
||||
onChange={(e) => {
|
||||
setChannelFilter(e.target.value);
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
}}
|
||||
onError={() => console.error("Google login failed")}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
className="border rounded px-2 py-1.5 text-sm text-gray-600"
|
||||
>
|
||||
<option value="">전체 채널</option>
|
||||
{channels.map((ch) => (
|
||||
<option key={ch.id} value={ch.channel_name}>
|
||||
{ch.channel_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{user && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleToggleFavorites}
|
||||
className={`px-3 py-1.5 text-sm rounded-full border transition-colors ${
|
||||
showFavorites
|
||||
? "bg-red-50 border-red-300 text-red-600"
|
||||
: "border-gray-300 text-gray-600 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{showFavorites ? "♥ 내 찜" : "♡ 찜"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleMyReviews}
|
||||
className={`px-3 py-1.5 text-sm rounded-full border transition-colors ${
|
||||
showMyReviews
|
||||
? "bg-blue-50 border-blue-300 text-blue-600"
|
||||
: "border-gray-300 text-gray-600 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{showMyReviews ? "✎ 내 리뷰" : "✎ 리뷰"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<span className="text-sm text-gray-500 whitespace-nowrap">
|
||||
{restaurants.length}개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-4 shrink-0 hidden md:block" />
|
||||
|
||||
{/* User area */}
|
||||
<div className="shrink-0">
|
||||
{authLoading ? null : user ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{user.avatar_url ? (
|
||||
<img
|
||||
src={user.avatar_url}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full border border-gray-200"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-amber-100 text-amber-700 flex items-center justify-center text-sm font-semibold border border-amber-200">
|
||||
{(user.nickname || user.email || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="hidden sm:inline text-sm font-medium text-gray-700">
|
||||
{user.nickname || user.email}
|
||||
</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="ml-1 px-2.5 py-1 text-xs text-gray-500 border border-gray-300 rounded-full hover:bg-gray-100 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<GoogleLogin
|
||||
onSuccess={(credentialResponse) => {
|
||||
if (credentialResponse.credential) {
|
||||
login(credentialResponse.credential).catch(console.error);
|
||||
}
|
||||
}}
|
||||
onError={() => console.error("Google login failed")}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Header row 2 (mobile only): search + filters ── */}
|
||||
<div className="md:hidden px-4 pb-2 space-y-2">
|
||||
<SearchBar onSearch={handleSearch} isLoading={loading} />
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
<select
|
||||
value={channelFilter}
|
||||
onChange={(e) => {
|
||||
setChannelFilter(e.target.value);
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
}}
|
||||
className="border rounded px-2 py-1 text-xs text-gray-600 shrink-0"
|
||||
>
|
||||
<option value="">전체 채널</option>
|
||||
{channels.map((ch) => (
|
||||
<option key={ch.id} value={ch.channel_name}>
|
||||
{ch.channel_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{user && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleToggleFavorites}
|
||||
className={`px-2.5 py-1 text-xs rounded-full border transition-colors shrink-0 ${
|
||||
showFavorites
|
||||
? "bg-red-50 border-red-300 text-red-600"
|
||||
: "border-gray-300 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{showFavorites ? "♥ 내 찜" : "♡ 찜"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleMyReviews}
|
||||
className={`px-2.5 py-1 text-xs rounded-full border transition-colors shrink-0 ${
|
||||
showMyReviews
|
||||
? "bg-blue-50 border-blue-300 text-blue-600"
|
||||
: "border-gray-300 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{showMyReviews ? "✎ 내 리뷰" : "✎ 리뷰"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<span className="text-xs text-gray-400 shrink-0 ml-1">
|
||||
{restaurants.length}개
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-80 bg-white border-r overflow-y-auto shrink-0">
|
||||
{showDetail && selected ? (
|
||||
<RestaurantDetail
|
||||
restaurant={selected}
|
||||
onClose={handleCloseDetail}
|
||||
/>
|
||||
) : (
|
||||
<RestaurantList
|
||||
restaurants={restaurants}
|
||||
selectedId={selected?.id}
|
||||
onSelect={handleSelectRestaurant}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
{/* ── Body: Desktop = side-by-side, Mobile = stacked ── */}
|
||||
|
||||
{/* Map */}
|
||||
<main className="flex-1">
|
||||
{/* Desktop layout */}
|
||||
<div className="hidden md:flex flex-1 overflow-hidden">
|
||||
<aside className="w-80 bg-white border-r overflow-y-auto shrink-0">
|
||||
{sidebarContent}
|
||||
</aside>
|
||||
<main className="flex-1 relative">
|
||||
<MapView
|
||||
restaurants={restaurants}
|
||||
selected={selected}
|
||||
onSelectRestaurant={handleSelectRestaurant}
|
||||
/>
|
||||
{visits && (
|
||||
<div className="absolute bottom-1 right-2 bg-black/40 text-white text-[10px] px-2 py-0.5 rounded">
|
||||
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Mobile layout */}
|
||||
<div className="md:hidden flex-1 flex flex-col overflow-hidden">
|
||||
{/* Map: fixed height */}
|
||||
<div className="h-[40vh] shrink-0 relative">
|
||||
<MapView
|
||||
restaurants={restaurants}
|
||||
selected={selected}
|
||||
onSelectRestaurant={handleSelectRestaurant}
|
||||
/>
|
||||
{visits && (
|
||||
<div className="absolute bottom-1 right-2 bg-black/40 text-white text-[10px] px-2 py-0.5 rounded z-10">
|
||||
오늘 {visits.today} · 전체 {visits.total.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* List/Detail: scrollable below */}
|
||||
<div className="flex-1 bg-white border-t overflow-y-auto">
|
||||
{sidebarContent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user