4 Commits

Author SHA1 Message Date
joungmin
2a0ee1d2cc 채널 카드 필터 UI, 캐시 초기화, 나라 필터 수정
- 채널 설명/태그 DB 컬럼 추가 및 백오피스 편집 기능
- 채널 드롭다운을 유튜브 아이콘 토글 카드로 변경 (데스크톱 최대 4개 표시, 스크롤)
- 모바일 홈탭 채널 카드 가로 스크롤
- region "나라" 값 필터 옵션에서 제외
- 관리자 캐시 초기화 버튼 및 API 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 16:10:21 +09:00
joungmin
0f985d52a9 벌크 자막/추출 개선, 검색 필터 무시, 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>
2026-03-11 09:00:40 +09:00
joungmin
cdee37e341 UI/UX 개선: 모바일 네비게이션, 로그인 모달, 지도 기능, 캐치테이블 연동
- 모바일 하단 네비게이션(홈/식당목록/내주변/찜/내정보) 추가
- 로그인 버튼을 모달 방식으로 변경 (소셜 로그인 확장 가능)
- 내위치 기반 정렬 및 영역 필터, 지도 내위치 버튼 추가
- 채널 필터 시 해당 채널만 마커/범례 표시
- 캐치테이블 검색/연동 (단건/벌크), NONE 저장 패턴
- 벌크 트랜스크립트 SSE (Playwright 브라우저 재사용)
- 테이블링/캐치테이블 버튼 UI 차별화
- Google Maps 링크 모바일 호환, 초기화 버튼, 검색 라벨 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 00:49:16 +09:00
joungmin
58c0f972e2 Add .DS_Store and cookies.txt to gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:31:37 +09:00
33 changed files with 2076 additions and 434 deletions

4
.gitignore vendored
View File

@@ -13,3 +13,7 @@ backend-java/.gradle/
# K8s secrets (never commit) # K8s secrets (never commit)
k8s/secrets.yaml k8s/secrets.yaml
# OS / misc
.DS_Store
backend/cookies.txt

View File

