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

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