벌크 자막/추출 개선, 검색 필터 무시, geocoding 필드 수정, 네이버맵 링크
- 벌크 자막: 브라우저 우선 + API fallback, 광고 즉시 skip, 대기 시간 단축 - 벌크 자막/추출: 선택한 영상만 처리 가능 (체크박스 선택 후 실행) - 자막 실패 시 no_transcript 상태 마킹하여 재시도 방지 - 검색 시 필터 조건 무시 (채널/장르/가격/지역/영역 초기화) - 리셋 버튼 클릭 시 검색어 입력란 초기화 - RestaurantMapper updateFields에 google_place_id, rating 등 geocoding 필드 추가 - SearchMapper에 tabling_url, catchtable_url, phone, website 필드 추가 - 식당 상세에 네이버 지도 링크 추가 - YouTubeService.getTranscriptApi public 전환 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -393,35 +393,46 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
}
|
||||
};
|
||||
|
||||
const startBulkStream = async (mode: "transcript" | "extract") => {
|
||||
const startBulkStream = async (mode: "transcript" | "extract", ids?: string[]) => {
|
||||
const isTranscript = mode === "transcript";
|
||||
const setRunning = isTranscript ? setBulkTranscripting : setBulkExtracting;
|
||||
const hasSelection = ids && ids.length > 0;
|
||||
|
||||
try {
|
||||
const pending = isTranscript
|
||||
? await api.getBulkTranscriptPending()
|
||||
: await api.getBulkExtractPending();
|
||||
if (pending.count === 0) {
|
||||
alert(isTranscript ? "자막 없는 영상이 없습니다" : "추출 대기 중인 영상이 없습니다");
|
||||
return;
|
||||
let count: number;
|
||||
if (hasSelection) {
|
||||
count = ids.length;
|
||||
} else {
|
||||
const pending = isTranscript
|
||||
? await api.getBulkTranscriptPending()
|
||||
: await api.getBulkExtractPending();
|
||||
if (pending.count === 0) {
|
||||
alert(isTranscript ? "자막 없는 영상이 없습니다" : "추출 대기 중인 영상이 없습니다");
|
||||
return;
|
||||
}
|
||||
count = pending.count;
|
||||
}
|
||||
const msg = isTranscript
|
||||
? `자막 없는 영상 ${pending.count}개의 트랜스크립트를 수집하시겠습니까?\n(영상 당 5~15초 랜덤 딜레이)`
|
||||
: `LLM 추출이 안된 영상 ${pending.count}개를 벌크 처리하시겠습니까?\n(영상 당 3~8초 랜덤 딜레이)`;
|
||||
? `${hasSelection ? "선택한 " : "자막 없는 "}영상 ${count}개의 트랜스크립트를 수집하시겠습니까?`
|
||||
: `${hasSelection ? "선택한 " : "LLM 추출이 안된 "}영상 ${count}개를 벌크 처리하시겠습니까?`;
|
||||
if (!confirm(msg)) return;
|
||||
|
||||
setRunning(true);
|
||||
setBulkProgress({
|
||||
label: isTranscript ? "벌크 자막 수집" : "벌크 LLM 추출",
|
||||
total: pending.count, current: 0, currentTitle: "", results: [],
|
||||
total: count, current: 0, currentTitle: "", results: [],
|
||||
});
|
||||
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
const endpoint = isTranscript ? "/api/videos/bulk-transcript" : "/api/videos/bulk-extract";
|
||||
const token = typeof window !== "undefined" ? localStorage.getItem("tasteby_token") : null;
|
||||
const headers: Record<string, string> = {};
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
const resp = await fetch(`${apiBase}${endpoint}`, { method: "POST", headers });
|
||||
const resp = await fetch(`${apiBase}${endpoint}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: hasSelection ? JSON.stringify({ ids }) : undefined,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
alert(`벌크 요청 실패: ${resp.status} ${resp.statusText}`);
|
||||
setRunning(false);
|
||||
@@ -757,6 +768,20 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
)}
|
||||
{isAdmin && selected.size > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => startBulkStream("transcript", Array.from(selected))}
|
||||
disabled={bulkTranscripting || bulkExtracting}
|
||||
className="bg-orange-500 text-white px-4 py-2 rounded text-sm hover:bg-orange-600 disabled:opacity-50"
|
||||
>
|
||||
선택 자막 수집 ({selected.size})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => startBulkStream("extract", Array.from(selected))}
|
||||
disabled={bulkExtracting || bulkTranscripting}
|
||||
className="bg-purple-500 text-white px-4 py-2 rounded text-sm hover:bg-purple-600 disabled:opacity-50"
|
||||
>
|
||||
선택 LLM 추출 ({selected.size})
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBulkSkip}
|
||||
className="bg-gray-500 text-white px-4 py-2 rounded text-sm hover:bg-gray-600"
|
||||
|
||||
@@ -154,6 +154,8 @@ export default function Home() {
|
||||
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 [isSearchResult, setIsSearchResult] = useState(false);
|
||||
const [resetCount, setResetCount] = useState(0);
|
||||
const geoApplied = useRef(false);
|
||||
|
||||
const regionTree = useMemo(() => buildRegionTree(restaurants), [restaurants]);
|
||||
@@ -174,6 +176,13 @@ export default function Home() {
|
||||
const filteredRestaurants = useMemo(() => {
|
||||
const dist = (r: Restaurant) =>
|
||||
(r.latitude - userLoc.lat) ** 2 + (r.longitude - userLoc.lng) ** 2;
|
||||
if (isSearchResult) {
|
||||
return [...restaurants].sort((a, b) => {
|
||||
const da = dist(a), db = dist(b);
|
||||
if (da !== db) return da - db;
|
||||
return (b.rating || 0) - (a.rating || 0);
|
||||
});
|
||||
}
|
||||
return restaurants.filter((r) => {
|
||||
if (channelFilter && !(r.channels || []).includes(channelFilter)) return false;
|
||||
if (cuisineFilter && !matchCuisineFilter(r.cuisine_type, cuisineFilter)) return false;
|
||||
@@ -194,7 +203,7 @@ export default function Home() {
|
||||
if (da !== db) return da - db;
|
||||
return (b.rating || 0) - (a.rating || 0);
|
||||
});
|
||||
}, [restaurants, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]);
|
||||
}, [restaurants, isSearchResult, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]);
|
||||
|
||||
// Set desktop default to map mode on mount + get user location
|
||||
useEffect(() => {
|
||||
@@ -217,6 +226,7 @@ export default function Home() {
|
||||
// Load restaurants on mount and when channel filter changes
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setIsSearchResult(false);
|
||||
api
|
||||
.getRestaurants({ limit: 500, channel: channelFilter || undefined })
|
||||
.then(setRestaurants)
|
||||
@@ -253,6 +263,18 @@ export default function Home() {
|
||||
setRestaurants(results);
|
||||
setSelected(null);
|
||||
setShowDetail(false);
|
||||
setIsSearchResult(true);
|
||||
// 검색 시 필터 초기화
|
||||
setChannelFilter("");
|
||||
setCuisineFilter("");
|
||||
setPriceFilter("");
|
||||
setCountryFilter("");
|
||||
setCityFilter("");
|
||||
setDistrictFilter("");
|
||||
setBoundsFilterOn(false);
|
||||
// 검색 결과에 맞게 지도 이동
|
||||
const flyTo = computeFlyTo(results);
|
||||
if (flyTo) setRegionFlyTo(flyTo);
|
||||
} catch (e) {
|
||||
console.error("Search failed:", e);
|
||||
} finally {
|
||||
@@ -350,6 +372,8 @@ export default function Home() {
|
||||
setBoundsFilterOn(false);
|
||||
setShowFavorites(false);
|
||||
setShowMyReviews(false);
|
||||
setIsSearchResult(false);
|
||||
setResetCount((c) => c + 1);
|
||||
api
|
||||
.getRestaurants({ limit: 500 })
|
||||
.then((data) => {
|
||||
@@ -531,7 +555,7 @@ export default function Home() {
|
||||
{/* Row 1: Search + dropdown filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-96 shrink-0">
|
||||
<SearchBar onSearch={handleSearch} isLoading={loading} />
|
||||
<SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} />
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
@@ -717,7 +741,7 @@ export default function Home() {
|
||||
{/* ── Header row 2 (mobile only): search + toolbar ── */}
|
||||
<div className={`md:hidden px-4 pb-3 space-y-2 ${mobileTab === "favorites" || mobileTab === "profile" ? "hidden" : ""}`}>
|
||||
{/* Row 1: Search */}
|
||||
<SearchBar onSearch={handleSearch} isLoading={loading} />
|
||||
<SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} />
|
||||
{/* Row 2: Toolbar */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user