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:
joungmin
2026-03-07 14:52:20 +09:00
parent 36bec10bd0
commit 3694730501
27 changed files with 4346 additions and 189 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
:root {
--background: #ffffff;
--foreground: #171717;
color-scheme: light only;
}
@theme inline {
@@ -11,6 +12,18 @@
--font-sans: var(--font-geist);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #ffffff;
--foreground: #171717;
color-scheme: light only;
}
}
* {
color-scheme: light only;
}
body {
background: var(--background);
color: var(--foreground);
@@ -20,3 +33,21 @@ html, body, #__next {
height: 100%;
margin: 0;
}
input, select, textarea {
color-scheme: light;
}
/* Force Google Maps InfoWindow to light mode */
.gm-style .gm-style-iw,
.gm-style .gm-style-iw-c,
.gm-style .gm-style-iw-d,
.gm-style .gm-style-iw-t {
background-color: #ffffff !important;
color: #171717 !important;
color-scheme: light !important;
}
.gm-style .gm-style-iw-d {
overflow: auto !important;
}

View File

@@ -19,7 +19,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="ko">
<html lang="ko" style={{ colorScheme: "light" }}>
<body className={`${geist.variable} font-sans antialiased`}>
<Providers>{children}</Providers>
</body>

View File

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

View File