@@ -1,16 +1,29 @@
package com.tasteby.config; package com.tasteby.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.EventListener;
import javax.sql.DataSource;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
@Configuration @Configuration
public class DataSourceConfig { public class DataSourceConfig {
private static final Logger log = LoggerFactory.getLogger(DataSourceConfig.class);
@Value("${app.oracle.wallet-path:}") @Value("${app.oracle.wallet-path:}")
private String walletPath; private String walletPath;
private final DataSource dataSource;
public DataSourceConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
@PostConstruct @PostConstruct
public void configureWallet() { public void configureWallet() {
if (walletPath != null && !walletPath.isBlank()) { if (walletPath != null && !walletPath.isBlank()) {
@@ -18,4 +31,23 @@ public class DataSourceConfig {
System.setProperty("oracle.net.wallet_location", walletPath); System.setProperty("oracle.net.wallet_location", walletPath);
} }
} }
@EventListener(ApplicationReadyEvent.class)
public void runMigrations() {
migrate("ALTER TABLE restaurants ADD (tabling_url VARCHAR2(500))");
migrate("ALTER TABLE restaurants ADD (catchtable_url VARCHAR2(500))");
}
private void migrate(String sql) {
try (var conn = dataSource.getConnection(); var stmt = conn.createStatement()) {
stmt.execute(sql);
log.info("[MIGRATE] {}", sql);
} catch (Exception e) {
if (e.getMessage() != null && e.getMessage().contains("ORA-01430")) {
log.debug("[MIGRATE] already done: {}", sql);
} else {
log.warn("[MIGRATE] failed: {} - {}", sql, e.getMessage());
}
}
}
} }

View File

@@ -0,0 +1,25 @@
package com.tasteby.controller;
import com.tasteby.security.AuthUtil;
import com.tasteby.service.CacheService;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/admin")
public class AdminCacheController {
private final CacheService cacheService;
public AdminCacheController(CacheService cacheService) {
this.cacheService = cacheService;
}
@PostMapping("/cache-flush")
public Map<String, Object> flushCache() {
AuthUtil.requireAdmin();
cacheService.flush();
return Map.of("ok", true);
}
}

View File

@@ -6,6 +6,7 @@ import com.tasteby.domain.Channel;
import com.tasteby.security.AuthUtil; import com.tasteby.security.AuthUtil;
import com.tasteby.service.CacheService; import com.tasteby.service.CacheService;
import com.tasteby.service.ChannelService; import com.tasteby.service.ChannelService;
import com.tasteby.service.YouTubeService;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -18,11 +19,14 @@ import java.util.Map;
public class ChannelController { public class ChannelController {
private final ChannelService channelService; private final ChannelService channelService;
private final YouTubeService youtubeService;
private final CacheService cache; private final CacheService cache;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public ChannelController(ChannelService channelService, CacheService cache, ObjectMapper objectMapper) { public ChannelController(ChannelService channelService, YouTubeService youtubeService,
CacheService cache, ObjectMapper objectMapper) {
this.channelService = channelService; this.channelService = channelService;
this.youtubeService = youtubeService;
this.cache = cache; this.cache = cache;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@@ -60,6 +64,26 @@ public class ChannelController {
} }
} }
@PostMapping("/{channelId}/scan")
public Map<String, Object> scan(@PathVariable String channelId,
@RequestParam(defaultValue = "false") boolean full) {
AuthUtil.requireAdmin();
var result = youtubeService.scanChannel(channelId, full);
if (result == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Channel not found");
}
cache.flush();
return result;
}
@PutMapping("/{id}")
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, String> body) {
AuthUtil.requireAdmin();
channelService.update(id, body.get("description"), body.get("tags"));
cache.flush();
return Map.of("ok", true);
}
@DeleteMapping("/{channelId}") @DeleteMapping("/{channelId}")
public Map<String, Object> delete(@PathVariable String channelId) { public Map<String, Object> delete(@PathVariable String channelId) {
AuthUtil.requireAdmin(); AuthUtil.requireAdmin();

View File

@@ -2,27 +2,44 @@ package com.tasteby.controller;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.microsoft.playwright.*;
import com.tasteby.domain.Restaurant; import com.tasteby.domain.Restaurant;
import com.tasteby.security.AuthUtil; import com.tasteby.security.AuthUtil;
import com.tasteby.service.CacheService; import com.tasteby.service.CacheService;
import com.tasteby.service.GeocodingService;
import com.tasteby.service.RestaurantService; import com.tasteby.service.RestaurantService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;
@RestController @RestController
@RequestMapping("/api/restaurants") @RequestMapping("/api/restaurants")
public class RestaurantController { public class RestaurantController {
private static final Logger log = LoggerFactory.getLogger(RestaurantController.class);
private final RestaurantService restaurantService; private final RestaurantService restaurantService;
private final GeocodingService geocodingService;
private final CacheService cache; private final CacheService cache;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
public RestaurantController(RestaurantService restaurantService, CacheService cache, ObjectMapper objectMapper) { public RestaurantController(RestaurantService restaurantService, GeocodingService geocodingService, CacheService cache, ObjectMapper objectMapper) {
this.restaurantService = restaurantService; this.restaurantService = restaurantService;
this.geocodingService = geocodingService;
this.cache = cache; this.cache = cache;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@@ -68,11 +85,43 @@ public class RestaurantController {
AuthUtil.requireAdmin(); AuthUtil.requireAdmin();
var r = restaurantService.findById(id); var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found"); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
// Re-geocode if name or address changed
String newName = (String) body.get("name");
String newAddress = (String) body.get("address");
boolean nameChanged = newName != null && !newName.equals(r.getName());
boolean addressChanged = newAddress != null && !newAddress.equals(r.getAddress());
if (nameChanged || addressChanged) {
String geoName = newName != null ? newName : r.getName();
String geoAddr = newAddress != null ? newAddress : r.getAddress();
var geo = geocodingService.geocodeRestaurant(geoName, geoAddr);
if (geo != null) {
body.put("latitude", geo.get("latitude"));
body.put("longitude", geo.get("longitude"));
body.put("google_place_id", geo.get("google_place_id"));
if (geo.containsKey("formatted_address")) {
body.put("address", geo.get("formatted_address"));
}
if (geo.containsKey("rating")) body.put("rating", geo.get("rating"));
if (geo.containsKey("rating_count")) body.put("rating_count", geo.get("rating_count"));
if (geo.containsKey("phone")) body.put("phone", geo.get("phone"));
if (geo.containsKey("business_status")) body.put("business_status", geo.get("business_status"));
// formatted_address에서 region 파싱 (예: "대한민국 서울특별시 강남구 ..." → "한국|서울|강남구")
String addr = (String) geo.get("formatted_address");
if (addr != null) {
body.put("region", GeocodingService.parseRegionFromAddress(addr));
}
}
}
restaurantService.update(id, body); restaurantService.update(id, body);
cache.flush(); cache.flush();
return Map.of("ok", true); var updated = restaurantService.findById(id);
return Map.of("ok", true, "restaurant", updated);
} }
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public Map<String, Object> delete(@PathVariable String id) { public Map<String, Object> delete(@PathVariable String id) {
AuthUtil.requireAdmin(); AuthUtil.requireAdmin();
@@ -83,6 +132,232 @@ public class RestaurantController {
return Map.of("ok", true); return Map.of("ok", true);
} }
/** 단건 테이블링 URL 검색 */
@GetMapping("/{id}/tabling-search")
public List<Map<String, Object>> tablingSearch(@PathVariable String id) {
AuthUtil.requireAdmin();
var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
try (Playwright pw = Playwright.create()) {
try (Browser browser = launchBrowser(pw)) {
BrowserContext ctx = newContext(browser);
Page page = newPage(ctx);
return searchTabling(page, r.getName());
}
} catch (Exception e) {
log.error("[TABLING] Search failed for '{}': {}", r.getName(), e.getMessage());
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
}
}
/** 테이블링 미연결 식당 목록 */
@GetMapping("/tabling-pending")
public Map<String, Object> tablingPending() {
AuthUtil.requireAdmin();
var list = restaurantService.findWithoutTabling();
var summary = list.stream()
.map(r -> Map.of("id", (Object) r.getId(), "name", (Object) r.getName()))
.toList();
return Map.of("count", list.size(), "restaurants", summary);
}
/** 벌크 테이블링 검색 (SSE) */
@PostMapping("/bulk-tabling")
public SseEmitter bulkTabling() {
AuthUtil.requireAdmin();
SseEmitter emitter = new SseEmitter(600_000L);
executor.execute(() -> {
try {
var restaurants = restaurantService.findWithoutTabling();
int total = restaurants.size();
emit(emitter, Map.of("type", "start", "total", total));
if (total == 0) {
emit(emitter, Map.of("type", "complete", "total", 0, "linked", 0, "notFound", 0));
emitter.complete();
return;
}
int linked = 0;
int notFound = 0;
try (Playwright pw = Playwright.create()) {
try (Browser browser = launchBrowser(pw)) {
BrowserContext ctx = newContext(browser);
Page page = newPage(ctx);
for (int i = 0; i < total; i++) {
var r = restaurants.get(i);
emit(emitter, Map.of("type", "processing", "current", i + 1,
"total", total, "name", r.getName()));
try {
var results = searchTabling(page, r.getName());
if (!results.isEmpty()) {
String url = String.valueOf(results.get(0).get("url"));
String title = String.valueOf(results.get(0).get("title"));
restaurantService.update(r.getId(), Map.of("tabling_url", url));
linked++;
emit(emitter, Map.of("type", "done", "current", i + 1,
"name", r.getName(), "url", url, "title", title));
} else {
restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
notFound++;
emit(emitter, Map.of("type", "notfound", "current", i + 1,
"name", r.getName()));
}
} catch (Exception e) {
notFound++;
emit(emitter, Map.of("type", "error", "current", i + 1,
"name", r.getName(), "message", e.getMessage()));
}
// Google 봇 판정 방지 랜덤 딜레이 (5~15초)
int delay = ThreadLocalRandom.current().nextInt(5000, 15001);
log.info("[TABLING] Waiting {}ms before next search...", delay);
page.waitForTimeout(delay);
}
}
}
cache.flush();
emit(emitter, Map.of("type", "complete", "total", total, "linked", linked, "notFound", notFound));
emitter.complete();
} catch (Exception e) {
log.error("[TABLING] Bulk search error", e);
emitter.completeWithError(e);
}
});
return emitter;
}
/** 테이블링 URL 저장 */
@PutMapping("/{id}/tabling-url")
public Map<String, Object> setTablingUrl(@PathVariable String id, @RequestBody Map<String, String> body) {
AuthUtil.requireAdmin();
var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
String url = body.get("tabling_url");
restaurantService.update(id, Map.of("tabling_url", url != null ? url : ""));
cache.flush();
return Map.of("ok", true);
}
/** 단건 캐치테이블 URL 검색 */
@GetMapping("/{id}/catchtable-search")
public List<Map<String, Object>> catchtableSearch(@PathVariable String id) {
AuthUtil.requireAdmin();
var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
try (Playwright pw = Playwright.create()) {
try (Browser browser = launchBrowser(pw)) {
BrowserContext ctx = newContext(browser);
Page page = newPage(ctx);
return searchCatchtable(page, r.getName());
}
} catch (Exception e) {
log.error("[CATCHTABLE] Search failed for '{}': {}", r.getName(), e.getMessage());
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
}
}
/** 캐치테이블 미연결 식당 목록 */
@GetMapping("/catchtable-pending")
public Map<String, Object> catchtablePending() {
AuthUtil.requireAdmin();
var list = restaurantService.findWithoutCatchtable();
var summary = list.stream()
.map(r -> Map.of("id", (Object) r.getId(), "name", (Object) r.getName()))
.toList();
return Map.of("count", list.size(), "restaurants", summary);
}
/** 벌크 캐치테이블 검색 (SSE) */
@PostMapping("/bulk-catchtable")
public SseEmitter bulkCatchtable() {
AuthUtil.requireAdmin();
SseEmitter emitter = new SseEmitter(600_000L);
executor.execute(() -> {
try {
var restaurants = restaurantService.findWithoutCatchtable();
int total = restaurants.size();
emit(emitter, Map.of("type", "start", "total", total));
if (total == 0) {
emit(emitter, Map.of("type", "complete", "total", 0, "linked", 0, "notFound", 0));
emitter.complete();
return;
}
int linked = 0;
int notFound = 0;
try (Playwright pw = Playwright.create()) {
try (Browser browser = launchBrowser(pw)) {
BrowserContext ctx = newContext(browser);
Page page = newPage(ctx);
for (int i = 0; i < total; i++) {
var r = restaurants.get(i);
emit(emitter, Map.of("type", "processing", "current", i + 1,
"total", total, "name", r.getName()));
try {
var results = searchCatchtable(page, r.getName());
if (!results.isEmpty()) {
String url = String.valueOf(results.get(0).get("url"));
String title = String.valueOf(results.get(0).get("title"));
restaurantService.update(r.getId(), Map.of("catchtable_url", url));
linked++;
emit(emitter, Map.of("type", "done", "current", i + 1,
"name", r.getName(), "url", url, "title", title));
} else {
restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
notFound++;
emit(emitter, Map.of("type", "notfound", "current", i + 1,
"name", r.getName()));
}
} catch (Exception e) {
notFound++;
emit(emitter, Map.of("type", "error", "current", i + 1,
"name", r.getName(), "message", e.getMessage()));
}
int delay = ThreadLocalRandom.current().nextInt(5000, 15001);
log.info("[CATCHTABLE] Waiting {}ms before next search...", delay);
page.waitForTimeout(delay);
}
}
}
cache.flush();
emit(emitter, Map.of("type", "complete", "total", total, "linked", linked, "notFound", notFound));
emitter.complete();
} catch (Exception e) {
log.error("[CATCHTABLE] Bulk search error", e);
emitter.completeWithError(e);
}
});
return emitter;
}
/** 캐치테이블 URL 저장 */
@PutMapping("/{id}/catchtable-url")
public Map<String, Object> setCatchtableUrl(@PathVariable String id, @RequestBody Map<String, String> body) {
AuthUtil.requireAdmin();
var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
String url = body.get("catchtable_url");
restaurantService.update(id, Map.of("catchtable_url", url != null ? url : ""));
cache.flush();
return Map.of("ok", true);
}
@GetMapping("/{id}/videos") @GetMapping("/{id}/videos")
public List<Map<String, Object>> videos(@PathVariable String id) { public List<Map<String, Object>> videos(@PathVariable String id) {
String key = cache.makeKey("restaurant_videos", id); String key = cache.makeKey("restaurant_videos", id);
@@ -98,4 +373,127 @@ public class RestaurantController {
cache.set(key, result); cache.set(key, result);
return result; return result;
} }
// ─── Playwright helpers ──────────────────────────────────────────────
private Browser launchBrowser(Playwright pw) {
return pw.chromium().launch(new BrowserType.LaunchOptions()
.setHeadless(false)
.setArgs(List.of("--disable-blink-features=AutomationControlled")));
}
private BrowserContext newContext(Browser browser) {
return browser.newContext(new Browser.NewContextOptions()
.setLocale("ko-KR").setViewportSize(1280, 900));
}
private Page newPage(BrowserContext ctx) {
Page page = ctx.newPage();
page.addInitScript("Object.defineProperty(navigator, 'webdriver', {get: () => false})");
return page;
}
@SuppressWarnings("unchecked")
private List<Map<String, Object>> searchTabling(Page page, String restaurantName) {
String query = "site:tabling.co.kr " + restaurantName;
log.info("[TABLING] Searching: {}", query);
String searchUrl = "https://www.google.com/search?q=" +
URLEncoder.encode(query, StandardCharsets.UTF_8);
page.navigate(searchUrl);
page.waitForTimeout(3000);
Object linksObj = page.evaluate("""
() => {
const results = [];
const links = document.querySelectorAll('a[href]');
for (const a of links) {
const href = a.href;
if (href.includes('tabling.co.kr/restaurant/') || href.includes('tabling.co.kr/place/')) {
const title = a.closest('[data-header-feature]')?.querySelector('h3')?.textContent
|| a.querySelector('h3')?.textContent
|| a.textContent?.trim()?.substring(0, 80)
|| '';
results.push({ title, url: href });
}
}
const seen = new Set();
return results.filter(r => {
if (seen.has(r.url)) return false;
seen.add(r.url);
return true;
}).slice(0, 5);
}
""");
List<Map<String, Object>> results = new ArrayList<>();
if (linksObj instanceof List<?> list) {
for (var item : list) {
if (item instanceof Map<?, ?> map) {
results.add(Map.of(
"title", String.valueOf(map.get("title")),
"url", String.valueOf(map.get("url"))
));
}
}
}
log.info("[TABLING] Found {} results for '{}'", results.size(), restaurantName);
return results;
}
@SuppressWarnings("unchecked")
private List<Map<String, Object>> searchCatchtable(Page page, String restaurantName) {
String query = "site:app.catchtable.co.kr " + restaurantName;
log.info("[CATCHTABLE] Searching: {}", query);
String searchUrl = "https://www.google.com/search?q=" +
URLEncoder.encode(query, StandardCharsets.UTF_8);
page.navigate(searchUrl);
page.waitForTimeout(3000);
Object linksObj = page.evaluate("""
() => {
const results = [];
const links = document.querySelectorAll('a[href]');
for (const a of links) {
const href = a.href;
if (href.includes('catchtable.co.kr/') && (href.includes('/dining/') || href.includes('/shop/'))) {
const title = a.closest('[data-header-feature]')?.querySelector('h3')?.textContent
|| a.querySelector('h3')?.textContent
|| a.textContent?.trim()?.substring(0, 80)
|| '';
results.push({ title, url: href });
}
}
const seen = new Set();
return results.filter(r => {
if (seen.has(r.url)) return false;
seen.add(r.url);
return true;
}).slice(0, 5);
}
""");
List<Map<String, Object>> results = new ArrayList<>();
if (linksObj instanceof List<?> list) {
for (var item : list) {
if (item instanceof Map<?, ?> map) {
results.add(Map.of(
"title", String.valueOf(map.get("title")),
"url", String.valueOf(map.get("url"))
));
}
}
}
log.info("[CATCHTABLE] Found {} results for '{}'", results.size(), restaurantName);
return results;
}
private void emit(SseEmitter emitter, Map<String, Object> data) {
try {
emitter.send(SseEmitter.event().data(objectMapper.writeValueAsString(data)));
} catch (Exception e) {
log.debug("SSE emit error: {}", e.getMessage());
}
}
} }

View File

@@ -103,6 +103,24 @@ public class VideoController {
return Map.of("ok", true, "length", result.text().length(), "source", result.source()); return Map.of("ok", true, "length", result.text().length(), "source", result.source());
} }
/** 클라이언트(브라우저)에서 가져온 트랜스크립트를 저장 */
@PostMapping("/{id}/upload-transcript")
public Map<String, Object> uploadTranscript(@PathVariable String id,
@RequestBody Map<String, String> body) {
AuthUtil.requireAdmin();
var video = videoService.findDetail(id);
if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found");
String text = body.get("text");
if (text == null || text.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "text is required");
}
videoService.updateTranscript(id, text);
String source = body.getOrDefault("source", "browser");
return Map.of("ok", true, "length", text.length(), "source", source);
}
@GetMapping("/extract/prompt") @GetMapping("/extract/prompt")
public Map<String, Object> getExtractPrompt() { public Map<String, Object> getExtractPrompt() {
return Map.of("prompt", extractorService.getPrompt()); return Map.of("prompt", extractorService.getPrompt());
@@ -234,6 +252,34 @@ public class VideoController {
if (body.containsKey(key)) restFields.put(key, body.get(key)); if (body.containsKey(key)) restFields.put(key, body.get(key));
} }
if (!restFields.isEmpty()) { if (!restFields.isEmpty()) {
// Re-geocode if name or address changed
var existing = restaurantService.findById(restaurantId);
String newName = (String) restFields.get("name");
String newAddr = (String) restFields.get("address");
boolean nameChanged = newName != null && existing != null && !newName.equals(existing.getName());
boolean addrChanged = newAddr != null && existing != null && !newAddr.equals(existing.getAddress());
if (nameChanged || addrChanged) {
String geoName = newName != null ? newName : existing.getName();
String geoAddr = newAddr != null ? newAddr : existing.getAddress();
var geo = geocodingService.geocodeRestaurant(geoName, geoAddr);
if (geo != null) {
restFields.put("latitude", geo.get("latitude"));
restFields.put("longitude", geo.get("longitude"));
restFields.put("google_place_id", geo.get("google_place_id"));
if (geo.containsKey("formatted_address")) {
restFields.put("address", geo.get("formatted_address"));
}
if (geo.containsKey("rating")) restFields.put("rating", geo.get("rating"));
if (geo.containsKey("rating_count")) restFields.put("rating_count", geo.get("rating_count"));
if (geo.containsKey("phone")) restFields.put("phone", geo.get("phone"));
if (geo.containsKey("business_status")) restFields.put("business_status", geo.get("business_status"));
// Parse region from address
String addr = (String) geo.get("formatted_address");
if (addr != null) {
restFields.put("region", GeocodingService.parseRegionFromAddress(addr));
}
}
}
restaurantService.update(restaurantId, restFields); restaurantService.update(restaurantId, restFields);
} }

View File

@@ -13,6 +13,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.*; import java.util.*;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;
/** /**
* SSE streaming endpoints for bulk operations. * SSE streaming endpoints for bulk operations.
@@ -26,6 +27,7 @@ public class VideoSseController {
private final VideoService videoService; private final VideoService videoService;
private final RestaurantService restaurantService; private final RestaurantService restaurantService;
private final PipelineService pipelineService; private final PipelineService pipelineService;
private final YouTubeService youTubeService;
private final OciGenAiService genAi; private final OciGenAiService genAi;
private final CacheService cache; private final CacheService cache;
private final ObjectMapper mapper; private final ObjectMapper mapper;
@@ -34,27 +36,120 @@ public class VideoSseController {
public VideoSseController(VideoService videoService, public VideoSseController(VideoService videoService,
RestaurantService restaurantService, RestaurantService restaurantService,
PipelineService pipelineService, PipelineService pipelineService,
YouTubeService youTubeService,
OciGenAiService genAi, OciGenAiService genAi,
CacheService cache, CacheService cache,
ObjectMapper mapper) { ObjectMapper mapper) {
this.videoService = videoService; this.videoService = videoService;
this.restaurantService = restaurantService; this.restaurantService = restaurantService;
this.pipelineService = pipelineService; this.pipelineService = pipelineService;
this.youTubeService = youTubeService;
this.genAi = genAi; this.genAi = genAi;
this.cache = cache; this.cache = cache;
this.mapper = mapper; this.mapper = mapper;
} }
@PostMapping("/bulk-transcript") @PostMapping("/bulk-transcript")
public SseEmitter bulkTranscript() { public SseEmitter bulkTranscript(@RequestBody(required = false) Map<String, Object> body) {
AuthUtil.requireAdmin(); AuthUtil.requireAdmin();
SseEmitter emitter = new SseEmitter(600_000L); // 10 min timeout SseEmitter emitter = new SseEmitter(1_800_000L); // 30 min timeout
@SuppressWarnings("unchecked")
List<String> selectedIds = body != null && body.containsKey("ids")
? ((List<?>) body.get("ids")).stream().map(Object::toString).toList()
: null;
executor.execute(() -> { executor.execute(() -> {
try { try {
// TODO: Implement when transcript extraction is available in Java var videos = selectedIds != null && !selectedIds.isEmpty()
emit(emitter, Map.of("type", "start", "total", 0)); ? videoService.findVideosByIds(selectedIds)
emit(emitter, Map.of("type", "complete", "total", 0, "success", 0)); : videoService.findVideosWithoutTranscript();
int total = videos.size();
emit(emitter, Map.of("type", "start", "total", total));
if (total == 0) {
emit(emitter, Map.of("type", "complete", "total", 0, "success", 0, "failed", 0));
emitter.complete();
return;
}
int success = 0;
int failed = 0;
// Pass 1: 브라우저 우선 (봇 탐지 회피)
var apiNeeded = new ArrayList<Integer>();
try (var session = youTubeService.createBrowserSession()) {
for (int i = 0; i < total; i++) {
var v = videos.get(i);
String videoId = (String) v.get("video_id");
String title = (String) v.get("title");
String id = (String) v.get("id");
emit(emitter, Map.of("type", "processing", "index", i, "title", title, "method", "browser"));
try {
var result = youTubeService.getTranscriptWithPage(session.page(), videoId);
if (result != null) {
videoService.updateTranscript(id, result.text());
success++;
emit(emitter, Map.of("type", "done", "index", i,
"title", title, "source", result.source(),
"length", result.text().length()));
} else {
apiNeeded.add(i);
emit(emitter, Map.of("type", "skip", "index", i,
"title", title, "message", "브라우저 실패, API로 재시도 예정"));
}
} catch (Exception e) {
apiNeeded.add(i);
log.warn("[BULK-TRANSCRIPT] Browser failed for {}: {}", videoId, e.getMessage());
}
// 봇 판정 방지 랜덤 딜레이 (3~8초)
if (i < total - 1) {
int delay = ThreadLocalRandom.current().nextInt(3000, 8001);
log.info("[BULK-TRANSCRIPT] Waiting {}ms before next...", delay);
session.page().waitForTimeout(delay);
}
}
}
// Pass 2: 브라우저 실패분만 API로 재시도
if (!apiNeeded.isEmpty()) {
emit(emitter, Map.of("type", "api_pass", "count", apiNeeded.size()));
for (int i : apiNeeded) {
var v = videos.get(i);
String videoId = (String) v.get("video_id");
String title = (String) v.get("title");
String id = (String) v.get("id");
emit(emitter, Map.of("type", "processing", "index", i, "title", title, "method", "api"));
try {
var result = youTubeService.getTranscriptApi(videoId, "auto");
if (result != null) {
videoService.updateTranscript(id, result.text());
success++;
emit(emitter, Map.of("type", "done", "index", i,
"title", title, "source", result.source(),
"length", result.text().length()));
} else {
failed++;
videoService.updateStatus(id, "no_transcript");
emit(emitter, Map.of("type", "error", "index", i,
"title", title, "message", "자막을 찾을 수 없음"));
}
} catch (Exception e) {
failed++;
videoService.updateStatus(id, "no_transcript");
log.error("[BULK-TRANSCRIPT] API error for {}: {}", videoId, e.getMessage());
emit(emitter, Map.of("type", "error", "index", i,
"title", title, "message", e.getMessage()));
}
}
}
emit(emitter, Map.of("type", "complete", "total", total, "success", success, "failed", failed));
emitter.complete(); emitter.complete();
} catch (Exception e) { } catch (Exception e) {
log.error("Bulk transcript error", e); log.error("Bulk transcript error", e);
@@ -65,13 +160,20 @@ public class VideoSseController {
} }
@PostMapping("/bulk-extract") @PostMapping("/bulk-extract")
public SseEmitter bulkExtract() { public SseEmitter bulkExtract(@RequestBody(required = false) Map<String, Object> body) {
AuthUtil.requireAdmin(); AuthUtil.requireAdmin();
SseEmitter emitter = new SseEmitter(600_000L); SseEmitter emitter = new SseEmitter(600_000L);
@SuppressWarnings("unchecked")
List<String> selectedIds = body != null && body.containsKey("ids")
? ((List<?>) body.get("ids")).stream().map(Object::toString).toList()
: null;
executor.execute(() -> { executor.execute(() -> {
try { try {
var rows = videoService.findVideosForBulkExtract(); var rows = selectedIds != null && !selectedIds.isEmpty()
? videoService.findVideosForExtractByIds(selectedIds)
: videoService.findVideosForBulkExtract();
int total = rows.size(); int total = rows.size();
int totalRestaurants = 0; int totalRestaurants = 0;

View File

@@ -14,6 +14,8 @@ public class Channel {
private String channelId; private String channelId;
private String channelName; private String channelName;
private String titleFilter; private String titleFilter;
private String description;
private String tags;
private int videoCount; private int videoCount;
private String lastVideoAt; private String lastVideoAt;
} }

View File

@@ -24,6 +24,8 @@ public class Restaurant {
private String phone; private String phone;
private String website; private String website;
private String googlePlaceId; private String googlePlaceId;
private String tablingUrl;
private String catchtableUrl;
private String businessStatus; private String businessStatus;
private Double rating; private Double rating;
private Integer ratingCount; private Integer ratingCount;

View File

@@ -21,4 +21,8 @@ public interface ChannelMapper {
int deactivateById(@Param("id") String id); int deactivateById(@Param("id") String id);
Channel findByChannelId(@Param("channelId") String channelId); Channel findByChannelId(@Param("channelId") String channelId);
void updateDescriptionTags(@Param("id") String id,
@Param("description") String description,
@Param("tags") String tags);
} }

View File

@@ -55,6 +55,10 @@ public interface RestaurantMapper {
void updateFoodsMentioned(@Param("id") String id, @Param("foods") String foods); void updateFoodsMentioned(@Param("id") String id, @Param("foods") String foods);
List<Restaurant> findWithoutTabling();
List<Restaurant> findWithoutCatchtable();
List<Map<String, Object>> findForRemapCuisine(); List<Map<String, Object>> findForRemapCuisine();
List<Map<String, Object>> findForRemapFoods(); List<Map<String, Object>> findForRemapFoods();

View File

@@ -68,6 +68,10 @@ public interface VideoMapper {
List<Map<String, Object>> findVideosWithoutTranscript(); List<Map<String, Object>> findVideosWithoutTranscript();
List<Map<String, Object>> findVideosByIds(@Param("ids") List<String> ids);
List<Map<String, Object>> findVideosForExtractByIds(@Param("ids") List<String> ids);
void updateVideoRestaurantFields(@Param("videoId") String videoId, void updateVideoRestaurantFields(@Param("videoId") String videoId,
@Param("restaurantId") String restaurantId, @Param("restaurantId") String restaurantId,
@Param("foodsJson") String foodsJson, @Param("foodsJson") String foodsJson,

View File

@@ -38,4 +38,8 @@ public class ChannelService {
public Channel findByChannelId(String channelId) { public Channel findByChannelId(String channelId) {
return mapper.findByChannelId(channelId); return mapper.findByChannelId(channelId);
} }
public void update(String id, String description, String tags) {
mapper.updateDescriptionTags(id, description, tags);
}
} }

View File

@@ -131,6 +131,34 @@ public class GeocodingService {
} }
} }
/**
* Parse Korean address into region format "나라|시/도|구/군".
* Example: "대한민국 서울특별시 강남구 역삼동 123" → "한국|서울|강남구"
*/
public static String parseRegionFromAddress(String address) {
if (address == null || address.isBlank()) return null;
String[] parts = address.split("\\s+");
String country = "";
String city = "";
String district = "";
for (String p : parts) {
if (p.equals("대한민국") || p.equals("South Korea")) {
country = "한국";
} else if (p.endsWith("특별시") || p.endsWith("광역시") || p.endsWith("특별자치시")) {
city = p.replace("특별시", "").replace("광역시", "").replace("특별자치시", "");
} else if (p.endsWith("") && !p.endsWith("") && p.length() <= 5) {
city = p;
} else if (p.endsWith("") || p.endsWith("") || (p.endsWith("") && !city.isEmpty())) {
if (district.isEmpty()) district = p;
}
}
if (country.isEmpty() && !city.isEmpty()) country = "한국";
if (country.isEmpty()) return null;
return country + "|" + city + "|" + district;
}
private Map<String, Object> geocode(String query) { private Map<String, Object> geocode(String query) {
try { try {
String response = webClient.get() String response = webClient.get()

View File

@@ -16,6 +16,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -45,6 +46,8 @@ public class OciGenAiService {
private final ObjectMapper mapper; private final ObjectMapper mapper;
private ConfigFileAuthenticationDetailsProvider authProvider; private ConfigFileAuthenticationDetailsProvider authProvider;
private GenerativeAiInferenceClient chatClient;
private GenerativeAiInferenceClient embedClient;
public OciGenAiService(ObjectMapper mapper) { public OciGenAiService(ObjectMapper mapper) {
this.mapper = mapper; this.mapper = mapper;
@@ -55,45 +58,50 @@ public class OciGenAiService {
try { try {
ConfigFileReader.ConfigFile configFile = ConfigFileReader.parseDefault(); ConfigFileReader.ConfigFile configFile = ConfigFileReader.parseDefault();
authProvider = new ConfigFileAuthenticationDetailsProvider(configFile); authProvider = new ConfigFileAuthenticationDetailsProvider(configFile);
log.info("OCI GenAI auth configured"); chatClient = GenerativeAiInferenceClient.builder()
.endpoint(chatEndpoint).build(authProvider);
embedClient = GenerativeAiInferenceClient.builder()
.endpoint(embedEndpoint).build(authProvider);
log.info("OCI GenAI auth configured (clients initialized)");
} catch (Exception e) { } catch (Exception e) {
log.warn("OCI config not found, GenAI features disabled: {}", e.getMessage()); log.warn("OCI config not found, GenAI features disabled: {}", e.getMessage());
} }
} }
@PreDestroy
public void destroy() {
if (chatClient != null) chatClient.close();
if (embedClient != null) embedClient.close();
}
/** /**
* Call OCI GenAI LLM (Chat). * Call OCI GenAI LLM (Chat).
*/ */
public String chat(String prompt, int maxTokens) { public String chat(String prompt, int maxTokens) {
if (authProvider == null) throw new IllegalStateException("OCI GenAI not configured"); if (chatClient == null) throw new IllegalStateException("OCI GenAI not configured");
try (var client = GenerativeAiInferenceClient.builder() var textContent = TextContent.builder().text(prompt).build();
.endpoint(chatEndpoint) var userMessage = UserMessage.builder().content(List.of(textContent)).build();
.build(authProvider)) {
var textContent = TextContent.builder().text(prompt).build(); var chatRequest = GenericChatRequest.builder()
var userMessage = UserMessage.builder().content(List.of(textContent)).build(); .messages(List.of(userMessage))
.maxTokens(maxTokens)
.temperature(0.0)
.build();
var chatRequest = GenericChatRequest.builder() var chatDetails = ChatDetails.builder()
.messages(List.of(userMessage)) .compartmentId(compartmentId)
.maxTokens(maxTokens) .servingMode(OnDemandServingMode.builder().modelId(chatModelId).build())
.temperature(0.0) .chatRequest(chatRequest)
.build(); .build();
var chatDetails = ChatDetails.builder() ChatResponse response = chatClient.chat(
.compartmentId(compartmentId) ChatRequest.builder().chatDetails(chatDetails).build());
.servingMode(OnDemandServingMode.builder().modelId(chatModelId).build())
.chatRequest(chatRequest)
.build();
ChatResponse response = client.chat( var chatResult = (GenericChatResponse) response.getChatResult().getChatResponse();
ChatRequest.builder().chatDetails(chatDetails).build()); var choice = chatResult.getChoices().get(0);
var content = ((TextContent) choice.getMessage().getContent().get(0)).getText();
var chatResult = (GenericChatResponse) response.getChatResult().getChatResponse(); return content.trim();
var choice = chatResult.getChoices().get(0);
var content = ((TextContent) choice.getMessage().getContent().get(0)).getText();
return content.trim();
}
} }
/** /**
@@ -111,25 +119,22 @@ public class OciGenAiService {
} }
private List<List<Double>> embedBatch(List<String> texts) { private List<List<Double>> embedBatch(List<String> texts) {
try (var client = GenerativeAiInferenceClient.builder() if (embedClient == null) throw new IllegalStateException("OCI GenAI not configured");
.endpoint(embedEndpoint)
.build(authProvider)) {
var embedDetails = EmbedTextDetails.builder() var embedDetails = EmbedTextDetails.builder()
.inputs(texts) .inputs(texts)
.servingMode(OnDemandServingMode.builder().modelId(embedModelId).build()) .servingMode(OnDemandServingMode.builder().modelId(embedModelId).build())
.compartmentId(compartmentId) .compartmentId(compartmentId)
.inputType(EmbedTextDetails.InputType.SearchDocument) .inputType(EmbedTextDetails.InputType.SearchDocument)
.build(); .build();
EmbedTextResponse response = client.embedText( EmbedTextResponse response = embedClient.embedText(
EmbedTextRequest.builder().embedTextDetails(embedDetails).build()); EmbedTextRequest.builder().embedTextDetails(embedDetails).build());
return response.getEmbedTextResult().getEmbeddings() return response.getEmbedTextResult().getEmbeddings()
.stream() .stream()
.map(emb -> emb.stream().map(Number::doubleValue).toList()) .map(emb -> emb.stream().map(Number::doubleValue).toList())
.toList(); .toList();
}
} }
/** /**

View File

@@ -26,6 +26,14 @@ public class RestaurantService {
return restaurants; return restaurants;
} }
public List<Restaurant> findWithoutTabling() {
return mapper.findWithoutTabling();
}
public List<Restaurant> findWithoutCatchtable() {
return mapper.findWithoutCatchtable();
}
public Restaurant findById(String id) { public Restaurant findById(String id) {
Restaurant restaurant = mapper.findById(id); Restaurant restaurant = mapper.findById(id);
if (restaurant == null) return null; if (restaurant == null) return null;

View File

@@ -111,6 +111,22 @@ public class VideoService {
return rows.stream().map(JsonUtil::lowerKeys).toList(); return rows.stream().map(JsonUtil::lowerKeys).toList();
} }
public List<Map<String, Object>> findVideosByIds(List<String> ids) {
var rows = mapper.findVideosByIds(ids);
return rows.stream().map(JsonUtil::lowerKeys).toList();
}
public List<Map<String, Object>> findVideosForExtractByIds(List<String> ids) {
var rows = mapper.findVideosForExtractByIds(ids);
return rows.stream().map(row -> {
var r = JsonUtil.lowerKeys(row);
Object transcript = r.get("transcript_text");
r.put("transcript", JsonUtil.readClob(transcript));
r.remove("transcript_text");
return r;
}).toList();
}
public void updateVideoRestaurantFields(String videoId, String restaurantId, public void updateVideoRestaurantFields(String videoId, String restaurantId,
String foodsJson, String evaluation, String guestsJson) { String foodsJson, String evaluation, String guestsJson) {
mapper.updateVideoRestaurantFields(videoId, restaurantId, foodsJson, evaluation, guestsJson); mapper.updateVideoRestaurantFields(videoId, restaurantId, foodsJson, evaluation, guestsJson);

View File

@@ -50,10 +50,77 @@ public class YouTubeService {
} }
/** /**
* Fetch videos from a YouTube channel, page by page. * Fetch videos from a YouTube channel using the uploads playlist (UC→UU).
* Returns all pages merged into one list. * This returns ALL videos unlike the Search API which caps results.
* Falls back to Search API if playlist approach fails.
*/ */
public List<Map<String, Object>> fetchChannelVideos(String channelId, String publishedAfter, boolean excludeShorts) { public List<Map<String, Object>> fetchChannelVideos(String channelId, String publishedAfter, boolean excludeShorts) {
// Convert channel ID UC... → uploads playlist UU...
String uploadsPlaylistId = "UU" + channelId.substring(2);
List<Map<String, Object>> allVideos = new ArrayList<>();
String nextPage = null;
try {
do {
String pageToken = nextPage;
String response = webClient.get()
.uri(uriBuilder -> {
var b = uriBuilder.path("/playlistItems")
.queryParam("key", apiKey)
.queryParam("playlistId", uploadsPlaylistId)
.queryParam("part", "snippet")
.queryParam("maxResults", 50);
if (pageToken != null) b.queryParam("pageToken", pageToken);
return b.build();
})
.retrieve()
.bodyToMono(String.class)
.block(Duration.ofSeconds(30));
JsonNode data = mapper.readTree(response);
List<Map<String, Object>> pageVideos = new ArrayList<>();
for (JsonNode item : data.path("items")) {
JsonNode snippet = item.path("snippet");
String vid = snippet.path("resourceId").path("videoId").asText();
String publishedAt = snippet.path("publishedAt").asText();
// publishedAfter 필터: 이미 스캔한 영상 이후만
if (publishedAfter != null && publishedAt.compareTo(publishedAfter) <= 0) {
// 업로드 재생목록은 최신순이므로 이전 날짜 만나면 중단
nextPage = null;
break;
}
pageVideos.add(Map.of(
"video_id", vid,
"title", snippet.path("title").asText(),
"published_at", publishedAt,
"url", "https://www.youtube.com/watch?v=" + vid
));
}
if (excludeShorts && !pageVideos.isEmpty()) {
pageVideos = filterShorts(pageVideos);
}
allVideos.addAll(pageVideos);
if (nextPage != null || data.has("nextPageToken")) {
nextPage = data.has("nextPageToken") ? data.path("nextPageToken").asText() : null;
}
} while (nextPage != null);
} catch (Exception e) {
log.warn("PlaylistItems API failed for {}, falling back to Search API", channelId, e);
return fetchChannelVideosViaSearch(channelId, publishedAfter, excludeShorts);
}
return allVideos;
}
/**
* Fallback: fetch via Search API (may not return all videos).
*/
private List<Map<String, Object>> fetchChannelVideosViaSearch(String channelId, String publishedAfter, boolean excludeShorts) {
List<Map<String, Object>> allVideos = new ArrayList<>(); List<Map<String, Object>> allVideos = new ArrayList<>();
String nextPage = null; String nextPage = null;
@@ -98,7 +165,7 @@ public class YouTubeService {
nextPage = data.has("nextPageToken") ? data.path("nextPageToken").asText() : null; nextPage = data.has("nextPageToken") ? data.path("nextPageToken").asText() : null;
} catch (Exception e) { } catch (Exception e) {
log.error("Failed to parse YouTube API response", e); log.error("Failed to parse YouTube Search API response", e);
break; break;
} }
} while (nextPage != null); } while (nextPage != null);
@@ -108,33 +175,39 @@ public class YouTubeService {
/** /**
* Filter out YouTube Shorts (<=60s duration). * Filter out YouTube Shorts (<=60s duration).
* YouTube /videos API accepts max 50 IDs per request, so we batch.
*/ */
private List<Map<String, Object>> filterShorts(List<Map<String, Object>> videos) { private List<Map<String, Object>> filterShorts(List<Map<String, Object>> videos) {
String ids = String.join(",", videos.stream().map(v -> (String) v.get("video_id")).toList()); Map<String, Integer> durations = new HashMap<>();
String response = webClient.get() List<String> allIds = videos.stream().map(v -> (String) v.get("video_id")).toList();
.uri(uriBuilder -> uriBuilder.path("/videos")
.queryParam("key", apiKey)
.queryParam("id", ids)
.queryParam("part", "contentDetails")
.build())
.retrieve()
.bodyToMono(String.class)
.block(Duration.ofSeconds(30));
try { for (int i = 0; i < allIds.size(); i += 50) {
JsonNode data = mapper.readTree(response); List<String> batch = allIds.subList(i, Math.min(i + 50, allIds.size()));
Map<String, Integer> durations = new HashMap<>(); String ids = String.join(",", batch);
for (JsonNode item : data.path("items")) { try {
String duration = item.path("contentDetails").path("duration").asText(); String response = webClient.get()
durations.put(item.path("id").asText(), parseDuration(duration)); .uri(uriBuilder -> uriBuilder.path("/videos")
.queryParam("key", apiKey)
.queryParam("id", ids)
.queryParam("part", "contentDetails")
.build())
.retrieve()
.bodyToMono(String.class)
.block(Duration.ofSeconds(30));
JsonNode data = mapper.readTree(response);
for (JsonNode item : data.path("items")) {
String duration = item.path("contentDetails").path("duration").asText();
durations.put(item.path("id").asText(), parseDuration(duration));
}
} catch (Exception e) {
log.warn("Failed to fetch video durations for batch starting at {}", i, e);
} }
return videos.stream()
.filter(v -> durations.getOrDefault(v.get("video_id"), 0) > 60)
.toList();
} catch (Exception e) {
log.warn("Failed to filter shorts", e);
return videos;
} }
return videos.stream()
.filter(v -> durations.getOrDefault(v.get("video_id"), 61) > 60)
.toList();
} }
private int parseDuration(String dur) { private int parseDuration(String dur) {
@@ -208,16 +281,16 @@ public class YouTubeService {
public TranscriptResult getTranscript(String videoId, String mode) { public TranscriptResult getTranscript(String videoId, String mode) {
if (mode == null) mode = "auto"; if (mode == null) mode = "auto";
// 1) Fast path: youtube-transcript-api // 1) Playwright headed browser (봇 판정 회피)
TranscriptResult apiResult = getTranscriptApi(videoId, mode); TranscriptResult browserResult = getTranscriptBrowser(videoId);
if (apiResult != null) return apiResult; if (browserResult != null) return browserResult;
// 2) Fallback: Playwright browser // 2) Fallback: youtube-transcript-api
log.warn("API failed for {}, trying Playwright browser", videoId); log.warn("Browser failed for {}, trying API", videoId);
return getTranscriptBrowser(videoId); return getTranscriptApi(videoId, mode);
} }
private TranscriptResult getTranscriptApi(String videoId, String mode) { public TranscriptResult getTranscriptApi(String videoId, String mode) {
TranscriptList transcriptList; TranscriptList transcriptList;
try { try {
transcriptList = transcriptApi.listTranscripts(videoId); transcriptList = transcriptApi.listTranscripts(videoId);
@@ -262,163 +335,195 @@ public class YouTubeService {
} }
} }
// ─── Playwright browser fallback ─────────────────────────────────────────── // ─── Playwright browser ───────────────────────────────────────────────────
/**
* Fetch transcript using an existing Playwright Page (for bulk reuse).
*/
@SuppressWarnings("unchecked")
public TranscriptResult getTranscriptWithPage(Page page, String videoId) {
return fetchTranscriptFromPage(page, videoId);
}
/**
* Create a Playwright browser + context + page for transcript fetching.
* Caller must close the returned resources (Playwright, Browser).
*/
public record BrowserSession(Playwright playwright, Browser browser, Page page) implements AutoCloseable {
@Override
public void close() {
try { browser.close(); } catch (Exception ignored) {}
try { playwright.close(); } catch (Exception ignored) {}
}
}
public BrowserSession createBrowserSession() {
Playwright pw = Playwright.create();
Browser browser = pw.chromium().launch(new BrowserType.LaunchOptions()
.setHeadless(false)
.setArgs(List.of("--disable-blink-features=AutomationControlled")));
BrowserContext ctx = browser.newContext(new Browser.NewContextOptions()
.setLocale("ko-KR")
.setViewportSize(1280, 900));
loadCookies(ctx);
Page page = ctx.newPage();
page.addInitScript("Object.defineProperty(navigator, 'webdriver', {get: () => false})");
return new BrowserSession(pw, browser, page);
}
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private TranscriptResult getTranscriptBrowser(String videoId) { private TranscriptResult getTranscriptBrowser(String videoId) {
try (Playwright pw = Playwright.create()) { try (BrowserSession session = createBrowserSession()) {
BrowserType.LaunchOptions launchOpts = new BrowserType.LaunchOptions() return fetchTranscriptFromPage(session.page(), videoId);
.setHeadless(false)
.setArgs(List.of("--disable-blink-features=AutomationControlled"));
try (Browser browser = pw.chromium().launch(launchOpts)) {
Browser.NewContextOptions ctxOpts = new Browser.NewContextOptions()
.setLocale("ko-KR")
.setViewportSize(1280, 900);
BrowserContext ctx = browser.newContext(ctxOpts);
// Load YouTube cookies if available
loadCookies(ctx);
Page page = ctx.newPage();
// Hide webdriver flag to reduce bot detection
page.addInitScript("Object.defineProperty(navigator, 'webdriver', {get: () => false})");
log.info("[TRANSCRIPT] Opening YouTube page for {}", videoId);
page.navigate("https://www.youtube.com/watch?v=" + videoId,
new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED).setTimeout(30000));
page.waitForTimeout(5000);
// Skip ads if present
skipAds(page);
page.waitForTimeout(2000);
log.info("[TRANSCRIPT] Page loaded, looking for transcript button");
// Click "더보기" (expand description)
page.evaluate("""
() => {
const moreBtn = document.querySelector('tp-yt-paper-button#expand');
if (moreBtn) moreBtn.click();
}
""");
page.waitForTimeout(2000);
// Click transcript button
Object clicked = page.evaluate("""
() => {
// Method 1: aria-label
for (const label of ['스크립트 표시', 'Show transcript']) {
const btns = document.querySelectorAll(`button[aria-label="${label}"]`);
for (const b of btns) { b.click(); return 'aria-label: ' + label; }
}
// Method 2: text content
const allBtns = document.querySelectorAll('button');
for (const b of allBtns) {
const text = b.textContent.trim();
if (text === '스크립트 표시' || text === 'Show transcript') {
b.click();
return 'text: ' + text;
}
}
// Method 3: engagement panel buttons
const engBtns = document.querySelectorAll('ytd-button-renderer button, ytd-button-renderer a');
for (const b of engBtns) {
const text = b.textContent.trim().toLowerCase();
if (text.includes('transcript') || text.includes('스크립트')) {
b.click();
return 'engagement: ' + text;
}
}
return false;
}
""");
log.info("[TRANSCRIPT] Clicked transcript button: {}", clicked);
if (Boolean.FALSE.equals(clicked)) {
Object btnLabels = page.evaluate("""
() => {
const btns = document.querySelectorAll('button[aria-label]');
return Array.from(btns).map(b => b.getAttribute('aria-label')).slice(0, 30);
}
""");
log.warn("[TRANSCRIPT] Transcript button not found. Available buttons: {}", btnLabels);
return null;
}
// Wait for transcript segments to appear (max ~40s)
page.waitForTimeout(3000);
for (int attempt = 0; attempt < 12; attempt++) {
page.waitForTimeout(3000);
Object count = page.evaluate(
"() => document.querySelectorAll('ytd-transcript-segment-renderer').length");
int segCount = count instanceof Number n ? n.intValue() : 0;
log.info("[TRANSCRIPT] Wait {}s: {} segments", (attempt + 1) * 3 + 3, segCount);
if (segCount > 0) break;
}
// Select Korean if available
selectKorean(page);
// Scroll transcript panel and collect segments
Object segmentsObj = page.evaluate("""
async () => {
const container = document.querySelector(
'ytd-transcript-segment-list-renderer #segments-container, ' +
'ytd-transcript-renderer #body'
);
if (!container) {
const segs = document.querySelectorAll('ytd-transcript-segment-renderer');
return Array.from(segs).map(s => {
const txt = s.querySelector('.segment-text, yt-formatted-string.segment-text');
return txt ? txt.textContent.trim() : '';
}).filter(t => t);
}
let prevCount = 0;
for (let i = 0; i < 50; i++) {
container.scrollTop = container.scrollHeight;
await new Promise(r => setTimeout(r, 300));
const segs = document.querySelectorAll('ytd-transcript-segment-renderer');
if (segs.length === prevCount && i > 3) break;
prevCount = segs.length;
}
const segs = document.querySelectorAll('ytd-transcript-segment-renderer');
return Array.from(segs).map(s => {
const txt = s.querySelector('.segment-text, yt-formatted-string.segment-text');
return txt ? txt.textContent.trim() : '';
}).filter(t => t);
}
""");
if (segmentsObj instanceof List<?> segments && !segments.isEmpty()) {
String text = segments.stream()
.map(Object::toString)
.collect(Collectors.joining(" "));
log.info("[TRANSCRIPT] Browser success: {} chars from {} segments", text.length(), segments.size());
return new TranscriptResult(text, "browser");
}
log.warn("[TRANSCRIPT] No segments found via browser for {}", videoId);
return null;
}
} catch (Exception e) { } catch (Exception e) {
log.error("[TRANSCRIPT] Playwright failed for {}: {}", videoId, e.getMessage()); log.error("[TRANSCRIPT] Playwright failed for {}: {}", videoId, e.getMessage());
return null; return null;
} }
} }
@SuppressWarnings("unchecked")
private TranscriptResult fetchTranscriptFromPage(Page page, String videoId) {
try {
log.info("[TRANSCRIPT] Opening YouTube page for {}", videoId);
page.navigate("https://www.youtube.com/watch?v=" + videoId,
new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED).setTimeout(30000));
page.waitForTimeout(3000);
skipAds(page);
page.waitForTimeout(1000);
log.info("[TRANSCRIPT] Page loaded, looking for transcript button");
// Click "더보기" (expand description)
page.evaluate("""
() => {
const moreBtn = document.querySelector('tp-yt-paper-button#expand');
if (moreBtn) moreBtn.click();
}
""");
page.waitForTimeout(2000);
// Click transcript button
Object clicked = page.evaluate("""
() => {
// Method 1: aria-label
for (const label of ['스크립트 표시', 'Show transcript']) {
const btns = document.querySelectorAll(`button[aria-label="${label}"]`);
for (const b of btns) { b.click(); return 'aria-label: ' + label; }
}
// Method 2: text content
const allBtns = document.querySelectorAll('button');
for (const b of allBtns) {
const text = b.textContent.trim();
if (text === '스크립트 표시' || text === 'Show transcript') {
b.click();
return 'text: ' + text;
}
}
// Method 3: engagement panel buttons
const engBtns = document.querySelectorAll('ytd-button-renderer button, ytd-button-renderer a');
for (const b of engBtns) {
const text = b.textContent.trim().toLowerCase();
if (text.includes('transcript') || text.includes('스크립트')) {
b.click();
return 'engagement: ' + text;
}
}
return false;
}
""");
log.info("[TRANSCRIPT] Clicked transcript button: {}", clicked);
if (Boolean.FALSE.equals(clicked)) {
Object btnLabels = page.evaluate("""
() => {
const btns = document.querySelectorAll('button[aria-label]');
return Array.from(btns).map(b => b.getAttribute('aria-label')).slice(0, 30);
}
""");
log.warn("[TRANSCRIPT] Transcript button not found. Available buttons: {}", btnLabels);
return null;
}
// Wait for transcript segments to appear (max ~15s)
page.waitForTimeout(2000);
for (int attempt = 0; attempt < 10; attempt++) {
page.waitForTimeout(1500);
Object count = page.evaluate(
"() => document.querySelectorAll('ytd-transcript-segment-renderer').length");
int segCount = count instanceof Number n ? n.intValue() : 0;
log.info("[TRANSCRIPT] Wait {}s: {} segments", (attempt + 1) * 1.5 + 2, segCount);
if (segCount > 0) break;
}
selectKorean(page);
// Scroll transcript panel and collect segments
Object segmentsObj = page.evaluate("""
async () => {
const container = document.querySelector(
'ytd-transcript-segment-list-renderer #segments-container, ' +
'ytd-transcript-renderer #body'
);
if (!container) {
const segs = document.querySelectorAll('ytd-transcript-segment-renderer');
return Array.from(segs).map(s => {
const txt = s.querySelector('.segment-text, yt-formatted-string.segment-text');
return txt ? txt.textContent.trim() : '';
}).filter(t => t);
}
let prevCount = 0;
for (let i = 0; i < 50; i++) {
container.scrollTop = container.scrollHeight;
await new Promise(r => setTimeout(r, 300));
const segs = document.querySelectorAll('ytd-transcript-segment-renderer');
if (segs.length === prevCount && i > 3) break;
prevCount = segs.length;
}
const segs = document.querySelectorAll('ytd-transcript-segment-renderer');
return Array.from(segs).map(s => {
const txt = s.querySelector('.segment-text, yt-formatted-string.segment-text');
return txt ? txt.textContent.trim() : '';
}).filter(t => t);
}
""");
if (segmentsObj instanceof List<?> segments && !segments.isEmpty()) {
String text = segments.stream()
.map(Object::toString)
.collect(Collectors.joining(" "));
log.info("[TRANSCRIPT] Browser success: {} chars from {} segments", text.length(), segments.size());
return new TranscriptResult(text, "browser");
}
log.warn("[TRANSCRIPT] No segments found via browser for {}", videoId);
return null;
} catch (Exception e) {
log.error("[TRANSCRIPT] Page fetch failed for {}: {}", videoId, e.getMessage());
return null;
}
}
private void skipAds(Page page) { private void skipAds(Page page) {
for (int i = 0; i < 12; i++) { for (int i = 0; i < 30; i++) {
Object adStatus = page.evaluate(""" Object adStatus = page.evaluate("""
() => { () => {
const skipBtn = document.querySelector('.ytp-skip-ad-button, .ytp-ad-skip-button, .ytp-ad-skip-button-modern, button.ytp-ad-skip-button-modern'); const skipBtn = document.querySelector('.ytp-skip-ad-button, .ytp-ad-skip-button, .ytp-ad-skip-button-modern, button.ytp-ad-skip-button-modern');
if (skipBtn) { skipBtn.click(); return 'skipped'; } if (skipBtn) { skipBtn.click(); return 'skipped'; }
const adOverlay = document.querySelector('.ytp-ad-player-overlay, .ad-showing'); const adOverlay = document.querySelector('.ytp-ad-player-overlay, .ad-showing');
if (adOverlay) return 'playing'; if (adOverlay) {
// 광고 중: 뮤트 + 끝으로 이동 시도
const video = document.querySelector('video');
if (video) {
video.muted = true;
if (video.duration && isFinite(video.duration)) {
video.currentTime = video.duration;
}
}
return 'playing';
}
const adBadge = document.querySelector('.ytp-ad-text'); const adBadge = document.querySelector('.ytp-ad-text');
if (adBadge && adBadge.textContent) return 'badge'; if (adBadge && adBadge.textContent) return 'badge';
return 'none'; return 'none';
@@ -428,10 +533,10 @@ public class YouTubeService {
if ("none".equals(status)) break; if ("none".equals(status)) break;
log.info("[TRANSCRIPT] Ad detected: {}, waiting...", status); log.info("[TRANSCRIPT] Ad detected: {}, waiting...", status);
if ("skipped".equals(status)) { if ("skipped".equals(status)) {
page.waitForTimeout(2000); page.waitForTimeout(1000);
break; break;
} }
page.waitForTimeout(5000); page.waitForTimeout(1000);
} }
} }

View File

@@ -39,7 +39,7 @@ app:
expiration-days: 7 expiration-days: 7
cors: cors:
allowed-origins: http://localhost:3000,http://localhost:3001,https://www.tasteby.net,https://tasteby.net allowed-origins: http://localhost:3000,http://localhost:3001,https://www.tasteby.net,https://tasteby.net,https://dev.tasteby.net
oracle: oracle:
wallet-path: ${ORACLE_WALLET:} wallet-path: ${ORACLE_WALLET:}

View File

@@ -7,12 +7,14 @@
<result property="channelId" column="channel_id"/> <result property="channelId" column="channel_id"/>
<result property="channelName" column="channel_name"/> <result property="channelName" column="channel_name"/>
<result property="titleFilter" column="title_filter"/> <result property="titleFilter" column="title_filter"/>
<result property="description" column="description"/>
<result property="tags" column="tags"/>
<result property="videoCount" column="video_count"/> <result property="videoCount" column="video_count"/>
<result property="lastVideoAt" column="last_video_at"/> <result property="lastVideoAt" column="last_video_at"/>
</resultMap> </resultMap>
<select id="findAllActive" resultMap="channelResultMap"> <select id="findAllActive" resultMap="channelResultMap">
SELECT c.id, c.channel_id, c.channel_name, c.title_filter, SELECT c.id, c.channel_id, c.channel_name, c.title_filter, c.description, c.tags,
(SELECT COUNT(*) FROM videos v WHERE v.channel_id = c.id) AS video_count, (SELECT COUNT(*) FROM videos v WHERE v.channel_id = c.id) AS video_count,
(SELECT MAX(v.published_at) FROM videos v WHERE v.channel_id = c.id) AS last_video_at (SELECT MAX(v.published_at) FROM videos v WHERE v.channel_id = c.id) AS last_video_at
FROM channels c FROM channels c
@@ -35,6 +37,11 @@
WHERE id = #{id} AND is_active = 1 WHERE id = #{id} AND is_active = 1
</update> </update>
<update id="updateDescriptionTags">
UPDATE channels SET description = #{description}, tags = #{tags}
WHERE id = #{id}
</update>
<select id="findByChannelId" resultMap="channelResultMap"> <select id="findByChannelId" resultMap="channelResultMap">
SELECT id, channel_id, channel_name, title_filter SELECT id, channel_id, channel_name, title_filter
FROM channels FROM channels

View File

@@ -16,6 +16,8 @@
<result property="phone" column="phone"/> <result property="phone" column="phone"/>
<result property="website" column="website"/> <result property="website" column="website"/>
<result property="googlePlaceId" column="google_place_id"/> <result property="googlePlaceId" column="google_place_id"/>
<result property="tablingUrl" column="tabling_url"/>
<result property="catchtableUrl" column="catchtable_url"/>
<result property="businessStatus" column="business_status"/> <result property="businessStatus" column="business_status"/>
<result property="rating" column="rating"/> <result property="rating" column="rating"/>
<result property="ratingCount" column="rating_count"/> <result property="ratingCount" column="rating_count"/>
@@ -26,7 +28,7 @@
<select id="findAll" resultMap="restaurantMap"> <select id="findAll" resultMap="restaurantMap">
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude, SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
r.cuisine_type, r.price_range, r.google_place_id, r.cuisine_type, r.price_range, r.google_place_id, r.tabling_url, r.catchtable_url,
r.business_status, r.rating, r.rating_count, r.updated_at r.business_status, r.rating, r.rating_count, r.updated_at
FROM restaurants r FROM restaurants r
<if test="channel != null and channel != ''"> <if test="channel != null and channel != ''">
@@ -54,7 +56,7 @@
<select id="findById" resultMap="restaurantMap"> <select id="findById" resultMap="restaurantMap">
SELECT r.id, r.name, r.address, r.region, r.latitude, r.longitude, SELECT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
r.cuisine_type, r.price_range, r.phone, r.website, r.google_place_id, r.cuisine_type, r.price_range, r.phone, r.website, r.google_place_id,
r.business_status, r.rating, r.rating_count r.tabling_url, r.catchtable_url, r.business_status, r.rating, r.rating_count
FROM restaurants r FROM restaurants r
WHERE r.id = #{id} WHERE r.id = #{id}
</select> </select>
@@ -129,12 +131,30 @@
<if test="fields.containsKey('website')"> <if test="fields.containsKey('website')">
website = #{fields.website}, website = #{fields.website},
</if> </if>
<if test="fields.containsKey('tabling_url')">
tabling_url = #{fields.tabling_url},
</if>
<if test="fields.containsKey('catchtable_url')">
catchtable_url = #{fields.catchtable_url},
</if>
<if test="fields.containsKey('latitude')"> <if test="fields.containsKey('latitude')">
latitude = #{fields.latitude}, latitude = #{fields.latitude},
</if> </if>
<if test="fields.containsKey('longitude')"> <if test="fields.containsKey('longitude')">
longitude = #{fields.longitude}, longitude = #{fields.longitude},
</if> </if>
<if test="fields.containsKey('google_place_id')">
google_place_id = #{fields.google_place_id},
</if>
<if test="fields.containsKey('business_status')">
business_status = #{fields.business_status},
</if>
<if test="fields.containsKey('rating')">
rating = #{fields.rating},
</if>
<if test="fields.containsKey('rating_count')">
rating_count = #{fields.rating_count},
</if>
updated_at = SYSTIMESTAMP, updated_at = SYSTIMESTAMP,
</trim> </trim>
WHERE id = #{id} WHERE id = #{id}
@@ -201,6 +221,24 @@
</foreach> </foreach>
</select> </select>
<select id="findWithoutTabling" resultMap="restaurantMap">
SELECT r.id, r.name, r.address, r.region
FROM restaurants r
WHERE r.tabling_url IS NULL
AND r.latitude IS NOT NULL
AND EXISTS (SELECT 1 FROM video_restaurants vr WHERE vr.restaurant_id = r.id)
ORDER BY r.name
</select>
<select id="findWithoutCatchtable" resultMap="restaurantMap">
SELECT r.id, r.name, r.address, r.region
FROM restaurants r
WHERE r.catchtable_url IS NULL
AND r.latitude IS NOT NULL
AND EXISTS (SELECT 1 FROM video_restaurants vr WHERE vr.restaurant_id = r.id)
ORDER BY r.name
</select>
<!-- ===== Remap operations ===== --> <!-- ===== Remap operations ===== -->
<update id="updateCuisineType"> <update id="updateCuisineType">

View File

@@ -11,7 +11,11 @@
<result property="longitude" column="longitude"/> <result property="longitude" column="longitude"/>
<result property="cuisineType" column="cuisine_type"/> <result property="cuisineType" column="cuisine_type"/>
<result property="priceRange" column="price_range"/> <result property="priceRange" column="price_range"/>
<result property="phone" column="phone"/>
<result property="website" column="website"/>
<result property="googlePlaceId" column="google_place_id"/> <result property="googlePlaceId" column="google_place_id"/>
<result property="tablingUrl" column="tabling_url"/>
<result property="catchtableUrl" column="catchtable_url"/>
<result property="businessStatus" column="business_status"/> <result property="businessStatus" column="business_status"/>
<result property="rating" column="rating"/> <result property="rating" column="rating"/>
<result property="ratingCount" column="rating_count"/> <result property="ratingCount" column="rating_count"/>
@@ -19,7 +23,8 @@
<select id="keywordSearch" resultMap="restaurantMap"> <select id="keywordSearch" resultMap="restaurantMap">
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude, SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
r.cuisine_type, r.price_range, r.google_place_id, r.cuisine_type, r.price_range, r.phone, r.website, r.google_place_id,
r.tabling_url, r.catchtable_url,
r.business_status, r.rating, r.rating_count r.business_status, r.rating, r.rating_count
FROM restaurants r FROM restaurants r
JOIN video_restaurants vr ON vr.restaurant_id = r.id JOIN video_restaurants vr ON vr.restaurant_id = r.id

View File

@@ -186,7 +186,8 @@
<insert id="insertVideo"> <insert id="insertVideo">
INSERT INTO videos (id, channel_id, video_id, title, url, published_at) INSERT INTO videos (id, channel_id, video_id, title, url, published_at)
VALUES (#{id}, #{channelId}, #{videoId}, #{title}, #{url}, #{publishedAt}) VALUES (#{id}, #{channelId}, #{videoId}, #{title}, #{url},
TO_TIMESTAMP(#{publishedAt}, 'YYYY-MM-DD"T"HH24:MI:SS"Z"'))
</insert> </insert>
<select id="getExistingVideoIds" resultType="string"> <select id="getExistingVideoIds" resultType="string">
@@ -194,7 +195,7 @@
</select> </select>
<select id="getLatestVideoDate" resultType="string"> <select id="getLatestVideoDate" resultType="string">
SELECT TO_CHAR(MAX(published_at), 'YYYY-MM-DD"T"HH24:MI:SS"Z"') SELECT TO_CHAR(MAX(published_at), 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS latest_date
FROM videos WHERE channel_id = #{channelId} FROM videos WHERE channel_id = #{channelId}
</select> </select>
@@ -220,10 +221,30 @@
SELECT id, video_id, title, url SELECT id, video_id, title, url
FROM videos FROM videos
WHERE (transcript_text IS NULL OR dbms_lob.getlength(transcript_text) = 0) WHERE (transcript_text IS NULL OR dbms_lob.getlength(transcript_text) = 0)
AND status != 'skip' AND status NOT IN ('skip', 'no_transcript')
ORDER BY created_at ORDER BY created_at
</select> </select>
<select id="findVideosByIds" resultType="map">
SELECT id, video_id, title, url
FROM videos
WHERE id IN
<foreach item="id" collection="ids" open="(" separator="," close=")">
#{id}
</foreach>
ORDER BY created_at
</select>
<select id="findVideosForExtractByIds" resultType="map">
SELECT v.id, v.video_id, v.title, v.url, v.transcript_text
FROM videos v
WHERE v.id IN
<foreach item="id" collection="ids" open="(" separator="," close=")">
#{id}
</foreach>
ORDER BY v.published_at DESC
</select>
<update id="updateVideoRestaurantFields"> <update id="updateVideoRestaurantFields">
UPDATE video_restaurants UPDATE video_restaurants
SET foods_mentioned = #{foodsJson,jdbcType=CLOB}, SET foods_mentioned = #{foodsJson,jdbcType=CLOB},

View File

@@ -5,5 +5,6 @@
<setting name="mapUnderscoreToCamelCase" value="true"/> <setting name="mapUnderscoreToCamelCase" value="true"/>
<setting name="callSettersOnNulls" value="true"/> <setting name="callSettersOnNulls" value="true"/>
<setting name="returnInstanceForEmptyRow" value="true"/> <setting name="returnInstanceForEmptyRow" value="true"/>
<setting name="jdbcTypeForNull" value="VARCHAR"/>
</settings> </settings>
</configuration> </configuration>

View File

@@ -7,6 +7,33 @@ import { useAuth } from "@/lib/auth-context";
type Tab = "channels" | "videos" | "restaurants" | "users" | "daemon"; type Tab = "channels" | "videos" | "restaurants" | "users" | "daemon";
function CacheFlushButton() {
const [flushing, setFlushing] = useState(false);
const handleFlush = async () => {
if (!confirm("Redis 캐시를 초기화하시겠습니까?")) return;
setFlushing(true);
try {
await api.flushCache();
alert("캐시가 초기화되었습니다.");
} catch (e) {
alert("캐시 초기화 실패: " + (e instanceof Error ? e.message : e));
} finally {
setFlushing(false);
}
};
return (
<button
onClick={handleFlush}
disabled={flushing}
className="px-3 py-1.5 text-xs bg-red-50 text-red-600 border border-red-200 rounded-lg hover:bg-red-100 disabled:opacity-50 transition-colors"
>
{flushing ? "초기화 중..." : "🗑 캐시 초기화"}
</button>
);
}
export default function AdminPage() { export default function AdminPage() {
const [tab, setTab] = useState<Tab>("channels"); const [tab, setTab] = useState<Tab>("channels");
const { user, isLoading } = useAuth(); const { user, isLoading } = useAuth();
@@ -38,9 +65,12 @@ export default function AdminPage() {
<span className="px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded text-xs font-medium"> </span> <span className="px-2 py-0.5 bg-yellow-100 text-yellow-700 rounded text-xs font-medium"> </span>
)} )}
</div> </div>
<a href="/" className="text-sm text-blue-600 hover:underline"> <div className="flex items-center gap-3">
&larr; {isAdmin && <CacheFlushButton />}
</a> <a href="/" className="text-sm text-blue-600 hover:underline">
&larr;
</a>
</div>
</div> </div>
<nav className="mt-3 flex gap-1"> <nav className="mt-3 flex gap-1">
{(["channels", "videos", "restaurants", "users", "daemon"] as Tab[]).map((t) => ( {(["channels", "videos", "restaurants", "users", "daemon"] as Tab[]).map((t) => (
@@ -101,6 +131,20 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
} }
}; };
const [editingChannel, setEditingChannel] = useState<string | null>(null);
const [editDesc, setEditDesc] = useState("");
const [editTags, setEditTags] = useState("");
const handleSaveChannel = async (id: string) => {
try {
await api.updateChannel(id, { description: editDesc, tags: editTags });
setEditingChannel(null);
load();
} catch {
alert("채널 수정 실패");
}
};
const handleDelete = async (channelId: string, channelName: string) => { const handleDelete = async (channelId: string, channelName: string) => {
if (!confirm(`"${channelName}" 채널을 삭제하시겠습니까?`)) return; if (!confirm(`"${channelName}" 채널을 삭제하시겠습니까?`)) return;
try { try {
@@ -164,8 +208,9 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
<th className="text-left px-4 py-3"> </th> <th className="text-left px-4 py-3"> </th>
<th className="text-left px-4 py-3">Channel ID</th> <th className="text-left px-4 py-3">Channel ID</th>
<th className="text-left px-4 py-3"> </th> <th className="text-left px-4 py-3"> </th>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-right px-4 py-3"> </th> <th className="text-right px-4 py-3"> </th>
<th className="text-left px-4 py-3"> </th>
{isAdmin && <th className="text-left px-4 py-3"></th>} {isAdmin && <th className="text-left px-4 py-3"></th>}
<th className="text-left px-4 py-3"> </th> <th className="text-left px-4 py-3"> </th>
</tr> </tr>
@@ -186,6 +231,32 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
<span className="text-gray-400 text-xs"></span> <span className="text-gray-400 text-xs"></span>
)} )}
</td> </td>
<td className="px-4 py-3 text-xs">
{editingChannel === ch.id ? (
<input value={editDesc} onChange={(e) => setEditDesc(e.target.value)}
className="border rounded px-2 py-1 text-xs w-32 bg-white text-gray-900" placeholder="설명" />
) : (
<span className="text-gray-600 cursor-pointer" onClick={() => {
if (!isAdmin) return;
setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || "");
}}>{ch.description || <span className="text-gray-400">-</span>}</span>
)}
</td>
<td className="px-4 py-3 text-xs">
{editingChannel === ch.id ? (
<div className="flex gap-1">
<input value={editTags} onChange={(e) => setEditTags(e.target.value)}
className="border rounded px-2 py-1 text-xs w-40 bg-white text-gray-900" placeholder="태그 (쉼표 구분)" />
<button onClick={() => handleSaveChannel(ch.id)} className="text-blue-600 text-xs hover:underline"></button>
<button onClick={() => setEditingChannel(null)} className="text-gray-400 text-xs hover:underline"></button>
</div>
) : (
<span className="text-gray-500 cursor-pointer" onClick={() => {
if (!isAdmin) return;
setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || "");
}}>{ch.tags ? ch.tags.split(",").map(t => t.trim()).join(", ") : <span className="text-gray-400">-</span>}</span>
)}
</td>
<td className="px-4 py-3 text-right font-medium"> <td className="px-4 py-3 text-right font-medium">
{ch.video_count > 0 ? ( {ch.video_count > 0 ? (
<span className="px-2 py-0.5 bg-green-50 text-green-700 rounded text-xs">{ch.video_count}</span> <span className="px-2 py-0.5 bg-green-50 text-green-700 rounded text-xs">{ch.video_count}</span>
@@ -193,9 +264,6 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
<span className="text-gray-400 text-xs">0</span> <span className="text-gray-400 text-xs">0</span>
)} )}
</td> </td>
<td className="px-4 py-3 text-xs text-gray-500">
{ch.last_scanned_at ? ch.last_scanned_at.slice(0, 16).replace("T", " ") : "-"}
</td>
{isAdmin && <td className="px-4 py-3 flex gap-3"> {isAdmin && <td className="px-4 py-3 flex gap-3">
<button <button
onClick={() => handleScan(ch.channel_id)} onClick={() => handleScan(ch.channel_id)}
@@ -393,35 +461,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 isTranscript = mode === "transcript";
const setRunning = isTranscript ? setBulkTranscripting : setBulkExtracting; const setRunning = isTranscript ? setBulkTranscripting : setBulkExtracting;
const hasSelection = ids && ids.length > 0;
try { try {
const pending = isTranscript let count: number;
? await api.getBulkTranscriptPending() if (hasSelection) {
: await api.getBulkExtractPending(); count = ids.length;
if (pending.count === 0) { } else {
alert(isTranscript ? "자막 없는 영상이 없습니다" : "추출 대기 중인 영상이 없습니다"); const pending = isTranscript
return; ? await api.getBulkTranscriptPending()
: await api.getBulkExtractPending();
if (pending.count === 0) {
alert(isTranscript ? "자막 없는 영상이 없습니다" : "추출 대기 중인 영상이 없습니다");
return;
}
count = pending.count;
} }
const msg = isTranscript const msg = isTranscript
? `자막 없는 영상 ${pending.count}개의 트랜스크립트를 수집하시겠습니까?\n(영상 당 5~15초 랜덤 딜레이)` ? `${hasSelection ? "선택한 " : "자막 없는 "}영상 ${count}개의 트랜스크립트를 수집하시겠습니까?`
: `LLM 추출이 안된 영상 ${pending.count}개를 벌크 처리하시겠습니까?\n(영상 당 3~8초 랜덤 딜레이)`; : `${hasSelection ? "선택한 " : "LLM 추출이 안된 "}영상 ${count}개를 벌크 처리하시겠습니까?`;
if (!confirm(msg)) return; if (!confirm(msg)) return;
setRunning(true); setRunning(true);
setBulkProgress({ setBulkProgress({
label: isTranscript ? "벌크 자막 수집" : "벌크 LLM 추출", 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 apiBase = process.env.NEXT_PUBLIC_API_URL || "";
const endpoint = isTranscript ? "/api/videos/bulk-transcript" : "/api/videos/bulk-extract"; const endpoint = isTranscript ? "/api/videos/bulk-transcript" : "/api/videos/bulk-extract";
const token = typeof window !== "undefined" ? localStorage.getItem("tasteby_token") : null; 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}`; 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) { if (!resp.ok) {
alert(`벌크 요청 실패: ${resp.status} ${resp.statusText}`); alert(`벌크 요청 실패: ${resp.status} ${resp.statusText}`);
setRunning(false); setRunning(false);
@@ -757,6 +836,20 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
)} )}
{isAdmin && selected.size > 0 && ( {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 <button
onClick={handleBulkSkip} onClick={handleBulkSkip}
className="bg-gray-500 text-white px-4 py-2 rounded text-sm hover:bg-gray-600" className="bg-gray-500 text-white px-4 py-2 rounded text-sm hover:bg-gray-600"
@@ -1495,6 +1588,12 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
const [editForm, setEditForm] = useState<Record<string, string>>({}); const [editForm, setEditForm] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [videos, setVideos] = useState<VideoLink[]>([]); 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"; type RestSortKey = "name" | "region" | "cuisine_type" | "price_range" | "rating" | "business_status";
const [sortKey, setSortKey] = useState<RestSortKey>("name"); const [sortKey, setSortKey] = useState<RestSortKey>("name");
const [sortAsc, setSortAsc] = useState(true); const [sortAsc, setSortAsc] = useState(true);
@@ -1617,10 +1716,126 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
</button> </button>
)} )}
</div> </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"> <span className="text-sm text-gray-400 ml-auto">
{nameSearch ? `${sorted.length} / ` : ""} {restaurants.length} {nameSearch ? `${sorted.length} / ` : ""} {restaurants.length}
</span> </span>
</div> </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"> <div className="bg-white rounded-lg shadow overflow-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -1730,6 +1945,109 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
</a> </a>
</p> </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 && ( {videos.length > 0 && (
<div className="mt-4 border-t pt-3"> <div className="mt-4 border-t pt-3">
<h4 className="text-xs font-semibold text-gray-500 mb-2"> ({videos.length})</h4> <h4 className="text-xs font-semibold text-gray-500 mb-2"> ({videos.length})</h4>

View File

@@ -42,3 +42,8 @@ html, body, #__next {
.gm-style .gm-style-iw-d { .gm-style .gm-style-iw-d {
overflow: auto !important; overflow: auto !important;
} }
/* Safe area for iOS bottom nav */
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom, 0px);
}

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { GoogleLogin } from "@react-oauth/google"; import { GoogleLogin } from "@react-oauth/google";
import LoginMenu from "@/components/LoginMenu";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import type { Restaurant, Channel, Review } from "@/lib/api"; import type { Restaurant, Channel, Review } from "@/lib/api";
import { useAuth } from "@/lib/auth-context"; import { useAuth } from "@/lib/auth-context";
@@ -67,7 +68,7 @@ function buildRegionTree(restaurants: Restaurant[]) {
const tree = new Map<string, Map<string, Set<string>>>(); const tree = new Map<string, Map<string, Set<string>>>();
for (const r of restaurants) { for (const r of restaurants) {
const p = parseRegion(r.region); const p = parseRegion(r.region);
if (!p || !p.country) continue; if (!p || !p.country || p.country === "나라") continue;
if (!tree.has(p.country)) tree.set(p.country, new Map()); if (!tree.has(p.country)) tree.set(p.country, new Map());
const cityMap = tree.get(p.country)!; const cityMap = tree.get(p.country)!;
if (p.city) { if (p.city) {
@@ -108,7 +109,7 @@ function findRegionFromCoords(
const groups = new Map<string, { country: string; city: string; lats: number[]; lngs: number[] }>(); const groups = new Map<string, { country: string; city: string; lats: number[]; lngs: number[] }>();
for (const r of restaurants) { for (const r of restaurants) {
const p = parseRegion(r.region); const p = parseRegion(r.region);
if (!p || !p.country || !p.city) continue; if (!p || !p.country || p.country === "나라" || !p.city) continue;
const key = `${p.country}|${p.city}`; const key = `${p.country}|${p.city}`;
if (!groups.has(key)) groups.set(key, { country: p.country, city: p.city, lats: [], lngs: [] }); if (!groups.has(key)) groups.set(key, { country: p.country, city: p.city, lats: [], lngs: [] });
const g = groups.get(key)!; const g = groups.get(key)!;
@@ -140,6 +141,7 @@ export default function Home() {
const [cuisineFilter, setCuisineFilter] = useState(""); const [cuisineFilter, setCuisineFilter] = useState("");
const [priceFilter, setPriceFilter] = useState(""); const [priceFilter, setPriceFilter] = useState("");
const [viewMode, setViewMode] = useState<"map" | "list">("list"); const [viewMode, setViewMode] = useState<"map" | "list">("list");
const [mobileTab, setMobileTab] = useState<"home" | "list" | "nearby" | "favorites" | "profile">("home");
const [showMobileFilters, setShowMobileFilters] = useState(false); const [showMobileFilters, setShowMobileFilters] = useState(false);
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null); const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
const [boundsFilterOn, setBoundsFilterOn] = useState(false); const [boundsFilterOn, setBoundsFilterOn] = useState(false);
@@ -151,6 +153,9 @@ export default function Home() {
const [showMyReviews, setShowMyReviews] = useState(false); const [showMyReviews, setShowMyReviews] = useState(false);
const [myReviews, setMyReviews] = useState<(Review & { restaurant_id: string; restaurant_name: string | null })[]>([]); const [myReviews, setMyReviews] = useState<(Review & { restaurant_id: string; restaurant_name: string | null })[]>([]);
const [visits, setVisits] = useState<{ today: number; total: number } | null>(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 geoApplied = useRef(false);
const regionTree = useMemo(() => buildRegionTree(restaurants), [restaurants]); const regionTree = useMemo(() => buildRegionTree(restaurants), [restaurants]);
@@ -169,6 +174,15 @@ export default function Home() {
}, [regionTree, countryFilter, cityFilter]); }, [regionTree, countryFilter, cityFilter]);
const filteredRestaurants = useMemo(() => { 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) => { return restaurants.filter((r) => {
if (channelFilter && !(r.channels || []).includes(channelFilter)) return false; if (channelFilter && !(r.channels || []).includes(channelFilter)) return false;
if (cuisineFilter && !matchCuisineFilter(r.cuisine_type, cuisineFilter)) return false; if (cuisineFilter && !matchCuisineFilter(r.cuisine_type, cuisineFilter)) return false;
@@ -184,12 +198,23 @@ export default function Home() {
if (r.longitude < mapBounds.west || r.longitude > mapBounds.east) return false; if (r.longitude < mapBounds.west || r.longitude > mapBounds.east) return false;
} }
return true; 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, isSearchResult, 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(() => { useEffect(() => {
if (window.innerWidth >= 768) setViewMode("map"); 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 // Load channels + record visit on mount
@@ -201,6 +226,7 @@ export default function Home() {
// Load restaurants on mount and when channel filter changes // Load restaurants on mount and when channel filter changes
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
setIsSearchResult(false);
api api
.getRestaurants({ limit: 500, channel: channelFilter || undefined }) .getRestaurants({ limit: 500, channel: channelFilter || undefined })
.then(setRestaurants) .then(setRestaurants)
@@ -220,12 +246,9 @@ export default function Home() {
if (match) { if (match) {
setCountryFilter(match.country); setCountryFilter(match.country);
setCityFilter(match.city); 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 */ }, () => { /* user denied or error — do nothing */ },
{ timeout: 5000 }, { timeout: 5000 },
@@ -240,6 +263,18 @@ export default function Home() {
setRestaurants(results); setRestaurants(results);
setSelected(null); setSelected(null);
setShowDetail(false); setShowDetail(false);
setIsSearchResult(true);
// 검색 시 필터 초기화
setChannelFilter("");
setCuisineFilter("");
setPriceFilter("");
setCountryFilter("");
setCityFilter("");
setDistrictFilter("");
setBoundsFilterOn(false);
// 검색 결과에 맞게 지도 이동
const flyTo = computeFlyTo(results);
if (flyTo) setRegionFlyTo(flyTo);
} catch (e) { } catch (e) {
console.error("Search failed:", e); console.error("Search failed:", e);
} finally { } finally {
@@ -262,6 +297,21 @@ export default function Home() {
setMapBounds(bounds); 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) => { const handleCountryChange = useCallback((country: string) => {
setCountryFilter(country); setCountryFilter(country);
setCityFilter(""); setCityFilter("");
@@ -322,6 +372,8 @@ export default function Home() {
setBoundsFilterOn(false); setBoundsFilterOn(false);
setShowFavorites(false); setShowFavorites(false);
setShowMyReviews(false); setShowMyReviews(false);
setIsSearchResult(false);
setResetCount((c) => c + 1);
api api
.getRestaurants({ limit: 500 }) .getRestaurants({ limit: 500 })
.then((data) => { .then((data) => {
@@ -333,6 +385,75 @@ export default function Home() {
.finally(() => setLoading(false)); .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 () => { const handleToggleFavorites = async () => {
if (showFavorites) { if (showFavorites) {
setShowFavorites(false); setShowFavorites(false);
@@ -434,30 +555,21 @@ export default function Home() {
{/* Row 1: Search + dropdown filters */} {/* Row 1: Search + dropdown filters */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-96 shrink-0"> <div className="w-96 shrink-0">
<SearchBar onSearch={handleSearch} isLoading={loading} /> <SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} />
</div> </div>
<select <button
value={channelFilter} onClick={handleReset}
onChange={(e) => { className="p-1.5 text-gray-400 dark:text-gray-500 hover:text-orange-500 dark:hover:text-orange-400 transition-colors"
setChannelFilter(e.target.value); title="초기화"
setSelected(null);
setShowDetail(false);
}}
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> <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>
{channels.map((ch) => ( </button>
<option key={ch.id} value={ch.channel_name}>
📺 {ch.channel_name}
</option>
))}
</select>
<select <select
value={cuisineFilter} value={cuisineFilter}
onChange={(e) => setCuisineFilter(e.target.value)} 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" 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) => ( {CUISINE_TAXONOMY.map((g) => (
<optgroup key={g.category} label={`── ${g.category} ──`}> <optgroup key={g.category} label={`── ${g.category} ──`}>
<option value={g.category}>🍽 {g.category} </option> <option value={g.category}>🍽 {g.category} </option>
@@ -474,7 +586,7 @@ export default function Home() {
onChange={(e) => setPriceFilter(e.target.value)} 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" 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) => ( {PRICE_GROUPS.map((g) => (
<option key={g.label} value={g.label}>{g.label}</option> <option key={g.label} value={g.label}>{g.label}</option>
))} ))}
@@ -487,7 +599,7 @@ export default function Home() {
onChange={(e) => handleCountryChange(e.target.value)} 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" 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) => ( {countries.map((c) => (
<option key={c} value={c}>🌍 {c}</option> <option key={c} value={c}>🌍 {c}</option>
))} ))}
@@ -518,15 +630,29 @@ export default function Home() {
)} )}
<div className="w-px h-5 bg-gray-200 dark:bg-gray-700" /> <div className="w-px h-5 bg-gray-200 dark:bg-gray-700" />
<button <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 ${ className={`px-3 py-1.5 text-sm border rounded-lg transition-colors ${
boundsFilterOn boundsFilterOn
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400" ? "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" : "hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400"
}`} }`}
title="지도 영역 내 식당만 표시" title="내 위치 주변 식당만 표시"
> >
{boundsFilterOn ? "📍 영역 필터" : "📍 영역"} {boundsFilterOn ? "📍 내위치 ON" : "📍 내위치"}
</button> </button>
<button <button
onClick={() => setViewMode(viewMode === "map" ? "list" : "map")} onClick={() => setViewMode(viewMode === "map" ? "list" : "map")}
@@ -563,6 +689,37 @@ export default function Home() {
{filteredRestaurants.length} {filteredRestaurants.length}
</span> </span>
</div> </div>
{/* Row 3: Channel cards (toggle filter) - max 4 visible, scroll for rest */}
{!isSearchResult && channels.length > 0 && (
<div className="overflow-x-auto scrollbar-hide" style={{ maxWidth: `${4 * 200 + 3 * 8}px` }}>
<div className="flex gap-2">
{channels.map((ch) => (
<button
key={ch.id}
onClick={() => {
setChannelFilter(channelFilter === ch.channel_name ? "" : ch.channel_name);
setSelected(null);
setShowDetail(false);
}}
className={`shrink-0 flex items-center gap-2 rounded-lg px-3 py-1.5 border transition-all text-left ${
channelFilter === ch.channel_name
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700"
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-orange-200 dark:hover:border-orange-800"
}`}
style={{ width: "200px" }}
>
<svg className="w-4 h-4 shrink-0 text-red-500" viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
<div className="min-w-0 flex-1">
<p className={`text-xs font-semibold truncate ${
channelFilter === ch.channel_name ? "text-orange-600 dark:text-orange-400" : "dark:text-gray-200"
}`}>{ch.channel_name}</p>
{ch.description && <p className="text-[10px] text-gray-400 dark:text-gray-500 truncate">{ch.description}</p>}
</div>
</button>
))}
</div>
</div>
)}
</div> </div>
{/* User area */} {/* User area */}
@@ -591,74 +748,69 @@ export default function Home() {
</button> </button>
</div> </div>
) : ( ) : (
<GoogleLogin <LoginMenu onGoogleSuccess={(credential) => login(credential).catch(console.error)} />
onSuccess={(credentialResponse) => {
if (credentialResponse.credential) {
login(credentialResponse.credential).catch(console.error);
}
}}
onError={() => console.error("Google login failed")}
size="small"
/>
)} )}
</div> </div>
</div> </div>
{/* ── Header row 2 (mobile only): search + toolbar ── */} {/* ── 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 */} {/* Row 1: Search */}
<SearchBar onSearch={handleSearch} isLoading={loading} /> <SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} />
{/* Channel cards - toggle filter */}
{mobileTab === "home" && !isSearchResult && channels.length > 0 && (
<div className="flex gap-2 overflow-x-auto pb-1 -mx-1 px-1 scrollbar-hide">
{channels.map((ch) => (
<button
key={ch.id}
onClick={() => {
setChannelFilter(channelFilter === ch.channel_name ? "" : ch.channel_name);
setSelected(null);
setShowDetail(false);
}}
className={`shrink-0 rounded-xl px-3 py-2 text-left border transition-all ${
channelFilter === ch.channel_name
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700"
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
}`}
style={{ minWidth: "140px", maxWidth: "170px" }}
>
<div className="flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 shrink-0 text-red-500" viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
<p className={`text-xs font-semibold truncate ${
channelFilter === ch.channel_name ? "text-orange-600 dark:text-orange-400" : "dark:text-gray-200"
}`}>{ch.channel_name}</p>
</div>
{ch.description && <p className="text-[10px] text-gray-500 dark:text-gray-400 truncate mt-0.5">{ch.description}</p>}
{ch.tags && (
<div className="flex gap-1 mt-1 overflow-hidden">
{ch.tags.split(",").slice(0, 2).map((t) => (
<span key={t} className="text-[9px] px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded-full whitespace-nowrap">{t.trim()}</span>
))}
</div>
)}
</button>
))}
</div>
)}
{/* Row 2: Toolbar */} {/* Row 2: Toolbar */}
<div className="flex items-center gap-2"> <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 <button
onClick={() => setShowMobileFilters(!showMobileFilters)} onClick={() => setShowMobileFilters(!showMobileFilters)}
className={`px-3 py-1.5 text-xs border rounded-lg transition-colors relative ${ 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" ? "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" : "text-gray-600"
}`} }`}
> >
{showMobileFilters ? "✕ 닫기" : "🔽 필터"} {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"> <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> </span>
)} )}
</button> </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"> <span className="text-xs text-gray-400 ml-auto">
{filteredRestaurants.length} {filteredRestaurants.length}
</span> </span>
@@ -669,28 +821,12 @@ export default function Home() {
<div className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-md rounded-xl p-3.5 space-y-3 border border-white/50 dark:border-gray-700/50 shadow-sm"> <div className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-md rounded-xl p-3.5 space-y-3 border border-white/50 dark:border-gray-700/50 shadow-sm">
{/* Dropdown filters */} {/* Dropdown filters */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<select
value={channelFilter}
onChange={(e) => {
setChannelFilter(e.target.value);
setSelected(null);
setShowDetail(false);
}}
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>
{channels.map((ch) => (
<option key={ch.id} value={ch.channel_name}>
📺 {ch.channel_name}
</option>
))}
</select>
<select <select
value={cuisineFilter} value={cuisineFilter}
onChange={(e) => setCuisineFilter(e.target.value)} 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" 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) => ( {CUISINE_TAXONOMY.map((g) => (
<optgroup key={g.category} label={`── ${g.category} ──`}> <optgroup key={g.category} label={`── ${g.category} ──`}>
<option value={g.category}>🍽 {g.category} </option> <option value={g.category}>🍽 {g.category} </option>
@@ -707,7 +843,7 @@ export default function Home() {
onChange={(e) => setPriceFilter(e.target.value)} 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" 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) => ( {PRICE_GROUPS.map((g) => (
<option key={g.label} value={g.label}>💰 {g.label}</option> <option key={g.label} value={g.label}>💰 {g.label}</option>
))} ))}
@@ -720,7 +856,7 @@ export default function Home() {
onChange={(e) => handleCountryChange(e.target.value)} 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" 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) => ( {countries.map((c) => (
<option key={c} value={c}>🌍 {c}</option> <option key={c} value={c}>🌍 {c}</option>
))} ))}
@@ -753,14 +889,28 @@ export default function Home() {
{/* Toggle buttons */} {/* Toggle buttons */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<button <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 ${ className={`px-2.5 py-1.5 text-xs border rounded-lg transition-colors ${
boundsFilterOn boundsFilterOn
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400" ? "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" : "text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800"
}`} }`}
> >
{boundsFilterOn ? "📍 영역 ON" : "📍 영역"} {boundsFilterOn ? "📍 내위치 ON" : "📍 내위치"}
</button> </button>
</div> </div>
</div> </div>
@@ -784,6 +934,8 @@ export default function Home() {
onSelectRestaurant={handleSelectRestaurant} onSelectRestaurant={handleSelectRestaurant}
onBoundsChanged={handleBoundsChanged} onBoundsChanged={handleBoundsChanged}
flyTo={regionFlyTo} flyTo={regionFlyTo}
onMyLocation={handleMyLocation}
activeChannel={channelFilter || undefined}
/> />
{visits && ( {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"> <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 +956,8 @@ export default function Home() {
onSelectRestaurant={handleSelectRestaurant} onSelectRestaurant={handleSelectRestaurant}
onBoundsChanged={handleBoundsChanged} onBoundsChanged={handleBoundsChanged}
flyTo={regionFlyTo} flyTo={regionFlyTo}
onMyLocation={handleMyLocation}
activeChannel={channelFilter || undefined}
/> />
{visits && ( {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"> <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 +970,115 @@ export default function Home() {
</div> </div>
{/* Mobile layout */} {/* Mobile layout */}
<div className="md:hidden flex-1 flex flex-col overflow-hidden"> <div className="md:hidden flex-1 flex flex-col overflow-hidden pb-14">
{viewMode === "map" ? ( {/* Tab content — takes all remaining space above fixed nav */}
<> {mobileTab === "nearby" ? (
<div className="flex-1 relative"> /* 내주변: 지도 + 리스트 분할, 영역필터 ON */
<div className="flex-1 flex flex-col overflow-hidden">
<div className="h-[45%] relative shrink-0">
<MapView <MapView
restaurants={filteredRestaurants} restaurants={filteredRestaurants}
selected={selected} selected={selected}
onSelectRestaurant={handleSelectRestaurant} onSelectRestaurant={handleSelectRestaurant}
onBoundsChanged={handleBoundsChanged} onBoundsChanged={handleBoundsChanged}
flyTo={regionFlyTo} 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 && ( {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"> <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()} {visits.today} · {visits.total.toLocaleString()}
</div> </div>
)} )}
</div> </div>
</> <div className="flex-1 overflow-y-auto">
) : ( {mobileListContent}
<> </div>
{/* List area — if selected, show single row; otherwise full list */} </div>
{selected ? ( ) : mobileTab === "profile" ? (
<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" <div className="flex-1 overflow-y-auto bg-white dark:bg-gray-950">
onClick={() => { setSelected(null); setShowDetail(false); }} {!user ? (
> <div className="flex flex-col items-center justify-center h-full gap-4 px-6">
<span className="text-base">{getCuisineIcon(selected.cuisine_type)}</span> <p className="text-gray-500 dark:text-gray-400 text-sm"> </p>
<span className="font-semibold text-sm truncate flex-1">{selected.name}</span> <GoogleLogin
{selected.rating && ( onSuccess={(res) => {
<span className="text-xs text-gray-500"><span className="text-yellow-500"></span> {selected.rating}</span> if (res.credential) login(res.credential).catch(console.error);
)} }}
{selected.cuisine_type && ( onError={() => console.error("Google login failed")}
<span className="text-xs text-gray-400">{selected.cuisine_type}</span> size="large"
)} text="signin_with"
<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> </div>
) : ( ) : (
<div className="max-h-[360px] bg-white dark:bg-gray-950 overflow-y-auto border-b dark:border-gray-800"> <div className="p-4 space-y-4">
{mobileListContent} {/* 프로필 헤더 */}
<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> </div>
)} )}
{/* Map fills remaining space below the list */} </div>
<div className="flex-1 relative"> ) : (
<MapView /* 홈 / 식당 목록 / 찜: 리스트 표시 */
restaurants={filteredRestaurants} <div className="flex-1 overflow-y-auto bg-white dark:bg-gray-950">
selected={selected} {mobileTab === "favorites" && !user ? (
onSelectRestaurant={handleSelectRestaurant} <div className="flex flex-col items-center justify-center h-full gap-4 px-6">
onBoundsChanged={handleBoundsChanged} <p className="text-gray-500 dark:text-gray-400 text-sm"> </p>
flyTo={regionFlyTo} <GoogleLogin
/> onSuccess={(res) => {
{visits && ( if (res.credential) login(res.credential).catch(console.error);
<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()} onError={() => console.error("Google login failed")}
</div> size="large"
)} text="signin_with"
</div> />
</> </div>
) : (
mobileListContent
)}
</div>
)} )}
{/* Mobile Bottom Sheet for restaurant detail */} {/* Mobile Bottom Sheet for restaurant detail */}
@@ -888,8 +1089,44 @@ export default function Home() {
</BottomSheet> </BottomSheet>
</div> </div>
{/* Footer */} {/* ── Mobile Bottom Nav (fixed) ── */}
<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"> <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"> <div className="relative">
<img <img
src="/icon.jpg" src="/icon.jpg"

View 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,
)}
</>
);
}

View File

@@ -57,33 +57,14 @@ interface MapViewProps {
onSelectRestaurant?: (r: Restaurant) => void; onSelectRestaurant?: (r: Restaurant) => void;
onBoundsChanged?: (bounds: MapBounds) => void; onBoundsChanged?: (bounds: MapBounds) => void;
flyTo?: FlyTo | null; 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 map = useMap();
const [infoTarget, setInfoTarget] = useState<Restaurant | null>(null); const [infoTarget, setInfoTarget] = useState<Restaurant | null>(null);
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]); 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( const handleMarkerClick = useCallback(
(r: Restaurant) => { (r: Restaurant) => {
@@ -113,7 +94,8 @@ function MapContent({ restaurants, selected, onSelectRestaurant, onBoundsChanged
{restaurants.map((r) => { {restaurants.map((r) => {
const isSelected = selected?.id === r.id; const isSelected = selected?.id === r.id;
const isClosed = r.business_status === "CLOSED_PERMANENTLY"; 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]; const c = chColor || CHANNEL_COLORS[0];
return ( return (
<AdvancedMarker <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 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 ( return (
<APIProvider apiKey={API_KEY}> <APIProvider apiKey={API_KEY}>
<Map <Map
defaultCenter={SEOUL_CENTER} defaultCenter={SEOUL_CENTER}
defaultZoom={12} defaultZoom={13}
mapId="tasteby-map" mapId="tasteby-map"
className="h-full w-full" className="h-full w-full"
colorScheme="LIGHT" colorScheme="LIGHT"
mapTypeControl={false} mapTypeControl={false}
fullscreenControl={false}
onCameraChanged={handleCameraChanged}
> >
<MapContent <MapContent
restaurants={restaurants} restaurants={restaurants}
selected={selected} selected={selected}
onSelectRestaurant={onSelectRestaurant} onSelectRestaurant={onSelectRestaurant}
onBoundsChanged={onBoundsChanged}
flyTo={flyTo} flyTo={flyTo}
activeChannel={activeChannel}
/> />
</Map> </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"> <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) => ( {channelNames.map((ch) => (
<div key={ch} className="flex items-center gap-1"> <div key={ch} className="flex items-center gap-1">

View File

@@ -84,6 +84,7 @@ export default function RestaurantDetail({
{restaurant.rating && ( {restaurant.rating && (
<div className="flex items-center gap-2 text-sm"> <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="text-yellow-500 dark:text-yellow-400">{"★".repeat(Math.round(restaurant.rating))}</span>
<span className="font-medium dark:text-gray-200">{restaurant.rating}</span> <span className="font-medium dark:text-gray-200">{restaurant.rating}</span>
{restaurant.rating_count && ( {restaurant.rating_count && (
@@ -122,19 +123,51 @@ export default function RestaurantDetail({
</p> </p>
)} )}
{restaurant.google_place_id && ( {restaurant.google_place_id && (
<p> <p className="flex gap-3">
<a <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" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-orange-600 dark:text-orange-400 hover:underline text-xs" className="text-orange-600 dark:text-orange-400 hover:underline text-xs"
> >
Google Maps에서 Google Maps에서
</a> </a>
<a
href={`https://map.naver.com/v5/search/${encodeURIComponent(restaurant.name)}`}
target="_blank"
rel="noopener noreferrer"
className="text-green-600 dark:text-green-400 hover:underline text-xs"
>
</a>
</p> </p>
)} )}
</div> </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> <div>
<h3 className="font-semibold text-sm mb-2 dark:text-gray-200"> </h3> <h3 className="font-semibold text-sm mb-2 dark:text-gray-200"> </h3>
{loading ? ( {loading ? (
@@ -161,7 +194,8 @@ export default function RestaurantDetail({
<div key={v.video_id} className="border dark:border-gray-700 rounded-lg p-3"> <div key={v.video_id} className="border dark:border-gray-700 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
{v.channel_name && ( {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} {v.channel_name}
</span> </span>
)} )}
@@ -175,8 +209,11 @@ export default function RestaurantDetail({
href={v.url} href={v.url}
target="_blank" target="_blank"
rel="noopener noreferrer" 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} {v.title}
</a> </a>
{v.foods_mentioned.length > 0 && ( {v.foods_mentioned.length > 0 && (

View File

@@ -34,7 +34,7 @@ export default function SearchBar({ onSearch, isLoading }: SearchBarProps) {
> >
<option value="hybrid"></option> <option value="hybrid"></option>
<option value="keyword"></option> <option value="keyword"></option>
<option value="semantic"></option> <option value="semantic"></option>
</select> </select>
<button <button
type="submit" type="submit"

View File

@@ -42,6 +42,8 @@ export interface Restaurant {
cuisine_type: string | null; cuisine_type: string | null;
price_range: string | null; price_range: string | null;
google_place_id: string | null; google_place_id: string | null;
tabling_url: string | null;
catchtable_url: string | null;
business_status: string | null; business_status: string | null;
rating: number | null; rating: number | null;
rating_count: number | null; rating_count: number | null;
@@ -68,6 +70,8 @@ export interface Channel {
channel_id: string; channel_id: string;
channel_name: string; channel_name: string;
title_filter: string | null; title_filter: string | null;
description: string | null;
tags: string | null;
video_count: number; video_count: number;
last_scanned_at: string | null; last_scanned_at: string | null;
} }
@@ -322,6 +326,39 @@ 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 }) }
);
},
updateChannel(id: string, data: { description?: string; tags?: string }) {
return fetchApi<{ ok: boolean }>(`/api/channels/${id}`, {
method: "PUT",
body: JSON.stringify(data),
});
},
deleteChannel(channelId: string) { deleteChannel(channelId: string) {
return fetchApi<{ ok: boolean }>(`/api/channels/${channelId}`, { return fetchApi<{ ok: boolean }>(`/api/channels/${channelId}`, {
method: "DELETE", method: "DELETE",
@@ -381,6 +418,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) { triggerProcessing(limit: number = 5) {
return fetchApi<{ restaurants_extracted: number }>( return fetchApi<{ restaurants_extracted: number }>(
`/api/videos/process?limit=${limit}`, `/api/videos/process?limit=${limit}`,
@@ -462,6 +506,12 @@ export const api = {
}); });
}, },
flushCache() {
return fetchApi<{ ok: boolean }>("/api/admin/cache-flush", {
method: "POST",
});
},
runDaemonProcess(limit: number = 10) { runDaemonProcess(limit: number = 10) {
return fetchApi<{ ok: boolean; restaurants_extracted: number }>( return fetchApi<{ ok: boolean; restaurants_extracted: number }>(
`/api/daemon/run/process?limit=${limit}`, `/api/daemon/run/process?limit=${limit}`,

View File

@@ -4,6 +4,47 @@ server {
return 301 https://www.tasteby.net$request_uri; return 301 https://www.tasteby.net$request_uri;
} }
server {
listen 80;
server_name dev.tasteby.net;
return 301 https://dev.tasteby.net$request_uri;
}
server {
listen 443 ssl http2;
server_name dev.tasteby.net;
ssl_certificate /etc/letsencrypt/live/dev.tasteby.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dev.tasteby.net/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Frontend (Next.js)
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Backend API
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300;
proxy_connect_timeout 75;
proxy_send_timeout 300;
}
}
server { server {
listen 443 ssl http2; listen 443 ssl http2;
server_name tasteby.net; server_name tasteby.net;