@@ -1,33 +1,172 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
APIProvider,
Map,
AdvancedMarker,
InfoWindow,
useMap,
} from "@vis.gl/react-google-maps";
import type { Restaurant } from "@/lib/api";
const SEOUL_CENTER = { lat: 37.5665, lng: 126.978 };
const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
// Channel color palette
const CHANNEL_COLORS = [
{ bg: "#fff7ed", text: "#78350f", border: "#f59e0b", arrow: "#f59e0b" }, // amber (default)
{ bg: "#eff6ff", text: "#1e3a5f", border: "#3b82f6", arrow: "#3b82f6" }, // blue
{ bg: "#f0fdf4", text: "#14532d", border: "#22c55e", arrow: "#22c55e" }, // green
{ bg: "#fdf2f8", text: "#831843", border: "#ec4899", arrow: "#ec4899" }, // pink
{ bg: "#faf5ff", text: "#581c87", border: "#a855f7", arrow: "#a855f7" }, // purple
{ bg: "#fff1f2", text: "#7f1d1d", border: "#ef4444", arrow: "#ef4444" }, // red
{ bg: "#f0fdfa", text: "#134e4a", border: "#14b8a6", arrow: "#14b8a6" }, // teal
{ bg: "#fefce8", text: "#713f12", border: "#eab308", arrow: "#eab308" }, // yellow
];
function getChannelColorMap(restaurants: Restaurant[]) {
const channels = new Set<string>();
restaurants.forEach((r) => r.channels?.forEach((ch) => channels.add(ch)));
const map: Record<string, typeof CHANNEL_COLORS[0]> = {};
let i = 0;
for (const ch of channels) {
map[ch] = CHANNEL_COLORS[i % CHANNEL_COLORS.length];
i++;
}
return map;
}
interface MapViewProps {
restaurants: Restaurant[];
selected?: Restaurant | null;
onSelectRestaurant?: (r: Restaurant) => void;
}
export default function MapView({ restaurants, onSelectRestaurant }: MapViewProps) {
const [selected, setSelected] = useState<Restaurant | null>(null);
function MapContent({ restaurants, selected, onSelectRestaurant }: MapViewProps) {
const map = useMap();
const [infoTarget, setInfoTarget] = useState<Restaurant | null>(null);
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
const handleMarkerClick = useCallback(
(r: Restaurant) => {
setSelected(r);
setInfoTarget(r);
onSelectRestaurant?.(r);
},
[onSelectRestaurant]
);
// Pan and zoom to selected restaurant
useEffect(() => {
if (!map || !selected) return;
map.panTo({ lat: selected.latitude, lng: selected.longitude });
map.setZoom(16);
setInfoTarget(selected);
}, [map, selected]);
return (
<>
{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 c = chColor || CHANNEL_COLORS[0];
return (
<AdvancedMarker
key={r.id}
position={{ lat: r.latitude, lng: r.longitude }}
onClick={() => handleMarkerClick(r)}
zIndex={isSelected ? 1000 : 1}
>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", transition: "transform 0.2s ease", transform: isSelected ? "scale(1.15)" : "scale(1)", opacity: isClosed ? 0.5 : 1 }}>
<div
style={{
padding: "4px 8px",
backgroundColor: isSelected ? "#2563eb" : isClosed ? "#f3f4f6" : c.bg,
color: isSelected ? "#fff" : isClosed ? "#9ca3af" : c.text,
fontSize: 12,
fontWeight: 600,
borderRadius: 6,
border: isSelected ? "2px solid #1d4ed8" : `1.5px solid ${c.border}`,
boxShadow: isSelected
? "0 2px 8px rgba(37,99,235,0.4)"
: `0 1px 4px ${c.border}40`,
whiteSpace: "nowrap",
maxWidth: 120,
overflow: "hidden",
textOverflow: "ellipsis",
textDecoration: isClosed ? "line-through" : "none",
}}
>
{r.name}
</div>
<div
style={{
width: 0,
height: 0,
borderLeft: "6px solid transparent",
borderRight: "6px solid transparent",
borderTop: isSelected ? "6px solid #1d4ed8" : `6px solid ${c.arrow}`,
marginTop: -1,
}}
/>
</div>
</AdvancedMarker>
);
})}
{infoTarget && (
<InfoWindow
position={{ lat: infoTarget.latitude, lng: infoTarget.longitude }}
onCloseClick={() => setInfoTarget(null)}
>
<div style={{ backgroundColor: "#ffffff", color: "#171717", colorScheme: "light" }} className="max-w-xs p-1">
<div className="flex items-center gap-2">
<h3 className="font-bold text-base" style={{ color: "#171717" }}>{infoTarget.name}</h3>
{infoTarget.business_status === "CLOSED_PERMANENTLY" && (
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold"></span>
)}
{infoTarget.business_status === "CLOSED_TEMPORARILY" && (
<span className="px-1.5 py-0.5 bg-yellow-100 text-yellow-700 rounded text-[10px] font-semibold"></span>
)}
</div>
{infoTarget.rating && (
<p className="text-xs mt-0.5">
<span className="text-yellow-500"></span> {infoTarget.rating}
{infoTarget.rating_count && (
<span className="text-gray-400 ml-1">({infoTarget.rating_count.toLocaleString()})</span>
)}
</p>
)}
{infoTarget.cuisine_type && (
<p className="text-sm text-gray-600">{infoTarget.cuisine_type}</p>
)}
{infoTarget.address && (
<p className="text-xs text-gray-500 mt-1">{infoTarget.address}</p>
)}
{infoTarget.price_range && (
<p className="text-xs text-gray-500">{infoTarget.price_range}</p>
)}
{infoTarget.phone && (
<p className="text-xs text-gray-500">{infoTarget.phone}</p>
)}
<button
onClick={() => onSelectRestaurant?.(infoTarget)}
className="mt-2 text-sm text-blue-600 hover:underline"
>
</button>
</div>
</InfoWindow>
)}
</>
);
}
export default function MapView({ restaurants, selected, onSelectRestaurant }: MapViewProps) {
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
const channelNames = useMemo(() => Object.keys(channelColors), [channelColors]);
return (
<APIProvider apiKey={API_KEY}>
<Map
@@ -35,41 +174,27 @@ export default function MapView({ restaurants, onSelectRestaurant }: MapViewProp
defaultZoom={12}
mapId="tasteby-map"
className="h-full w-full"
colorScheme="LIGHT"
>
{restaurants.map((r) => (
<AdvancedMarker
key={r.id}
position={{ lat: r.latitude, lng: r.longitude }}
onClick={() => handleMarkerClick(r)}
/>
))}
{selected && (
<InfoWindow
position={{ lat: selected.latitude, lng: selected.longitude }}
onCloseClick={() => setSelected(null)}
>
<div className="max-w-xs p-1">
<h3 className="font-bold text-base">{selected.name}</h3>
{selected.cuisine_type && (
<p className="text-sm text-gray-600">{selected.cuisine_type}</p>
)}
{selected.address && (
<p className="text-xs text-gray-500 mt-1">{selected.address}</p>
)}
{selected.price_range && (
<p className="text-xs text-gray-500">{selected.price_range}</p>
)}
<button
onClick={() => onSelectRestaurant?.(selected)}
className="mt-2 text-sm text-blue-600 hover:underline"
>
</button>
</div>
</InfoWindow>
)}
<MapContent
restaurants={restaurants}
selected={selected}
onSelectRestaurant={onSelectRestaurant}
/>
</Map>
{channelNames.length > 1 && (
<div className="absolute top-2 left-2 bg-white/90 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">
<span
className="inline-block w-2.5 h-2.5 rounded-full border"
style={{ backgroundColor: channelColors[ch].border, borderColor: channelColors[ch].border }}
/>
<span className="text-gray-700">{ch}</span>
</div>
))}
</div>
)}
</APIProvider>
);
}

View File

@@ -0,0 +1,69 @@
"use client";
import type { Review } from "@/lib/api";
interface MyReview extends Review {
restaurant_id: string;
restaurant_name: string | null;
}
interface MyReviewsListProps {
reviews: MyReview[];
onClose: () => void;
onSelectRestaurant: (restaurantId: string) => void;
}
export default function MyReviewsList({
reviews,
onClose,
onSelectRestaurant,
}: MyReviewsListProps) {
return (
<div className="p-4 space-y-3">
<div className="flex justify-between items-center">
<h2 className="font-bold text-lg"> ({reviews.length})</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-xl leading-none"
>
x
</button>
</div>
{reviews.length === 0 ? (
<p className="text-sm text-gray-500 py-8 text-center">
.
</p>
) : (
<div className="space-y-2">
{reviews.map((r) => (
<button
key={r.id}
onClick={() => onSelectRestaurant(r.restaurant_id)}
className="w-full text-left border rounded-lg p-3 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center justify-between mb-1">
<span className="font-semibold text-sm truncate">
{r.restaurant_name || "알 수 없는 식당"}
</span>
<span className="text-yellow-500 text-sm shrink-0 ml-2">
{"★".repeat(Math.round(r.rating))}
<span className="text-gray-500 ml-1">{r.rating}</span>
</span>
</div>
{r.review_text && (
<p className="text-xs text-gray-600 line-clamp-2">
{r.review_text}
</p>
)}
<div className="flex items-center gap-2 mt-1.5 text-[10px] text-gray-400">
{r.visited_at && <span>: {r.visited_at}</span>}
{r.created_at && <span>{r.created_at.slice(0, 10)}</span>}
</div>
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { api, getToken } from "@/lib/api";
import type { Restaurant, VideoLink } from "@/lib/api";
import ReviewSection from "@/components/ReviewSection";
@@ -16,6 +16,8 @@ export default function RestaurantDetail({
}: RestaurantDetailProps) {
const [videos, setVideos] = useState<VideoLink[]>([]);
const [loading, setLoading] = useState(true);
const [favorited, setFavorited] = useState(false);
const [favLoading, setFavLoading] = useState(false);
useEffect(() => {
setLoading(true);
@@ -24,12 +26,53 @@ export default function RestaurantDetail({
.then(setVideos)
.catch(() => setVideos([]))
.finally(() => setLoading(false));
// Load favorite status if logged in
if (getToken()) {
api.getFavoriteStatus(restaurant.id)
.then((r) => setFavorited(r.favorited))
.catch(() => {});
}
}, [restaurant.id]);
const handleToggleFavorite = async () => {
if (!getToken()) return;
setFavLoading(true);
try {
const res = await api.toggleFavorite(restaurant.id);
setFavorited(res.favorited);
} catch { /* ignore */ }
finally { setFavLoading(false); }
};
return (
<div className="p-4 space-y-4">
<div className="flex justify-between items-start">
<h2 className="text-lg font-bold">{restaurant.name}</h2>
<div className="flex items-center gap-2">
<h2 className="text-lg font-bold">{restaurant.name}</h2>
{getToken() && (
<button
onClick={handleToggleFavorite}
disabled={favLoading}
className={`text-xl leading-none transition-colors ${
favorited ? "text-red-500" : "text-gray-300 hover:text-red-400"
}`}
title={favorited ? "찜 해제" : "찜하기"}
>
{favorited ? "♥" : "♡"}
</button>
)}
{restaurant.business_status === "CLOSED_PERMANENTLY" && (
<span className="px-2 py-0.5 bg-red-100 text-red-700 rounded text-xs font-semibold">
</span>
)}
{restaurant.business_status === "CLOSED_TEMPORARILY" && (
<span className="px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded text-xs font-semibold">
</span>
)}
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-xl leading-none"
@@ -38,6 +81,16 @@ export default function RestaurantDetail({
</button>
</div>
{restaurant.rating && (
<div className="flex items-center gap-2 text-sm">
<span className="text-yellow-500">{"★".repeat(Math.round(restaurant.rating))}</span>
<span className="font-medium">{restaurant.rating}</span>
{restaurant.rating_count && (
<span className="text-gray-400 text-xs">({restaurant.rating_count.toLocaleString()})</span>
)}
</div>
)}
<div className="space-y-1 text-sm">
{restaurant.cuisine_type && (
<p>
@@ -59,6 +112,26 @@ export default function RestaurantDetail({
<span className="text-gray-500">:</span> {restaurant.price_range}
</p>
)}
{restaurant.phone && (
<p>
<span className="text-gray-500">:</span>{" "}
<a href={`tel:${restaurant.phone}`} className="text-blue-600 hover:underline">
{restaurant.phone}
</a>
</p>
)}
{restaurant.google_place_id && (
<p>
<a
href={`https://www.google.com/maps/place/?q=place_id:${restaurant.google_place_id}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-xs"
>
Google Maps에서
</a>
</p>
)}
</div>
<div>
@@ -71,11 +144,23 @@ export default function RestaurantDetail({
<div className="space-y-3">
{videos.map((v) => (
<div key={v.video_id} className="border 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-red-50 text-red-600 rounded text-[10px] font-medium">
{v.channel_name}
</span>
)}
{v.published_at && (
<span className="text-[10px] text-gray-400">
{v.published_at.slice(0, 10)}
</span>
)}
</div>
<a
href={v.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-blue-600 hover:underline"
className="block text-sm font-medium text-blue-600 hover:underline"
>
{v.title}
</a>
@@ -107,6 +192,17 @@ export default function RestaurantDetail({
)}
</div>
{videos.length > 0 && (
<div className="bg-gray-50 rounded-lg px-4 py-3 text-center space-y-1">
<p className="text-xs text-gray-500">
.
</p>
<p className="text-xs text-gray-400">
!
</p>
</div>
)}
<ReviewSection restaurantId={restaurant.id} />
</div>
);

View File

@@ -37,6 +37,18 @@ export default function RestaurantList({
{r.region && <span>{r.region}</span>}
{r.price_range && <span>{r.price_range}</span>}
</div>
{r.channels && r.channels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{r.channels.map((ch) => (
<span
key={ch}
className="px-1.5 py-0.5 bg-red-50 text-red-600 rounded text-[10px] font-medium"
>
{ch}
</span>
))}
</div>
)}
</button>
))}
</div>

View File

@@ -19,18 +19,18 @@ export default function SearchBar({ onSearch, isLoading }: SearchBarProps) {
};
return (
<form onSubmit={handleSubmit} className="flex gap-2 items-center">
<form onSubmit={handleSubmit} className="flex gap-1.5 items-center">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="식당, 지역, 음식 종류..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
placeholder="식당, 지역, 음식..."
className="flex-1 min-w-0 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
/>
<select
value={mode}
onChange={(e) => setMode(e.target.value as typeof mode)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm bg-white"
className="shrink-0 px-2 py-2 border border-gray-300 rounded-lg text-sm bg-white"
>
<option value="hybrid"></option>
<option value="keyword"></option>
@@ -39,7 +39,7 @@ export default function SearchBar({ onSearch, isLoading }: SearchBarProps) {
<button
type="submit"
disabled={isLoading || !query.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 text-sm"
className="shrink-0 px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 text-sm"
>
{isLoading ? "..." : "검색"}
</button>

View File

@@ -1,4 +1,4 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "";
const TOKEN_KEY = "tasteby_token";
@@ -42,6 +42,12 @@ export interface Restaurant {
cuisine_type: string | null;
price_range: string | null;
google_place_id: string | null;
business_status: string | null;
rating: number | null;
rating_count: number | null;
phone: string | null;
website: string | null;
channels?: string[];
}
export interface VideoLink {
@@ -52,12 +58,48 @@ export interface VideoLink {
foods_mentioned: string[];
evaluation: Record<string, string>;
guests: string[];
channel_name: string | null;
channel_id: string | null;
}
export interface Channel {
id: string;
channel_id: string;
channel_name: string;
title_filter: string | null;
}
export interface Video {
id: string;
video_id: string;
title: string;
url: string;
status: string;
published_at: string | null;
channel_name: string;
has_transcript: boolean;
has_llm: boolean;
restaurant_count: number;
matched_count: number;
}
export interface VideoRestaurant {
restaurant_id: string;
name: string;
address: string | null;
cuisine_type: string | null;
price_range: string | null;
region: string | null;
foods_mentioned: string[];
evaluation: Record<string, string>;
guests: string[];
google_place_id: string | null;
has_location: boolean;
}
export interface VideoDetail extends Video {
transcript: string | null;
restaurants: VideoRestaurant[];
}
export interface User {
@@ -88,12 +130,14 @@ export const api = {
getRestaurants(params?: {
cuisine?: string;
region?: string;
channel?: string;
limit?: number;
offset?: number;
}) {
const sp = new URLSearchParams();
if (params?.cuisine) sp.set("cuisine", params.cuisine);
if (params?.region) sp.set("region", params.region);
if (params?.channel) sp.set("channel", params.channel);
if (params?.limit) sp.set("limit", String(params.limit));
if (params?.offset) sp.set("offset", String(params.offset));
const qs = sp.toString();
@@ -104,6 +148,19 @@ export const api = {
return fetchApi<Restaurant>(`/api/restaurants/${id}`);
},
updateRestaurant(id: string, data: Partial<Restaurant>) {
return fetchApi<{ ok: boolean }>(`/api/restaurants/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
},
deleteRestaurant(id: string) {
return fetchApi<{ ok: boolean }>(`/api/restaurants/${id}`, {
method: "DELETE",
});
},
getRestaurantVideos(id: string) {
return fetchApi<VideoLink[]>(`/api/restaurants/${id}/videos`);
},
@@ -129,6 +186,20 @@ export const api = {
return fetchApi<User>("/api/auth/me");
},
getFavoriteStatus(restaurantId: string) {
return fetchApi<{ favorited: boolean }>(`/api/restaurants/${restaurantId}/favorite`);
},
toggleFavorite(restaurantId: string) {
return fetchApi<{ favorited: boolean }>(`/api/restaurants/${restaurantId}/favorite`, {
method: "POST",
});
},
getMyFavorites() {
return fetchApi<Restaurant[]>("/api/users/me/favorites");
},
getReviews(restaurantId: string) {
return fetchApi<ReviewsResponse>(`/api/restaurants/${restaurantId}/reviews`);
},
@@ -158,4 +229,203 @@ export const api = {
method: "DELETE",
});
},
getMyReviews() {
return fetchApi<(Review & { restaurant_id: string; restaurant_name: string | null })[]>(
"/api/users/me/reviews?limit=100"
);
},
// Stats
recordVisit() {
return fetchApi<{ ok: boolean }>("/api/stats/visit", { method: "POST" });
},
getVisits() {
return fetchApi<{ today: number; total: number }>("/api/stats/visits");
},
// Admin - Users
getAdminUsers(params?: { limit?: number; offset?: number }) {
const sp = new URLSearchParams();
if (params?.limit) sp.set("limit", String(params.limit));
if (params?.offset) sp.set("offset", String(params.offset));
const qs = sp.toString();
return fetchApi<{
users: {
id: string;
email: string | null;
nickname: string | null;
avatar_url: string | null;
provider: string | null;
created_at: string | null;
favorite_count: number;
review_count: number;
}[];
total: number;
}>(`/api/admin/users${qs ? `?${qs}` : ""}`);
},
getAdminUserFavorites(userId: string) {
return fetchApi<
{
id: string;
name: string;
address: string | null;
region: string | null;
cuisine_type: string | null;
rating: number | null;
business_status: string | null;
created_at: string | null;
}[]
>(`/api/admin/users/${userId}/favorites`);
},
getAdminUserReviews(userId: string) {
return fetchApi<
{
id: string;
restaurant_id: string;
rating: number;
review_text: string | null;
visited_at: string | null;
created_at: string | null;
restaurant_name: string | null;
}[]
>(`/api/admin/users/${userId}/reviews`);
},
// Admin
addChannel(channelId: string, channelName: string, titleFilter?: string) {
return fetchApi<{ id: string; channel_id: string }>("/api/channels", {
method: "POST",
body: JSON.stringify({
channel_id: channelId,
channel_name: channelName,
title_filter: titleFilter || null,
}),
});
},
deleteChannel(channelId: string) {
return fetchApi<{ ok: boolean }>(`/api/channels/${channelId}`, {
method: "DELETE",
});
},
scanChannel(channelId: string, full: boolean = false) {
return fetchApi<{ total_fetched: number; new_videos: number }>(
`/api/channels/${channelId}/scan${full ? "?full=true" : ""}`,
{ method: "POST" }
);
},
getVideos(params?: { status?: string; limit?: number; offset?: number }) {
const sp = new URLSearchParams();
if (params?.status) sp.set("status", params.status);
if (params?.limit) sp.set("limit", String(params.limit));
if (params?.offset) sp.set("offset", String(params.offset));
const qs = sp.toString();
return fetchApi<Video[]>(`/api/videos${qs ? `?${qs}` : ""}`);
},
getVideoDetail(videoDbId: string) {
return fetchApi<VideoDetail>(`/api/videos/${videoDbId}`);
},
skipVideo(videoDbId: string) {
return fetchApi<{ ok: boolean }>(`/api/videos/${videoDbId}/skip`, {
method: "POST",
});
},
deleteVideo(videoDbId: string) {
return fetchApi<{ ok: boolean }>(`/api/videos/${videoDbId}`, {
method: "DELETE",
});
},
getExtractPrompt() {
return fetchApi<{ prompt: string }>("/api/videos/extract/prompt");
},
extractRestaurants(videoDbId: string, customPrompt?: string) {
return fetchApi<{ ok: boolean; restaurants_extracted: number }>(
`/api/videos/${videoDbId}/extract`,
{
method: "POST",
body: JSON.stringify(customPrompt ? { prompt: customPrompt } : {}),
}
);
},
fetchTranscript(videoDbId: string, mode: "auto" | "manual" | "generated" = "auto") {
return fetchApi<{ ok: boolean; length: number; source: string }>(
`/api/videos/${videoDbId}/fetch-transcript?mode=${mode}`,
{ method: "POST" }
);
},
triggerProcessing(limit: number = 5) {
return fetchApi<{ restaurants_extracted: number }>(
`/api/videos/process?limit=${limit}`,
{ method: "POST" }
);
},
getBulkExtractPending() {
return fetchApi<{ count: number; videos: { id: string; title: string }[] }>(
"/api/videos/bulk-extract/pending"
);
},
getBulkTranscriptPending() {
return fetchApi<{ count: number; videos: { id: string; title: string }[] }>(
"/api/videos/bulk-transcript/pending"
);
},
updateVideo(videoDbId: string, data: { title: string }) {
return fetchApi<{ ok: boolean }>(`/api/videos/${videoDbId}`, {
method: "PUT",
body: JSON.stringify(data),
});
},
addManualRestaurant(
videoDbId: string,
data: {
name: string;
address?: string;
region?: string;
cuisine_type?: string;
price_range?: string;
foods_mentioned?: string[];
evaluation?: string;
guests?: string[];
}
) {
return fetchApi<{ ok: boolean; restaurant_id: string; link_id: string }>(
`/api/videos/${videoDbId}/restaurants/manual`,
{ method: "POST", body: JSON.stringify(data) }
);
},
deleteVideoRestaurant(videoDbId: string, restaurantId: string) {
return fetchApi<{ ok: boolean }>(
`/api/videos/${videoDbId}/restaurants/${restaurantId}`,
{ method: "DELETE" }
);
},
updateVideoRestaurant(
videoDbId: string,
restaurantId: string,
data: Partial<VideoRestaurant>
) {
return fetchApi<{ ok: boolean }>(
`/api/videos/${videoDbId}/restaurants/${restaurantId}`,
{ method: "PUT", body: JSON.stringify(data) }
);
},
};