10 Commits

Author SHA1 Message Date
joungmin
4b1f7c13b7 Playwright 제거 → DuckDuckGo HTML 검색 전환 + UI 미세 조정
- 테이블링/캐치테이블 검색: Google+Playwright → DuckDuckGo HTML 파싱 (브라우저 불필요)
- 검색 딜레이 5~15초 → 2~5초로 단축
- 프론트엔드: 정보 텍스트 계층 개선 (폰트 크기/색상 조정)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:28:49 +09:00
joungmin
75e0066dbe 지도 마커 클러스터링 적용 (supercluster)
- 줌 16 이하에서 근접 마커를 숫자 원형 클러스터로 묶음
- 클러스터 클릭 시 해당 영역으로 자동 줌인
- 개별 마커 스타일/채널 색상 유지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:16:31 +09:00
joungmin
3134994817 비공개 메모 기능 추가 + 아이콘 개선
- 식당별 1:1 비공개 메모 CRUD (user_memos 테이블)
- 내 기록에 리뷰/메모 탭 분리
- 백오피스 유저 관리에 메모 수/상세 표시
- 리뷰/메모 작성 시 현재 날짜 기본값
- 지도우선/목록우선 버튼 Material Symbols 아이콘 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:10:06 +09:00
joungmin
88c1b4243e 로고 라이트 고정 + color-scheme only light 강화
- 다크/라이트 로고 전환 로직 제거, 라이트 로고 고정
- color-scheme: only light !important 강화
- supported-color-schemes 메타 태그 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:17:33 +09:00
joungmin
824c171158 Material Symbols 아이콘 전환 + 로고 이미지 적용 + 테이블링 이름 유사도 체크
- 전체 인라인 SVG를 Google Material Symbols Rounded로 교체
- Icon 컴포넌트 추가, cuisine-icons 매핑 리팩토링
- Tasteby 핀 로고 이미지 적용 (라이트/다크 버전)
- 테이블링/캐치테이블 이름 유사도 체크 및 리셋 API 추가
- 어드민 페이지 리셋 버튼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:55:04 +09:00
joungmin
4f8b4f435e 라이트 테마 강제 적용 + surface 토큰 + 텍스트 대비 개선
- dark: 클래스를 class 기반으로 전환 (다크모드 비활성화)
- color-scheme: light 강제
- surface 색상 토큰 추가 (카드/패널용)
- 리스트/사이드바 배경 bg-surface로 통일
- 텍스트 대비 강화 (gray-400 → gray-500/700)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:30:49 +09:00
joungmin
50018c17fa Saffron 디자인 시스템 적용: 브랜드 컬러 + Pretendard 폰트 + 크림 배경
- CSS 변수 기반 brand-50~950 컬러 팔레트 추가 (Tailwind @theme inline)
- Pretendard Variable 폰트 로드 및 기본 폰트로 설정
- 라이트모드 배경 #FFFAF5 크림색 적용 (다크모드 기본 유지)
- 전체 컴포넌트 orange-* → brand-* 마이그레이션
- 식당 리스트 채널명에 YouTube SVG 아이콘 추가
- 디자인 컨셉 문서 추가 (docs/design-concepts.md)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:15:45 +09:00
joungmin
ec8330a978 모바일 UI 개선: 내주변 지도전용, 필터 상시노출, 채널필터 전탭 확장
- 내주변 탭: 지도만 전체 표시 (리스트 제거), 마커 클릭 시 바텀시트 상세보기 유지
- 유튜버 채널 필터: 홈/식당목록/내주변 탭 모두에서 표시
- 모바일 필터: 토글 패널 → 항상 보이는 2줄 레이아웃 (장르+가격 / 나라+내위치)
- 모바일 헤더에 찜/리뷰 버튼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:49:42 +09:00
joungmin
e85e135c8b 검색/필터 UI 개선, 채널 정렬, 드래그 스크롤, 지도링크 수정
- 검색바: 아이콘 내장, 모드 select 제거 (hybrid 고정), 엔터 검색
- 필터 그룹화: [음식 장르·가격] [지역 나라·시·구] + X 해제 버튼
- 채널 필터: 드롭다운 → 유튜브 아이콘 토글 카드, 드래그 스크롤
- 채널 정렬: sort_order 컬럼 추가, 백오피스 순서 편집
- 채널+필터 동시 적용: API 호출 대신 클라이언트 필터링
- 내위치 ON 시 다른 필터 초기화, 역방향도 동일
- 전체보기 버튼: 모든 필터 일괄 해제
- 네이버맵: 한국 식당만, 식당명만 검색
- 구글맵: 식당명+주소/지역 검색
- 로그인 영역 데스크톱 Row 1 우측 배치
- scrollbar-hide CSS 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:42:25 +09:00
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
46 changed files with 2139 additions and 756 deletions

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

@@ -1,7 +1,9 @@
package com.tasteby.controller; package com.tasteby.controller;
import com.tasteby.domain.Memo;
import com.tasteby.domain.Restaurant; import com.tasteby.domain.Restaurant;
import com.tasteby.domain.Review; import com.tasteby.domain.Review;
import com.tasteby.service.MemoService;
import com.tasteby.service.ReviewService; import com.tasteby.service.ReviewService;
import com.tasteby.service.UserService; import com.tasteby.service.UserService;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -15,10 +17,12 @@ public class AdminUserController {
private final UserService userService; private final UserService userService;
private final ReviewService reviewService; private final ReviewService reviewService;
private final MemoService memoService;
public AdminUserController(UserService userService, ReviewService reviewService) { public AdminUserController(UserService userService, ReviewService reviewService, MemoService memoService) {
this.userService = userService; this.userService = userService;
this.reviewService = reviewService; this.reviewService = reviewService;
this.memoService = memoService;
} }
@GetMapping @GetMapping
@@ -39,4 +43,9 @@ public class AdminUserController {
public List<Review> userReviews(@PathVariable String userId) { public List<Review> userReviews(@PathVariable String userId) {
return reviewService.findByUser(userId, 100, 0); return reviewService.findByUser(userId, 100, 0);
} }
@GetMapping("/{userId}/memos")
public List<Memo> userMemos(@PathVariable String userId) {
return memoService.findByUser(userId);
}
} }

View File

@@ -76,6 +76,15 @@ public class ChannelController {
return result; return result;
} }
@PutMapping("/{id}")
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
AuthUtil.requireAdmin();
Integer sortOrder = body.get("sort_order") != null ? ((Number) body.get("sort_order")).intValue() : null;
channelService.update(id, (String) body.get("description"), (String) body.get("tags"), sortOrder);
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

@@ -0,0 +1,59 @@
package com.tasteby.controller;
import com.tasteby.domain.Memo;
import com.tasteby.security.AuthUtil;
import com.tasteby.service.MemoService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class MemoController {
private final MemoService memoService;
public MemoController(MemoService memoService) {
this.memoService = memoService;
}
@GetMapping("/restaurants/{restaurantId}/memo")
public Memo getMemo(@PathVariable String restaurantId) {
String userId = AuthUtil.getUserId();
Memo memo = memoService.findByUserAndRestaurant(userId, restaurantId);
if (memo == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No memo");
}
return memo;
}
@PostMapping("/restaurants/{restaurantId}/memo")
public Memo upsertMemo(@PathVariable String restaurantId,
@RequestBody Map<String, Object> body) {
String userId = AuthUtil.getUserId();
Double rating = body.get("rating") != null
? ((Number) body.get("rating")).doubleValue() : null;
String text = (String) body.get("memo_text");
LocalDate visitedAt = body.get("visited_at") != null
? LocalDate.parse((String) body.get("visited_at")) : null;
return memoService.upsert(userId, restaurantId, rating, text, visitedAt);
}
@GetMapping("/users/me/memos")
public List<Memo> myMemos() {
return memoService.findByUser(AuthUtil.getUserId());
}
@DeleteMapping("/restaurants/{restaurantId}/memo")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteMemo(@PathVariable String restaurantId) {
String userId = AuthUtil.getUserId();
if (!memoService.delete(userId, restaurantId)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No memo");
}
}
}

View File

@@ -2,7 +2,6 @@ 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;
@@ -15,15 +14,19 @@ 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 org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.net.URI;
import java.net.URLDecoder;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
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; import java.util.concurrent.ThreadLocalRandom;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@RestController @RestController
@RequestMapping("/api/restaurants") @RequestMapping("/api/restaurants")
@@ -139,12 +142,8 @@ public class RestaurantController {
var r = restaurantService.findById(id); var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
try (Playwright pw = Playwright.create()) { try {
try (Browser browser = launchBrowser(pw)) { return searchTabling(r.getName());
BrowserContext ctx = newContext(browser);
Page page = newPage(ctx);
return searchTabling(page, r.getName());
}
} catch (Exception e) { } catch (Exception e) {
log.error("[TABLING] Search failed for '{}': {}", r.getName(), e.getMessage()); log.error("[TABLING] Search failed for '{}': {}", r.getName(), e.getMessage());
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage()); throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
@@ -183,25 +182,28 @@ public class RestaurantController {
int linked = 0; int linked = 0;
int notFound = 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++) { for (int i = 0; i < total; i++) {
var r = restaurants.get(i); var r = restaurants.get(i);
emit(emitter, Map.of("type", "processing", "current", i + 1, emit(emitter, Map.of("type", "processing", "current", i + 1,
"total", total, "name", r.getName())); "total", total, "name", r.getName()));
try { try {
var results = searchTabling(page, r.getName()); var results = searchTabling(r.getName());
if (!results.isEmpty()) { if (!results.isEmpty()) {
String url = String.valueOf(results.get(0).get("url")); String url = String.valueOf(results.get(0).get("url"));
String title = String.valueOf(results.get(0).get("title")); String title = String.valueOf(results.get(0).get("title"));
if (isNameSimilar(r.getName(), title)) {
restaurantService.update(r.getId(), Map.of("tabling_url", url)); restaurantService.update(r.getId(), Map.of("tabling_url", url));
linked++; linked++;
emit(emitter, Map.of("type", "done", "current", i + 1, emit(emitter, Map.of("type", "done", "current", i + 1,
"name", r.getName(), "url", url, "title", title)); "name", r.getName(), "url", url, "title", title));
} else {
restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
notFound++;
log.info("[TABLING] Name mismatch: '{}' vs '{}', skipping", r.getName(), title);
emit(emitter, Map.of("type", "notfound", "current", i + 1,
"name", r.getName(), "reason", "이름 불일치: " + title));
}
} else { } else {
restaurantService.update(r.getId(), Map.of("tabling_url", "NONE")); restaurantService.update(r.getId(), Map.of("tabling_url", "NONE"));
notFound++; notFound++;
@@ -214,12 +216,10 @@ public class RestaurantController {
"name", r.getName(), "message", e.getMessage())); "name", r.getName(), "message", e.getMessage()));
} }
// Google 봇 판정 방지 랜덤 딜레이 (5~15초) // 랜덤 딜레이 (2~5초)
int delay = ThreadLocalRandom.current().nextInt(5000, 15001); int delay = ThreadLocalRandom.current().nextInt(2000, 5001);
log.info("[TABLING] Waiting {}ms before next search...", delay); log.info("[TABLING] Waiting {}ms before next search...", delay);
page.waitForTimeout(delay); Thread.sleep(delay);
}
}
} }
cache.flush(); cache.flush();
@@ -246,18 +246,31 @@ public class RestaurantController {
return Map.of("ok", true); return Map.of("ok", true);
} }
/** 테이블링/캐치테이블 매핑 초기화 */
@DeleteMapping("/reset-tabling")
public Map<String, Object> resetTabling() {
AuthUtil.requireAdmin();
restaurantService.resetTablingUrls();
cache.flush();
return Map.of("ok", true);
}
@DeleteMapping("/reset-catchtable")
public Map<String, Object> resetCatchtable() {
AuthUtil.requireAdmin();
restaurantService.resetCatchtableUrls();
cache.flush();
return Map.of("ok", true);
}
/** 단건 캐치테이블 URL 검색 */ /** 단건 캐치테이블 URL 검색 */
@GetMapping("/{id}/catchtable-search") @GetMapping("/{id}/catchtable-search")
public List<Map<String, Object>> catchtableSearch(@PathVariable String id) { public List<Map<String, Object>> catchtableSearch(@PathVariable String id) {
AuthUtil.requireAdmin(); AuthUtil.requireAdmin();
var r = restaurantService.findById(id); var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
try (Playwright pw = Playwright.create()) { try {
try (Browser browser = launchBrowser(pw)) { return searchCatchtable(r.getName());
BrowserContext ctx = newContext(browser);
Page page = newPage(ctx);
return searchCatchtable(page, r.getName());
}
} catch (Exception e) { } catch (Exception e) {
log.error("[CATCHTABLE] Search failed for '{}': {}", r.getName(), e.getMessage()); log.error("[CATCHTABLE] Search failed for '{}': {}", r.getName(), e.getMessage());
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage()); throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Search failed: " + e.getMessage());
@@ -296,25 +309,28 @@ public class RestaurantController {
int linked = 0; int linked = 0;
int notFound = 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++) { for (int i = 0; i < total; i++) {
var r = restaurants.get(i); var r = restaurants.get(i);
emit(emitter, Map.of("type", "processing", "current", i + 1, emit(emitter, Map.of("type", "processing", "current", i + 1,
"total", total, "name", r.getName())); "total", total, "name", r.getName()));
try { try {
var results = searchCatchtable(page, r.getName()); var results = searchCatchtable(r.getName());
if (!results.isEmpty()) { if (!results.isEmpty()) {
String url = String.valueOf(results.get(0).get("url")); String url = String.valueOf(results.get(0).get("url"));
String title = String.valueOf(results.get(0).get("title")); String title = String.valueOf(results.get(0).get("title"));
if (isNameSimilar(r.getName(), title)) {
restaurantService.update(r.getId(), Map.of("catchtable_url", url)); restaurantService.update(r.getId(), Map.of("catchtable_url", url));
linked++; linked++;
emit(emitter, Map.of("type", "done", "current", i + 1, emit(emitter, Map.of("type", "done", "current", i + 1,
"name", r.getName(), "url", url, "title", title)); "name", r.getName(), "url", url, "title", title));
} else {
restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
notFound++;
log.info("[CATCHTABLE] Name mismatch: '{}' vs '{}', skipping", r.getName(), title);
emit(emitter, Map.of("type", "notfound", "current", i + 1,
"name", r.getName(), "reason", "이름 불일치: " + title));
}
} else { } else {
restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE")); restaurantService.update(r.getId(), Map.of("catchtable_url", "NONE"));
notFound++; notFound++;
@@ -327,11 +343,9 @@ public class RestaurantController {
"name", r.getName(), "message", e.getMessage())); "name", r.getName(), "message", e.getMessage()));
} }
int delay = ThreadLocalRandom.current().nextInt(5000, 15001); int delay = ThreadLocalRandom.current().nextInt(2000, 5001);
log.info("[CATCHTABLE] Waiting {}ms before next search...", delay); log.info("[CATCHTABLE] Waiting {}ms before next search...", delay);
page.waitForTimeout(delay); Thread.sleep(delay);
}
}
} }
cache.flush(); cache.flush();
@@ -374,119 +388,121 @@ public class RestaurantController {
return result; return result;
} }
// ─── Playwright helpers ────────────────────────────────────────────── // ─── DuckDuckGo HTML search helpers ─────────────────────────────────
private Browser launchBrowser(Playwright pw) { private static final HttpClient httpClient = HttpClient.newBuilder()
return pw.chromium().launch(new BrowserType.LaunchOptions() .followRedirects(HttpClient.Redirect.NORMAL)
.setHeadless(false) .build();
.setArgs(List.of("--disable-blink-features=AutomationControlled")));
}
private BrowserContext newContext(Browser browser) { private static final Pattern DDG_RESULT_PATTERN = Pattern.compile(
return browser.newContext(new Browser.NewContextOptions() "<a[^>]+class=\"result__a\"[^>]+href=\"([^\"]+)\"[^>]*>(.*?)</a>",
.setLocale("ko-KR").setViewportSize(1280, 900)); Pattern.DOTALL
} );
private Page newPage(BrowserContext ctx) { /**
Page page = ctx.newPage(); * DuckDuckGo HTML 검색을 통해 특정 사이트의 URL을 찾는다.
page.addInitScript("Object.defineProperty(navigator, 'webdriver', {get: () => false})"); * html.duckduckgo.com은 서버사이드 렌더링이라 봇 판정 없이 HTTP 요청만으로 결과를 파싱할 수 있다.
return page; */
} private List<Map<String, Object>> searchDuckDuckGo(String query, String... urlPatterns) throws Exception {
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
String searchUrl = "https://html.duckduckgo.com/html/?q=" + encoded;
log.info("[DDG] Searching: {}", query);
@SuppressWarnings("unchecked") HttpRequest request = HttpRequest.newBuilder()
private List<Map<String, Object>> searchTabling(Page page, String restaurantName) { .uri(URI.create(searchUrl))
String query = "site:tabling.co.kr " + restaurantName; .header("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
log.info("[TABLING] Searching: {}", query); .header("Accept", "text/html,application/xhtml+xml")
.header("Accept-Language", "ko-KR,ko;q=0.9")
.GET()
.build();
String searchUrl = "https://www.google.com/search?q=" + HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
URLEncoder.encode(query, StandardCharsets.UTF_8); String html = response.body();
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<>(); List<Map<String, Object>> results = new ArrayList<>();
if (linksObj instanceof List<?> list) { Set<String> seen = new HashSet<>();
for (var item : list) { Matcher matcher = DDG_RESULT_PATTERN.matcher(html);
if (item instanceof Map<?, ?> map) {
results.add(Map.of( while (matcher.find() && results.size() < 5) {
"title", String.valueOf(map.get("title")), String href = matcher.group(1);
"url", String.valueOf(map.get("url")) String title = matcher.group(2).replaceAll("<[^>]+>", "").trim();
));
// DDG 링크에서 실제 URL 추출 (uddg 파라미터)
String actualUrl = extractDdgUrl(href);
if (actualUrl == null) continue;
boolean matches = false;
for (String pattern : urlPatterns) {
if (actualUrl.contains(pattern)) {
matches = true;
break;
} }
} }
if (matches && !seen.contains(actualUrl)) {
seen.add(actualUrl);
results.add(Map.of("title", title, "url", actualUrl));
} }
log.info("[TABLING] Found {} results for '{}'", results.size(), restaurantName); }
log.info("[DDG] Found {} results for '{}'", results.size(), query);
return results; return results;
} }
@SuppressWarnings("unchecked") /** DDG 리다이렉트 URL에서 실제 URL 추출 */
private List<Map<String, Object>> searchCatchtable(Page page, String restaurantName) { private String extractDdgUrl(String ddgHref) {
String query = "site:app.catchtable.co.kr " + restaurantName; try {
log.info("[CATCHTABLE] Searching: {}", query); // //duckduckgo.com/l/?uddg=ENCODED_URL&rut=...
if (ddgHref.contains("uddg=")) {
String uddgParam = ddgHref.substring(ddgHref.indexOf("uddg=") + 5);
int ampIdx = uddgParam.indexOf('&');
if (ampIdx > 0) uddgParam = uddgParam.substring(0, ampIdx);
return URLDecoder.decode(uddgParam, StandardCharsets.UTF_8);
}
// 직접 URL인 경우
if (ddgHref.startsWith("http")) return ddgHref;
} catch (Exception e) {
log.debug("[DDG] Failed to extract URL from: {}", ddgHref);
}
return null;
}
String searchUrl = "https://www.google.com/search?q=" + private List<Map<String, Object>> searchTabling(String restaurantName) throws Exception {
URLEncoder.encode(query, StandardCharsets.UTF_8); return searchDuckDuckGo(
page.navigate(searchUrl); "site:tabling.co.kr " + restaurantName,
page.waitForTimeout(3000); "tabling.co.kr/restaurant/", "tabling.co.kr/place/"
);
}
Object linksObj = page.evaluate(""" private List<Map<String, Object>> searchCatchtable(String restaurantName) throws Exception {
() => { return searchDuckDuckGo(
const results = []; "site:app.catchtable.co.kr " + restaurantName,
const links = document.querySelectorAll('a[href]'); "catchtable.co.kr/dining/", "catchtable.co.kr/shop/"
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) { * 한쪽 이름이 다른쪽에 포함되거나, 공통 글자 비율이 40% 이상이면 유사하다고 판단.
if (item instanceof Map<?, ?> map) { */
results.add(Map.of( private boolean isNameSimilar(String restaurantName, String resultTitle) {
"title", String.valueOf(map.get("title")), String a = normalize(restaurantName);
"url", String.valueOf(map.get("url")) String b = normalize(resultTitle);
)); if (a.isEmpty() || b.isEmpty()) return false;
// 포함 관계 체크
if (a.contains(b) || b.contains(a)) return true;
// 공통 문자 비율 (Jaccard-like)
var setA = a.chars().boxed().collect(java.util.stream.Collectors.toSet());
var setB = b.chars().boxed().collect(java.util.stream.Collectors.toSet());
long common = setA.stream().filter(setB::contains).count();
double ratio = (double) common / Math.max(setA.size(), setB.size());
return ratio >= 0.4;
} }
}
} private String normalize(String s) {
log.info("[CATCHTABLE] Found {} results for '{}'", results.size(), restaurantName); if (s == null) return "";
return results; return s.replaceAll("[\\\\-_()\\[\\]【】]", "").toLowerCase();
} }
private void emit(SseEmitter emitter, Map<String, Object> data) { private void emit(SseEmitter emitter, Map<String, Object> data) {

View File

@@ -14,6 +14,9 @@ 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 Integer sortOrder;
private int videoCount; private int videoCount;
private String lastVideoAt; private String lastVideoAt;
} }

View File

@@ -0,0 +1,22 @@
package com.tasteby.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Memo {
private String id;
private String userId;
private String restaurantId;
private Double rating;
private String memoText;
private String visitedAt;
private String createdAt;
private String updatedAt;
private String restaurantName;
}

View File

@@ -22,4 +22,5 @@ public class UserInfo {
private String createdAt; private String createdAt;
private int favoriteCount; private int favoriteCount;
private int reviewCount; private int reviewCount;
private int memoCount;
} }

View File

@@ -21,4 +21,9 @@ 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 updateChannel(@Param("id") String id,
@Param("description") String description,
@Param("tags") String tags,
@Param("sortOrder") Integer sortOrder);
} }

View File

@@ -0,0 +1,32 @@
package com.tasteby.mapper;
import com.tasteby.domain.Memo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface MemoMapper {
Memo findByUserAndRestaurant(@Param("userId") String userId,
@Param("restaurantId") String restaurantId);
void insertMemo(@Param("id") String id,
@Param("userId") String userId,
@Param("restaurantId") String restaurantId,
@Param("rating") Double rating,
@Param("memoText") String memoText,
@Param("visitedAt") String visitedAt);
int updateMemo(@Param("userId") String userId,
@Param("restaurantId") String restaurantId,
@Param("rating") Double rating,
@Param("memoText") String memoText,
@Param("visitedAt") String visitedAt);
int deleteMemo(@Param("userId") String userId,
@Param("restaurantId") String restaurantId);
List<Memo> findByUser(@Param("userId") String userId);
}

View File

@@ -59,6 +59,10 @@ public interface RestaurantMapper {
List<Restaurant> findWithoutCatchtable(); List<Restaurant> findWithoutCatchtable();
void resetTablingUrls();
void resetCatchtableUrls();
List<Map<String, Object>> findForRemapCuisine(); List<Map<String, Object>> findForRemapCuisine();
List<Map<String, Object>> findForRemapFoods(); List<Map<String, Object>> findForRemapFoods();

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, Integer sortOrder) {
mapper.updateChannel(id, description, tags, sortOrder);
}
} }

View File

@@ -0,0 +1,44 @@
package com.tasteby.service;
import com.tasteby.domain.Memo;
import com.tasteby.mapper.MemoMapper;
import com.tasteby.util.IdGenerator;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
@Service
public class MemoService {
private final MemoMapper mapper;
public MemoService(MemoMapper mapper) {
this.mapper = mapper;
}
public Memo findByUserAndRestaurant(String userId, String restaurantId) {
return mapper.findByUserAndRestaurant(userId, restaurantId);
}
@Transactional
public Memo upsert(String userId, String restaurantId, Double rating, String memoText, LocalDate visitedAt) {
String visitedStr = visitedAt != null ? visitedAt.toString() : null;
Memo existing = mapper.findByUserAndRestaurant(userId, restaurantId);
if (existing != null) {
mapper.updateMemo(userId, restaurantId, rating, memoText, visitedStr);
} else {
mapper.insertMemo(IdGenerator.newId(), userId, restaurantId, rating, memoText, visitedStr);
}
return mapper.findByUserAndRestaurant(userId, restaurantId);
}
public boolean delete(String userId, String restaurantId) {
return mapper.deleteMemo(userId, restaurantId) > 0;
}
public List<Memo> findByUser(String userId) {
return mapper.findByUser(userId);
}
}

View File

@@ -34,6 +34,16 @@ public class RestaurantService {
return mapper.findWithoutCatchtable(); return mapper.findWithoutCatchtable();
} }
@Transactional
public void resetTablingUrls() {
mapper.resetTablingUrls();
}
@Transactional
public void resetCatchtableUrls() {
mapper.resetCatchtableUrls();
}
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

@@ -7,17 +7,20 @@
<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="sortOrder" column="sort_order"/>
<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, c.sort_order,
(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
WHERE c.is_active = 1 WHERE c.is_active = 1
ORDER BY c.channel_name ORDER BY c.sort_order, c.channel_name
</select> </select>
<insert id="insert"> <insert id="insert">
@@ -35,6 +38,11 @@
WHERE id = #{id} AND is_active = 1 WHERE id = #{id} AND is_active = 1
</update> </update>
<update id="updateChannel">
UPDATE channels SET description = #{description}, tags = #{tags}, sort_order = #{sortOrder}
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

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tasteby.mapper.MemoMapper">
<resultMap id="memoResultMap" type="com.tasteby.domain.Memo">
<id property="id" column="id"/>
<result property="userId" column="user_id"/>
<result property="restaurantId" column="restaurant_id"/>
<result property="rating" column="rating"/>
<result property="memoText" column="memo_text" typeHandler="com.tasteby.config.ClobTypeHandler"/>
<result property="visitedAt" column="visited_at"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
<result property="restaurantName" column="restaurant_name"/>
</resultMap>
<select id="findByUserAndRestaurant" resultMap="memoResultMap">
SELECT id, user_id, restaurant_id, rating, memo_text,
visited_at, created_at, updated_at
FROM user_memos
WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
</select>
<insert id="insertMemo">
INSERT INTO user_memos (id, user_id, restaurant_id, rating, memo_text, visited_at)
VALUES (#{id}, #{userId}, #{restaurantId}, #{rating}, #{memoText},
<choose>
<when test="visitedAt != null">TO_DATE(#{visitedAt}, 'YYYY-MM-DD')</when>
<otherwise>NULL</otherwise>
</choose>)
</insert>
<update id="updateMemo">
UPDATE user_memos SET
rating = #{rating},
memo_text = #{memoText},
visited_at = <choose>
<when test="visitedAt != null">TO_DATE(#{visitedAt}, 'YYYY-MM-DD')</when>
<otherwise>NULL</otherwise>
</choose>,
updated_at = SYSTIMESTAMP
WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
</update>
<delete id="deleteMemo">
DELETE FROM user_memos WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
</delete>
<select id="findByUser" resultMap="memoResultMap">
SELECT m.id, m.user_id, m.restaurant_id, m.rating, m.memo_text,
m.visited_at, m.created_at, m.updated_at,
r.name AS restaurant_name
FROM user_memos m
LEFT JOIN restaurants r ON r.id = m.restaurant_id
WHERE m.user_id = #{userId}
ORDER BY m.updated_at DESC
</select>
</mapper>

View File

@@ -239,6 +239,14 @@
ORDER BY r.name ORDER BY r.name
</select> </select>
<update id="resetTablingUrls">
UPDATE restaurants SET tabling_url = NULL WHERE tabling_url IS NOT NULL
</update>
<update id="resetCatchtableUrls">
UPDATE restaurants SET catchtable_url = NULL WHERE catchtable_url IS NOT NULL
</update>
<!-- ===== Remap operations ===== --> <!-- ===== Remap operations ===== -->
<update id="updateCuisineType"> <update id="updateCuisineType">

View File

@@ -12,6 +12,7 @@
<result property="createdAt" column="created_at"/> <result property="createdAt" column="created_at"/>
<result property="favoriteCount" column="favorite_count"/> <result property="favoriteCount" column="favorite_count"/>
<result property="reviewCount" column="review_count"/> <result property="reviewCount" column="review_count"/>
<result property="memoCount" column="memo_count"/>
</resultMap> </resultMap>
<select id="findByProviderAndProviderId" resultMap="userResultMap"> <select id="findByProviderAndProviderId" resultMap="userResultMap">
@@ -38,10 +39,12 @@
<select id="findAllWithCounts" resultMap="userResultMap"> <select id="findAllWithCounts" resultMap="userResultMap">
SELECT u.id, u.email, u.nickname, u.avatar_url, u.provider, u.created_at, SELECT u.id, u.email, u.nickname, u.avatar_url, u.provider, u.created_at,
NVL(fav.cnt, 0) AS favorite_count, NVL(fav.cnt, 0) AS favorite_count,
NVL(rev.cnt, 0) AS review_count NVL(rev.cnt, 0) AS review_count,
NVL(memo.cnt, 0) AS memo_count
FROM tasteby_users u FROM tasteby_users u
LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_favorites GROUP BY user_id) fav ON fav.user_id = u.id LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_favorites GROUP BY user_id) fav ON fav.user_id = u.id
LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_reviews GROUP BY user_id) rev ON rev.user_id = u.id LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_reviews GROUP BY user_id) rev ON rev.user_id = u.id
LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_memos GROUP BY user_id) memo ON memo.user_id = u.id
ORDER BY u.created_at DESC ORDER BY u.created_at DESC
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
</select> </select>

16
frontend/dev-restart.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/bash
# Build + restart dev server (standalone mode)
set -euo pipefail
cd "$(dirname "$0")"
echo "▶ Building..."
npm run build
echo "▶ Copying static files to standalone..."
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public 2>/dev/null || true
echo "▶ Restarting PM2..."
pm2 restart tasteby-web
echo "✅ Done — http://localhost:3001"

View File

@@ -0,0 +1,85 @@
# Tasteby Design Concept 후보
> Oracle의 Redwood처럼, Tasteby만의 디자인 언어를 정의하기 위한 컨셉 후보안.
---
## 1. Saffron (사프란) 🟠
따뜻한 금빛 오렌지. 고급스러운 미식 큐레이션 느낌.
| 역할 | 색상 | Hex |
|------|------|-----|
| Primary | 깊은 오렌지 | `#E8720C` |
| Primary Light | 밝은 오렌지 | `#F59E3F` |
| Primary Dark | 진한 오렌지 | `#C45A00` |
| Accent | 골드 | `#F5A623` |
| Accent Light | 라이트 골드 | `#FFD080` |
| Background | 크림 화이트 | `#FFFAF5` |
| Surface | 웜 그레이 | `#F7F3EF` |
| Text Primary | 다크 브라운 | `#2C1810` |
| Text Secondary | 미디엄 브라운 | `#7A6555` |
**키워드**: 프리미엄, 미식, 큐레이션, 따뜻함, 신뢰
**어울리는 폰트**: Pretendard, Noto Sans KR (깔끔 + 웜톤 배경)
---
## 2. Gochujang (고추장) 🔴
한국 음식 DNA. 약간 붉은 오렌지 톤으로 대담하고 강렬.
| 역할 | 색상 | Hex |
|------|------|-----|
| Primary | 고추장 레드 | `#D94F30` |
| Primary Light | 밝은 레드 | `#EF7B5A` |
| Primary Dark | 진한 레드 | `#B53518` |
| Accent | 따뜻한 오렌지 | `#FF8C42` |
| Accent Light | 라이트 피치 | `#FFB88C` |
| Background | 소프트 화이트 | `#FFFBF8` |
| Surface | 웜 베이지 | `#F5F0EB` |
| Text Primary | 차콜 | `#1A1A1A` |
| Text Secondary | 다크 그레이 | `#666052` |
**키워드**: 한국, 활기, 식욕, 대담, 강렬
**어울리는 폰트**: Spoqa Han Sans Neo, Pretendard (모던 + 힘있는)
---
## 3. Citrus (시트러스) 🍊
밝고 상큼한 비비드 오렌지. 현대적이고 친근한 느낌.
| 역할 | 색상 | Hex |
|------|------|-----|
| Primary | 비비드 오렌지 | `#FF6B2B` |
| Primary Light | 라이트 오렌지 | `#FF9A6C` |
| Primary Dark | 딥 오렌지 | `#E04D10` |
| Accent | 피치 | `#FFB347` |
| Accent Light | 소프트 피치 | `#FFD9A0` |
| Background | 퓨어 화이트 | `#FFFFFF` |
| Surface | 쿨 그레이 | `#F5F5F7` |
| Text Primary | 뉴트럴 블랙 | `#171717` |
| Text Secondary | 미디엄 그레이 | `#6B7280` |
**키워드**: 캐주얼, 트렌디, 활발, 친근, 상큼
**어울리는 폰트**: Geist (현재 사용 중), Inter
---
## 현재 상태 (Before)
- Tailwind 기본 `orange` 팔레트 사용 (커스텀 없음)
- 폰트: Geist (Google Fonts)
- 다크모드: `prefers-color-scheme` 기반 자동 전환
- 브랜드 컬러 정의 없음 — 컴포넌트마다 `orange-400~700` 개별 적용
## 적용 계획
1. 컨셉 선택
2. CSS 변수로 디자인 토큰 정의 (`globals.css`)
3. Tailwind v4 `@theme` 에 커스텀 컬러 등록
4. 컴포넌트별 하드코딩된 orange → 시맨틱 토큰으로 교체
5. 다크모드 팔레트 정의
6. 폰트 교체 (필요시)
7. 로고/아이콘 톤 맞춤

View File

@@ -9,10 +9,12 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@react-oauth/google": "^0.13.4", "@react-oauth/google": "^0.13.4",
"@types/supercluster": "^7.1.3",
"@vis.gl/react-google-maps": "^1.7.1", "@vis.gl/react-google-maps": "^1.7.1",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"supercluster": "^8.0.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -1544,6 +1546,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/google.maps": { "node_modules/@types/google.maps": {
"version": "3.58.1", "version": "3.58.1",
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz", "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz",
@@ -1595,6 +1603,15 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.56.1", "version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
@@ -4555,6 +4572,12 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/kdbush": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
"license": "ISC"
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -6086,6 +6109,15 @@
} }
} }
}, },
"node_modules/supercluster": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/supports-color": { "node_modules/supports-color": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",

View File

@@ -10,10 +10,12 @@
}, },
"dependencies": { "dependencies": {
"@react-oauth/google": "^0.13.4", "@react-oauth/google": "^0.13.4",
"@types/supercluster": "^7.1.3",
"@vis.gl/react-google-maps": "^1.7.1", "@vis.gl/react-google-maps": "^1.7.1",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"supercluster": "^8.0.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 KiB

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();
@@ -14,34 +41,38 @@ export default function AdminPage() {
const isAdmin = user?.is_admin === true; const isAdmin = user?.is_admin === true;
if (isLoading) { if (isLoading) {
return <div className="min-h-screen bg-gray-50 flex items-center justify-center text-gray-500"> ...</div>; return <div className="min-h-screen bg-background flex items-center justify-center text-gray-500"> ...</div>;
} }
if (!user) { if (!user) {
return ( return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center"> <div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<p className="text-gray-600 mb-4"> </p> <p className="text-gray-600 mb-4"> </p>
<a href="/" className="text-blue-600 hover:underline"> </a> <a href="/" className="text-brand-600 hover:underline"> </a>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen bg-gray-50 text-gray-900"> <div className="min-h-screen bg-background text-gray-900">
<header className="bg-white border-b px-6 py-4"> <header className="bg-surface border-b border-brand-100 px-6 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-xl font-bold">Tasteby Admin</h1> <img src="/logo-80h.png" alt="Tasteby" className="h-7" />
<span className="text-xl font-bold text-gray-500">Admin</span>
{!isAdmin && ( {!isAdmin && (
<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">
{isAdmin && <CacheFlushButton />}
<a href="/" className="text-sm text-brand-600 hover:underline">
&larr; &larr;
</a> </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) => (
<button <button
@@ -49,8 +80,8 @@ export default function AdminPage() {
onClick={() => setTab(t)} onClick={() => setTab(t)}
className={`px-4 py-2 text-sm rounded-t font-medium ${ className={`px-4 py-2 text-sm rounded-t font-medium ${
tab === t tab === t
? "bg-blue-600 text-white" ? "bg-brand-600 text-white"
: "bg-gray-200 text-gray-700 hover:bg-gray-300" : "bg-brand-50 text-brand-700 hover:bg-brand-100"
}`} }`}
> >
{t === "channels" ? "채널 관리" : t === "videos" ? "영상 관리" : t === "restaurants" ? "식당 관리" : t === "users" ? "유저 관리" : "데몬 설정"} {t === "channels" ? "채널 관리" : t === "videos" ? "영상 관리" : t === "restaurants" ? "식당 관리" : t === "users" ? "유저 관리" : "데몬 설정"}
@@ -101,6 +132,21 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
} }
}; };
const [editingChannel, setEditingChannel] = useState<string | null>(null);
const [editDesc, setEditDesc] = useState("");
const [editTags, setEditTags] = useState("");
const [editOrder, setEditOrder] = useState<number>(99);
const handleSaveChannel = async (id: string) => {
try {
await api.updateChannel(id, { description: editDesc, tags: editTags, sort_order: editOrder });
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 {
@@ -126,66 +172,102 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
return ( return (
<div> <div>
{isAdmin && <div className="bg-white rounded-lg shadow p-4 mb-6"> {isAdmin && <div className="bg-surface rounded-lg shadow p-4 mb-6">
<h2 className="font-semibold mb-3"> </h2> <h2 className="font-semibold mb-3"> </h2>
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
placeholder="YouTube Channel ID" placeholder="YouTube Channel ID"
value={newId} value={newId}
onChange={(e) => setNewId(e.target.value)} onChange={(e) => setNewId(e.target.value)}
className="border rounded px-3 py-2 flex-1 text-sm bg-white text-gray-900" className="border rounded px-3 py-2 flex-1 text-sm bg-surface text-gray-900"
/> />
<input <input
placeholder="채널 이름" placeholder="채널 이름"
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
className="border rounded px-3 py-2 flex-1 text-sm bg-white text-gray-900" className="border rounded px-3 py-2 flex-1 text-sm bg-surface text-gray-900"
/> />
<input <input
placeholder="제목 필터 (선택)" placeholder="제목 필터 (선택)"
value={newFilter} value={newFilter}
onChange={(e) => setNewFilter(e.target.value)} onChange={(e) => setNewFilter(e.target.value)}
className="border rounded px-3 py-2 w-40 text-sm bg-white text-gray-900" className="border rounded px-3 py-2 w-40 text-sm bg-surface text-gray-900"
/> />
<button <button
onClick={handleAdd} onClick={handleAdd}
disabled={loading} disabled={loading}
className="bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700 disabled:opacity-50" className="bg-brand-600 text-white px-4 py-2 rounded text-sm hover:bg-brand-700 disabled:opacity-50"
> >
</button> </button>
</div> </div>
</div>} </div>}
<div className="bg-white rounded-lg shadow"> <div className="bg-surface rounded-lg shadow">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold"> <thead className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm font-semibold">
<tr> <tr>
<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-center 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>
</thead> </thead>
<tbody> <tbody>
{channels.map((ch) => ( {channels.map((ch) => (
<tr key={ch.id} className="border-b hover:bg-gray-50"> <tr key={ch.id} className="border-b hover:bg-brand-50/50">
<td className="px-4 py-3 font-medium">{ch.channel_name}</td> <td className="px-4 py-3 font-medium">{ch.channel_name}</td>
<td className="px-4 py-3 text-gray-500 font-mono text-xs"> <td className="px-4 py-3 text-gray-500 font-mono text-xs">
{ch.channel_id} {ch.channel_id}
</td> </td>
<td className="px-4 py-3 text-sm"> <td className="px-4 py-3 text-sm">
{ch.title_filter ? ( {ch.title_filter ? (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs"> <span className="px-2 py-0.5 bg-brand-50 text-brand-700 rounded text-xs">
{ch.title_filter} {ch.title_filter}
</span> </span>
) : ( ) : (
<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-surface text-gray-900" placeholder="설명" />
) : (
<span className="text-gray-600 cursor-pointer" onClick={() => {
if (!isAdmin) return;
setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || ""); setEditOrder(ch.sort_order ?? 99);
}}>{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-surface text-gray-900" placeholder="태그 (쉼표 구분)" />
<button onClick={() => handleSaveChannel(ch.id)} className="text-brand-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 || ""); setEditOrder(ch.sort_order ?? 99);
}}>{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-center text-xs">
{editingChannel === ch.id ? (
<input type="number" value={editOrder} onChange={(e) => setEditOrder(Number(e.target.value))}
className="border rounded px-2 py-1 text-xs w-14 text-center bg-surface text-gray-900" min={1} />
) : (
<span className="text-gray-500">{ch.sort_order ?? 99}</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,13 +275,10 @@ 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)}
className="text-blue-600 hover:underline text-sm" className="text-brand-600 hover:underline text-sm"
> >
</button> </button>
@@ -660,7 +739,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
const statusColor: Record<string, string> = { const statusColor: Record<string, string> = {
pending: "bg-yellow-100 text-yellow-800", pending: "bg-yellow-100 text-yellow-800",
processing: "bg-blue-100 text-blue-800", processing: "bg-brand-100 text-brand-800",
done: "bg-green-100 text-green-800", done: "bg-green-100 text-green-800",
error: "bg-red-100 text-red-800", error: "bg-red-100 text-red-800",
skip: "bg-gray-100 text-gray-600", skip: "bg-gray-100 text-gray-600",
@@ -672,7 +751,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
<select <select
value={channelFilter} value={channelFilter}
onChange={(e) => { setChannelFilter(e.target.value); setPage(0); }} onChange={(e) => { setChannelFilter(e.target.value); setPage(0); }}
className="border rounded px-3 py-2 text-sm bg-white text-gray-900" className="border rounded px-3 py-2 text-sm bg-surface text-gray-900"
> >
<option value=""> </option> <option value=""> </option>
{channels.map((ch) => ( {channels.map((ch) => (
@@ -682,7 +761,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
<select <select
value={statusFilter} value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)} onChange={(e) => setStatusFilter(e.target.value)}
className="border rounded px-3 py-2 text-sm bg-white text-gray-900" className="border rounded px-3 py-2 text-sm bg-surface text-gray-900"
> >
<option value=""> </option> <option value=""> </option>
<option value="pending"></option> <option value="pending"></option>
@@ -698,7 +777,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
value={titleSearch} value={titleSearch}
onChange={(e) => { setTitleSearch(e.target.value); setPage(0); }} onChange={(e) => { setTitleSearch(e.target.value); setPage(0); }}
onKeyDown={(e) => e.key === "Escape" && setTitleSearch("")} onKeyDown={(e) => e.key === "Escape" && setTitleSearch("")}
className="border border-r-0 rounded-l px-3 py-2 text-sm w-48 bg-white text-gray-900" className="border border-r-0 rounded-l px-3 py-2 text-sm w-48 bg-surface text-gray-900"
/> />
{titleSearch ? ( {titleSearch ? (
<button <button
@@ -730,7 +809,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
<button <button
onClick={() => startBulkStream("transcript")} onClick={() => startBulkStream("transcript")}
disabled={bulkTranscripting || bulkExtracting} disabled={bulkTranscripting || bulkExtracting}
className="bg-orange-600 text-white px-4 py-2 rounded text-sm hover:bg-orange-700 disabled:opacity-50" className="bg-brand-600 text-white px-4 py-2 rounded text-sm hover:bg-brand-700 disabled:opacity-50"
> >
{bulkTranscripting ? "자막 수집 중..." : "벌크 자막 수집"} {bulkTranscripting ? "자막 수집 중..." : "벌크 자막 수집"}
</button> </button>
@@ -758,7 +837,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
<button <button
onClick={startRemapFoods} onClick={startRemapFoods}
disabled={remappingFoods || bulkExtracting || bulkTranscripting || rebuildingVectors || remappingCuisine} disabled={remappingFoods || bulkExtracting || bulkTranscripting || rebuildingVectors || remappingCuisine}
className="bg-orange-600 text-white px-4 py-2 rounded text-sm hover:bg-orange-700 disabled:opacity-50" className="bg-brand-600 text-white px-4 py-2 rounded text-sm hover:bg-brand-700 disabled:opacity-50"
> >
{remappingFoods ? "메뉴태그 재생성 중..." : "메뉴태그 재생성"} {remappingFoods ? "메뉴태그 재생성 중..." : "메뉴태그 재생성"}
</button> </button>
@@ -771,7 +850,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
<button <button
onClick={() => startBulkStream("transcript", Array.from(selected))} onClick={() => startBulkStream("transcript", Array.from(selected))}
disabled={bulkTranscripting || bulkExtracting} disabled={bulkTranscripting || bulkExtracting}
className="bg-orange-500 text-white px-4 py-2 rounded text-sm hover:bg-orange-600 disabled:opacity-50" className="bg-brand-500 text-white px-4 py-2 rounded text-sm hover:bg-brand-600 disabled:opacity-50"
> >
({selected.size}) ({selected.size})
</button> </button>
@@ -802,9 +881,9 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
</span> </span>
</div> </div>
<div className="bg-white rounded-lg shadow overflow-auto min-w-[800px]"> <div className="bg-surface rounded-lg shadow overflow-auto min-w-[800px]">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold"> <thead className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm font-semibold">
<tr> <tr>
<th className="px-4 py-3 w-8"> <th className="px-4 py-3 w-8">
<input <input
@@ -845,7 +924,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
</thead> </thead>
<tbody> <tbody>
{pagedVideos.map((v) => ( {pagedVideos.map((v) => (
<tr key={v.id} className={`border-b hover:bg-gray-50 ${selected.has(v.id) ? "bg-blue-50" : ""}`}> <tr key={v.id} className={`border-b hover:bg-brand-50/50 ${selected.has(v.id) ? "bg-brand-50" : ""}`}>
<td className="px-4 py-3"> <td className="px-4 py-3">
<input <input
type="checkbox" type="checkbox"
@@ -868,7 +947,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
<button <button
onClick={() => handleSelectVideo(v)} onClick={() => handleSelectVideo(v)}
className={`text-left text-sm hover:underline truncate block max-w-full ${ className={`text-left text-sm hover:underline truncate block max-w-full ${
detail?.id === v.id ? "text-blue-800 font-semibold" : "text-blue-600" detail?.id === v.id ? "text-blue-800 font-semibold" : "text-brand-600"
}`} }`}
title={v.title} title={v.title}
> >
@@ -879,7 +958,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
<span title="자막" className={`inline-block w-5 text-center text-xs ${v.has_transcript ? "text-green-600" : "text-gray-300"}`}> <span title="자막" className={`inline-block w-5 text-center text-xs ${v.has_transcript ? "text-green-600" : "text-gray-300"}`}>
{v.has_transcript ? "T" : "-"} {v.has_transcript ? "T" : "-"}
</span> </span>
<span title="LLM 추출" className={`inline-block w-5 text-center text-xs ${v.has_llm ? "text-blue-600" : "text-gray-300"}`}> <span title="LLM 추출" className={`inline-block w-5 text-center text-xs ${v.has_llm ? "text-brand-600" : "text-gray-300"}`}>
{v.has_llm ? "L" : "-"} {v.has_llm ? "L" : "-"}
</span> </span>
</td> </td>
@@ -971,7 +1050,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
{/* 음식종류 재분류 진행 */} {/* 음식종류 재분류 진행 */}
{remapProgress && ( {remapProgress && (
<div className="mt-4 bg-white rounded-lg shadow p-4"> <div className="mt-4 bg-surface rounded-lg shadow p-4">
<h4 className="font-semibold text-sm mb-2"> <h4 className="font-semibold text-sm mb-2">
{remapProgress.current >= remapProgress.total ? "완료" : "진행 중"} {remapProgress.current >= remapProgress.total ? "완료" : "진행 중"}
</h4> </h4>
@@ -989,13 +1068,13 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
{/* 메뉴태그 재생성 진행 */} {/* 메뉴태그 재생성 진행 */}
{foodsProgress && ( {foodsProgress && (
<div className="mt-4 bg-white rounded-lg shadow p-4"> <div className="mt-4 bg-surface rounded-lg shadow p-4">
<h4 className="font-semibold text-sm mb-2"> <h4 className="font-semibold text-sm mb-2">
{foodsProgress.current >= foodsProgress.total ? "완료" : "진행 중"} {foodsProgress.current >= foodsProgress.total ? "완료" : "진행 중"}
</h4> </h4>
<div className="w-full bg-gray-200 rounded-full h-2 mb-2"> <div className="w-full bg-gray-200 rounded-full h-2 mb-2">
<div <div
className="bg-orange-500 h-2 rounded-full transition-all" className="bg-brand-500 h-2 rounded-full transition-all"
style={{ width: `${foodsProgress.total ? (foodsProgress.current / foodsProgress.total) * 100 : 0}%` }} style={{ width: `${foodsProgress.total ? (foodsProgress.current / foodsProgress.total) * 100 : 0}%` }}
/> />
</div> </div>
@@ -1007,7 +1086,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
{/* 벡터 재생성 진행 */} {/* 벡터 재생성 진행 */}
{vectorProgress && ( {vectorProgress && (
<div className="mt-4 bg-white rounded-lg shadow p-4"> <div className="mt-4 bg-surface rounded-lg shadow p-4">
<h4 className="font-semibold text-sm mb-2"> <h4 className="font-semibold text-sm mb-2">
{vectorProgress.phase === "done" ? "완료" : `(${vectorProgress.phase === "prepare" ? "데이터 준비" : "임베딩 저장"})`} {vectorProgress.phase === "done" ? "완료" : `(${vectorProgress.phase === "prepare" ? "데이터 준비" : "임베딩 저장"})`}
</h4> </h4>
@@ -1026,7 +1105,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
{/* 벌크 진행 패널 */} {/* 벌크 진행 패널 */}
{bulkProgress && ( {bulkProgress && (
<div className="mt-4 bg-white rounded-lg shadow p-4"> <div className="mt-4 bg-surface rounded-lg shadow p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h4 className="font-semibold text-sm"> <h4 className="font-semibold text-sm">
{bulkProgress.label} ({bulkProgress.current}/{bulkProgress.total}) {bulkProgress.label} ({bulkProgress.current}/{bulkProgress.total})
@@ -1079,7 +1158,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
<div className="mt-6 text-center text-gray-500 text-sm"> ...</div> <div className="mt-6 text-center text-gray-500 text-sm"> ...</div>
)} )}
{detail && !detailLoading && ( {detail && !detailLoading && (
<div className="mt-6 bg-white rounded-lg shadow p-4"> <div className="mt-6 bg-surface rounded-lg shadow p-4">
<div className="flex items-center justify-between mb-4 gap-2"> <div className="flex items-center justify-between mb-4 gap-2">
{editingTitle ? ( {editingTitle ? (
<div className="flex items-center gap-2 flex-1"> <div className="flex items-center gap-2 flex-1">
@@ -1100,7 +1179,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
finally { setSaving(false); } finally { setSaving(false); }
}} }}
disabled={saving} disabled={saving}
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50" className="px-2 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50"
> >
</button> </button>
@@ -1113,7 +1192,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
</div> </div>
) : ( ) : (
<h3 <h3
className={`font-semibold text-base ${isAdmin ? "cursor-pointer hover:text-blue-600" : ""}`} className={`font-semibold text-base ${isAdmin ? "cursor-pointer hover:text-brand-600" : ""}`}
onClick={isAdmin ? () => { setEditTitle(detail.title); setEditingTitle(true); } : undefined} onClick={isAdmin ? () => { setEditTitle(detail.title); setEditingTitle(true); } : undefined}
title={isAdmin ? "클릭하여 제목 수정" : undefined} title={isAdmin ? "클릭하여 제목 수정" : undefined}
> >
@@ -1212,34 +1291,34 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<label className="text-[10px] text-gray-500"> *</label> <label className="text-[10px] text-gray-500"> *</label>
<input value={manualForm.name} onChange={(e) => setManualForm(f => ({ ...f, name: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="식당 이름" /> <input value={manualForm.name} onChange={(e) => setManualForm(f => ({ ...f, name: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="식당 이름" />
</div> </div>
<div> <div>
<label className="text-[10px] text-gray-500"></label> <label className="text-[10px] text-gray-500"></label>
<input value={manualForm.address} onChange={(e) => setManualForm(f => ({ ...f, address: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="주소 (없으면 지역)" /> <input value={manualForm.address} onChange={(e) => setManualForm(f => ({ ...f, address: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="주소 (없으면 지역)" />
</div> </div>
<div> <div>
<label className="text-[10px] text-gray-500"></label> <label className="text-[10px] text-gray-500"></label>
<input value={manualForm.region} onChange={(e) => setManualForm(f => ({ ...f, region: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="서울 강남" /> <input value={manualForm.region} onChange={(e) => setManualForm(f => ({ ...f, region: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="서울 강남" />
</div> </div>
<div> <div>
<label className="text-[10px] text-gray-500"> </label> <label className="text-[10px] text-gray-500"> </label>
<input value={manualForm.cuisine_type} onChange={(e) => setManualForm(f => ({ ...f, cuisine_type: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="한식, 일식..." /> <input value={manualForm.cuisine_type} onChange={(e) => setManualForm(f => ({ ...f, cuisine_type: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="한식, 일식..." />
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<label className="text-[10px] text-gray-500"></label> <label className="text-[10px] text-gray-500"></label>
<input value={manualForm.foods_mentioned} onChange={(e) => setManualForm(f => ({ ...f, foods_mentioned: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="메뉴1, 메뉴2" /> <input value={manualForm.foods_mentioned} onChange={(e) => setManualForm(f => ({ ...f, foods_mentioned: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="메뉴1, 메뉴2" />
</div> </div>
<div> <div>
<label className="text-[10px] text-gray-500"></label> <label className="text-[10px] text-gray-500"></label>
<input value={manualForm.guests} onChange={(e) => setManualForm(f => ({ ...f, guests: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="게스트1, 게스트2" /> <input value={manualForm.guests} onChange={(e) => setManualForm(f => ({ ...f, guests: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="게스트1, 게스트2" />
</div> </div>
</div> </div>
<div> <div>
<label className="text-[10px] text-gray-500">/</label> <label className="text-[10px] text-gray-500">/</label>
<textarea value={manualForm.evaluation} onChange={(e) => setManualForm(f => ({ ...f, evaluation: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" rows={2} placeholder="맛집 평가 내용" /> <textarea value={manualForm.evaluation} onChange={(e) => setManualForm(f => ({ ...f, evaluation: e.target.value }))} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" rows={2} placeholder="맛집 평가 내용" />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
@@ -1282,7 +1361,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
<textarea <textarea
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
className="w-full border rounded p-2 text-xs font-mono mb-2 bg-white text-gray-900" className="w-full border rounded p-2 text-xs font-mono mb-2 bg-surface text-gray-900"
rows={12} rows={12}
placeholder="프롬프트 템플릿 ({title}, {transcript} 변수 사용)" placeholder="프롬프트 템플릿 ({title}, {transcript} 변수 사용)"
/> />
@@ -1296,39 +1375,39 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
<div className="space-y-2"> <div className="space-y-2">
<div> <div>
<label className="text-xs text-gray-500"></label> <label className="text-xs text-gray-500"></label>
<input value={editRest.name} onChange={(e) => setEditRest({ ...editRest, name: e.target.value })} className="w-full border rounded px-2 py-1 text-sm bg-white text-gray-900" /> <input value={editRest.name} onChange={(e) => setEditRest({ ...editRest, name: e.target.value })} className="w-full border rounded px-2 py-1 text-sm bg-surface text-gray-900" />
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<label className="text-xs text-gray-500"></label> <label className="text-xs text-gray-500"></label>
<input value={editRest.cuisine_type} onChange={(e) => setEditRest({ ...editRest, cuisine_type: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" /> <input value={editRest.cuisine_type} onChange={(e) => setEditRest({ ...editRest, cuisine_type: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" />
</div> </div>
<div> <div>
<label className="text-xs text-gray-500"></label> <label className="text-xs text-gray-500"></label>
<input value={editRest.price_range} onChange={(e) => setEditRest({ ...editRest, price_range: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" /> <input value={editRest.price_range} onChange={(e) => setEditRest({ ...editRest, price_range: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" />
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<label className="text-xs text-gray-500"></label> <label className="text-xs text-gray-500"></label>
<input value={editRest.region} onChange={(e) => setEditRest({ ...editRest, region: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" /> <input value={editRest.region} onChange={(e) => setEditRest({ ...editRest, region: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" />
</div> </div>
<div> <div>
<label className="text-xs text-gray-500"></label> <label className="text-xs text-gray-500"></label>
<input value={editRest.address} onChange={(e) => setEditRest({ ...editRest, address: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" /> <input value={editRest.address} onChange={(e) => setEditRest({ ...editRest, address: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" />
</div> </div>
</div> </div>
<div> <div>
<label className="text-xs text-gray-500"> ( )</label> <label className="text-xs text-gray-500"> ( )</label>
<input value={editRest.foods_mentioned} onChange={(e) => setEditRest({ ...editRest, foods_mentioned: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" placeholder="메뉴1, 메뉴2, ..." /> <input value={editRest.foods_mentioned} onChange={(e) => setEditRest({ ...editRest, foods_mentioned: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" placeholder="메뉴1, 메뉴2, ..." />
</div> </div>
<div> <div>
<label className="text-xs text-gray-500">/</label> <label className="text-xs text-gray-500">/</label>
<textarea value={editRest.evaluation} onChange={(e) => setEditRest({ ...editRest, evaluation: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" rows={2} /> <textarea value={editRest.evaluation} onChange={(e) => setEditRest({ ...editRest, evaluation: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" rows={2} />
</div> </div>
<div> <div>
<label className="text-xs text-gray-500"> ( )</label> <label className="text-xs text-gray-500"> ( )</label>
<input value={editRest.guests} onChange={(e) => setEditRest({ ...editRest, guests: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-white text-gray-900" /> <input value={editRest.guests} onChange={(e) => setEditRest({ ...editRest, guests: e.target.value })} className="w-full border rounded px-2 py-1 text-xs bg-surface text-gray-900" />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
@@ -1359,7 +1438,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
finally { setSaving(false); } finally { setSaving(false); }
}} }}
disabled={saving} disabled={saving}
className="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50" className="px-3 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50"
> >
{saving ? "저장 중..." : "저장"} {saving ? "저장 중..." : "저장"}
</button> </button>
@@ -1373,7 +1452,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
</div> </div>
) : ( ) : (
<div <div
className={`${isAdmin ? "cursor-pointer hover:bg-gray-50" : ""} -m-3 p-3 rounded group`} className={`${isAdmin ? "cursor-pointer hover:bg-brand-50/50" : ""} -m-3 p-3 rounded group`}
onClick={isAdmin ? () => { onClick={isAdmin ? () => {
let evalText = ""; let evalText = "";
if (typeof r.evaluation === "object" && r.evaluation) { if (typeof r.evaluation === "object" && r.evaluation) {
@@ -1437,7 +1516,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
{r.foods_mentioned.length > 0 && ( {r.foods_mentioned.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2"> <div className="flex flex-wrap gap-1 mt-2">
{r.foods_mentioned.map((f, j) => ( {r.foods_mentioned.map((f, j) => (
<span key={j} className="px-1.5 py-0.5 bg-orange-50 text-orange-700 rounded text-xs">{f}</span> <span key={j} className="px-1.5 py-0.5 bg-brand-50 text-brand-700 rounded text-xs">{f}</span>
))} ))}
</div> </div>
)} )}
@@ -1467,7 +1546,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
<select <select
value={transcriptMode} value={transcriptMode}
onChange={(e) => setTranscriptMode(e.target.value as "auto" | "manual" | "generated")} onChange={(e) => setTranscriptMode(e.target.value as "auto" | "manual" | "generated")}
className="border rounded px-2 py-1 text-xs bg-white text-gray-900" className="border rounded px-2 py-1 text-xs bg-surface text-gray-900"
> >
<option value="auto"> ()</option> <option value="auto"> ()</option>
<option value="manual"> </option> <option value="manual"> </option>
@@ -1488,7 +1567,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
} }
}} }}
disabled={fetchingTranscript} disabled={fetchingTranscript}
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50" className="px-2 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50"
> >
{fetchingTranscript ? "가져오는 중..." : detail.transcript ? "다시 가져오기" : "트랜스크립트 가져오기"} {fetchingTranscript ? "가져오는 중..." : detail.transcript ? "다시 가져오기" : "트랜스크립트 가져오기"}
</button> </button>
@@ -1629,7 +1708,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
value={nameSearch} value={nameSearch}
onChange={(e) => { setNameSearch(e.target.value); setPage(0); }} onChange={(e) => { setNameSearch(e.target.value); setPage(0); }}
onKeyDown={(e) => e.key === "Escape" && setNameSearch("")} onKeyDown={(e) => e.key === "Escape" && setNameSearch("")}
className="border border-r-0 rounded-l px-3 py-2 text-sm w-48 bg-white text-gray-900" className="border border-r-0 rounded-l px-3 py-2 text-sm w-48 bg-surface text-gray-900"
/> />
{nameSearch ? ( {nameSearch ? (
<button <button
@@ -1691,10 +1770,42 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
finally { setBulkTabling(false); load(); } finally { setBulkTabling(false); load(); }
}} }}
disabled={bulkTabling} disabled={bulkTabling}
className="px-3 py-1.5 text-xs bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50" className="px-3 py-1.5 text-xs bg-brand-500 text-white rounded hover:bg-brand-600 disabled:opacity-50"
> >
{bulkTabling ? `테이블링 검색 중 (${bulkTablingProgress.current}/${bulkTablingProgress.total})` : "벌크 테이블링 연결"} {bulkTabling ? `테이블링 검색 중 (${bulkTablingProgress.current}/${bulkTablingProgress.total})` : "벌크 테이블링 연결"}
</button> </button>
<button
onClick={async () => {
if (!confirm("테이블링 매핑을 전부 초기화하시겠습니까?")) return;
try {
await fetch("/api/restaurants/reset-tabling", {
method: "DELETE",
headers: { Authorization: `Bearer ${localStorage.getItem("tasteby_token")}` },
});
alert("테이블링 매핑 초기화 완료");
load();
} catch (e) { alert("실패: " + e); }
}}
className="px-3 py-1.5 text-xs bg-red-50 text-red-600 border border-red-200 rounded hover:bg-red-100"
>
</button>
<button
onClick={async () => {
if (!confirm("캐치테이블 매핑을 전부 초기화하시겠습니까?")) return;
try {
await fetch("/api/restaurants/reset-catchtable", {
method: "DELETE",
headers: { Authorization: `Bearer ${localStorage.getItem("tasteby_token")}` },
});
alert("캐치테이블 매핑 초기화 완료");
load();
} catch (e) { alert("실패: " + e); }
}}
className="px-3 py-1.5 text-xs bg-red-50 text-red-600 border border-red-200 rounded hover:bg-red-100"
>
</button>
<button <button
onClick={async () => { onClick={async () => {
const pending = await fetch(`/api/restaurants/catchtable-pending`, { const pending = await fetch(`/api/restaurants/catchtable-pending`, {
@@ -1747,13 +1858,13 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
</span> </span>
</div> </div>
{bulkTabling && bulkTablingProgress.name && ( {bulkTabling && bulkTablingProgress.name && (
<div className="bg-orange-50 rounded p-3 mb-4 text-sm"> <div className="bg-brand-50 rounded p-3 mb-4 text-sm">
<div className="flex justify-between mb-1"> <div className="flex justify-between mb-1">
<span>{bulkTablingProgress.current}/{bulkTablingProgress.total} - {bulkTablingProgress.name}</span> <span>{bulkTablingProgress.current}/{bulkTablingProgress.total} - {bulkTablingProgress.name}</span>
<span className="text-xs text-gray-500">: {bulkTablingProgress.linked} / : {bulkTablingProgress.notFound}</span> <span className="text-xs text-gray-500">: {bulkTablingProgress.linked} / : {bulkTablingProgress.notFound}</span>
</div> </div>
<div className="w-full bg-orange-200 rounded-full h-1.5"> <div className="w-full bg-brand-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 className="bg-brand-500 h-1.5 rounded-full transition-all" style={{ width: `${(bulkTablingProgress.current / bulkTablingProgress.total) * 100}%` }} />
</div> </div>
</div> </div>
)} )}
@@ -1769,9 +1880,9 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
</div> </div>
)} )}
<div className="bg-white rounded-lg shadow overflow-auto"> <div className="bg-surface rounded-lg shadow overflow-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold"> <thead className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm font-semibold">
<tr> <tr>
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("name")}>{sortIcon("name")}</th> <th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("name")}>{sortIcon("name")}</th>
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("region")}>{sortIcon("region")}</th> <th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("region")}>{sortIcon("region")}</th>
@@ -1786,7 +1897,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
<tr <tr
key={r.id} key={r.id}
onClick={() => handleSelect(r)} onClick={() => handleSelect(r)}
className={`border-b cursor-pointer hover:bg-gray-50 ${selected?.id === r.id ? "bg-blue-50" : ""}`} className={`border-b cursor-pointer hover:bg-brand-50/50 ${selected?.id === r.id ? "bg-brand-50" : ""}`}
> >
<td className="px-4 py-3 font-medium">{r.name}</td> <td className="px-4 py-3 font-medium">{r.name}</td>
<td className="px-4 py-3 text-gray-600 text-xs">{r.region || "-"}</td> <td className="px-4 py-3 text-gray-600 text-xs">{r.region || "-"}</td>
@@ -1831,7 +1942,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
{/* 식당 상세/수정 패널 */} {/* 식당 상세/수정 패널 */}
{selected && ( {selected && (
<div className="mt-6 bg-white rounded-lg shadow p-4"> <div className="mt-6 bg-surface rounded-lg shadow p-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-base">{selected.name}</h3> <h3 className="font-semibold text-base">{selected.name}</h3>
<button onClick={() => setSelected(null)} className="text-gray-400 hover:text-gray-600 text-xl leading-none">x</button> <button onClick={() => setSelected(null)} className="text-gray-400 hover:text-gray-600 text-xl leading-none">x</button>
@@ -1853,7 +1964,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
<input <input
value={editForm[key] || ""} value={editForm[key] || ""}
onChange={(e) => setEditForm((f) => ({ ...f, [key]: e.target.value }))} onChange={(e) => setEditForm((f) => ({ ...f, [key]: e.target.value }))}
className="w-full border rounded px-2 py-1.5 text-sm bg-white text-gray-900" className="w-full border rounded px-2 py-1.5 text-sm bg-surface text-gray-900"
disabled={!isAdmin} disabled={!isAdmin}
/> />
</div> </div>
@@ -1871,7 +1982,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
href={`https://www.google.com/maps/place/?q=place_id:${selected.google_place_id}`} href={`https://www.google.com/maps/place/?q=place_id:${selected.google_place_id}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-xs" className="text-brand-600 hover:underline text-xs"
> >
Google Maps에서 Google Maps에서
</a> </a>
@@ -1886,7 +1997,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
<span className="text-xs text-gray-400">-</span> <span className="text-xs text-gray-400">-</span>
) : selected.tabling_url ? ( ) : selected.tabling_url ? (
<a href={selected.tabling_url} target="_blank" rel="noopener noreferrer" <a href={selected.tabling_url} target="_blank" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-xs">{selected.tabling_url}</a> className="text-brand-600 hover:underline text-xs">{selected.tabling_url}</a>
) : ( ) : (
<span className="text-xs text-gray-400"></span> <span className="text-xs text-gray-400"></span>
)} )}
@@ -1909,7 +2020,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
finally { setTablingSearching(false); } finally { setTablingSearching(false); }
}} }}
disabled={tablingSearching} disabled={tablingSearching}
className="px-2 py-0.5 text-[11px] bg-orange-500 text-white rounded hover:bg-orange-600 disabled:opacity-50" className="px-2 py-0.5 text-[11px] bg-brand-500 text-white rounded hover:bg-brand-600 disabled:opacity-50"
> >
{tablingSearching ? "검색 중..." : "테이블링 검색"} {tablingSearching ? "검색 중..." : "테이블링 검색"}
</button> </button>
@@ -1937,7 +2048,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
<span className="text-xs text-gray-400">-</span> <span className="text-xs text-gray-400">-</span>
) : selected.catchtable_url ? ( ) : selected.catchtable_url ? (
<a href={selected.catchtable_url} target="_blank" rel="noopener noreferrer" <a href={selected.catchtable_url} target="_blank" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-xs">{selected.catchtable_url}</a> className="text-brand-600 hover:underline text-xs">{selected.catchtable_url}</a>
) : ( ) : (
<span className="text-xs text-gray-400"></span> <span className="text-xs text-gray-400"></span>
)} )}
@@ -1989,7 +2100,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
<span className="px-1.5 py-0.5 bg-red-50 text-red-600 rounded text-[10px] font-medium shrink-0"> <span className="px-1.5 py-0.5 bg-red-50 text-red-600 rounded text-[10px] font-medium shrink-0">
{v.channel_name} {v.channel_name}
</span> </span>
<a href={v.url} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline truncate"> <a href={v.url} target="_blank" rel="noopener noreferrer" className="text-brand-600 hover:underline truncate">
{v.title} {v.title}
</a> </a>
<span className="text-gray-400 shrink-0">{v.published_at?.slice(0, 10)}</span> <span className="text-gray-400 shrink-0">{v.published_at?.slice(0, 10)}</span>
@@ -2003,7 +2114,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
{isAdmin && <button {isAdmin && <button
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50" className="px-4 py-2 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50"
> >
{saving ? "저장 중..." : "저장"} {saving ? "저장 중..." : "저장"}
</button>} </button>}
@@ -2037,6 +2148,7 @@ interface AdminUser {
created_at: string | null; created_at: string | null;
favorite_count: number; favorite_count: number;
review_count: number; review_count: number;
memo_count: number;
} }
interface UserFavorite { interface UserFavorite {
@@ -2060,6 +2172,16 @@ interface UserReview {
restaurant_name: string | null; restaurant_name: string | null;
} }
interface UserMemo {
id: string;
restaurant_id: string;
rating: number | null;
memo_text: string | null;
visited_at: string | null;
created_at: string;
restaurant_name: string | null;
}
function UsersPanel() { function UsersPanel() {
const [users, setUsers] = useState<AdminUser[]>([]); const [users, setUsers] = useState<AdminUser[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
@@ -2067,6 +2189,7 @@ function UsersPanel() {
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null); const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
const [favorites, setFavorites] = useState<UserFavorite[]>([]); const [favorites, setFavorites] = useState<UserFavorite[]>([]);
const [reviews, setReviews] = useState<UserReview[]>([]); const [reviews, setReviews] = useState<UserReview[]>([]);
const [memos, setMemos] = useState<UserMemo[]>([]);
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const perPage = 20; const perPage = 20;
@@ -2089,17 +2212,20 @@ function UsersPanel() {
setSelectedUser(null); setSelectedUser(null);
setFavorites([]); setFavorites([]);
setReviews([]); setReviews([]);
setMemos([]);
return; return;
} }
setSelectedUser(u); setSelectedUser(u);
setDetailLoading(true); setDetailLoading(true);
try { try {
const [favs, revs] = await Promise.all([ const [favs, revs, mems] = await Promise.all([
api.getAdminUserFavorites(u.id), api.getAdminUserFavorites(u.id),
api.getAdminUserReviews(u.id), api.getAdminUserReviews(u.id),
api.getAdminUserMemos(u.id),
]); ]);
setFavorites(favs); setFavorites(favs);
setReviews(revs); setReviews(revs);
setMemos(mems);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally { } finally {
@@ -2114,14 +2240,15 @@ function UsersPanel() {
<h2 className="text-lg font-bold"> ({total})</h2> <h2 className="text-lg font-bold"> ({total})</h2>
{/* Users Table */} {/* Users Table */}
<div className="bg-white rounded-lg shadow overflow-hidden"> <div className="bg-surface rounded-lg shadow overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-gray-100 border-b text-gray-700 text-sm font-semibold"> <thead className="bg-brand-50 border-b border-brand-100 text-brand-800 text-sm font-semibold">
<tr> <tr>
<th className="text-left px-4 py-2"></th> <th className="text-left px-4 py-2"></th>
<th className="text-left px-4 py-2"></th> <th className="text-left px-4 py-2"></th>
<th className="text-center px-4 py-2"></th> <th className="text-center px-4 py-2"></th>
<th className="text-center px-4 py-2"></th> <th className="text-center px-4 py-2"></th>
<th className="text-center px-4 py-2"></th>
<th className="text-left px-4 py-2"></th> <th className="text-left px-4 py-2"></th>
</tr> </tr>
</thead> </thead>
@@ -2132,8 +2259,8 @@ function UsersPanel() {
onClick={() => handleSelectUser(u)} onClick={() => handleSelectUser(u)}
className={`border-t cursor-pointer transition-colors ${ className={`border-t cursor-pointer transition-colors ${
selectedUser?.id === u.id selectedUser?.id === u.id
? "bg-blue-50" ? "bg-brand-50"
: "hover:bg-gray-50" : "hover:bg-brand-50/50"
}`} }`}
> >
<td className="px-4 py-2"> <td className="px-4 py-2">
@@ -2166,13 +2293,22 @@ function UsersPanel() {
</td> </td>
<td className="px-4 py-2 text-center"> <td className="px-4 py-2 text-center">
{u.review_count > 0 ? ( {u.review_count > 0 ? (
<span className="inline-block px-2 py-0.5 bg-blue-50 text-blue-600 rounded-full text-xs font-medium"> <span className="inline-block px-2 py-0.5 bg-brand-50 text-brand-600 rounded-full text-xs font-medium">
{u.review_count} {u.review_count}
</span> </span>
) : ( ) : (
<span className="text-gray-300">0</span> <span className="text-gray-300">0</span>
)} )}
</td> </td>
<td className="px-4 py-2 text-center">
{u.memo_count > 0 ? (
<span className="inline-block px-2 py-0.5 bg-purple-50 text-purple-600 rounded-full text-xs font-medium">
{u.memo_count}
</span>
) : (
<span className="text-gray-300">0</span>
)}
</td>
<td className="px-4 py-2 text-gray-400 text-xs"> <td className="px-4 py-2 text-gray-400 text-xs">
{u.created_at?.slice(0, 10) || "-"} {u.created_at?.slice(0, 10) || "-"}
</td> </td>
@@ -2207,7 +2343,7 @@ function UsersPanel() {
{/* Selected User Detail */} {/* Selected User Detail */}
{selectedUser && ( {selectedUser && (
<div className="bg-white rounded-lg shadow p-5 space-y-4"> <div className="bg-surface rounded-lg shadow p-5 space-y-4">
<div className="flex items-center gap-3 pb-3 border-b"> <div className="flex items-center gap-3 pb-3 border-b">
{selectedUser.avatar_url ? ( {selectedUser.avatar_url ? (
<img <img
@@ -2232,7 +2368,7 @@ function UsersPanel() {
{detailLoading ? ( {detailLoading ? (
<p className="text-sm text-gray-500"> ...</p> <p className="text-sm text-gray-500"> ...</p>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Favorites */} {/* Favorites */}
<div> <div>
<h3 className="font-semibold text-sm mb-2 text-red-600"> <h3 className="font-semibold text-sm mb-2 text-red-600">
@@ -2274,7 +2410,7 @@ function UsersPanel() {
{/* Reviews */} {/* Reviews */}
<div> <div>
<h3 className="font-semibold text-sm mb-2 text-blue-600"> <h3 className="font-semibold text-sm mb-2 text-brand-600">
({reviews.length}) ({reviews.length})
</h3> </h3>
{reviews.length === 0 ? ( {reviews.length === 0 ? (
@@ -2308,6 +2444,46 @@ function UsersPanel() {
</div> </div>
)} )}
</div> </div>
{/* Memos */}
<div>
<h3 className="font-semibold text-sm mb-2 text-purple-600">
({memos.length})
</h3>
{memos.length === 0 ? (
<p className="text-xs text-gray-400"> .</p>
) : (
<div className="space-y-1 max-h-64 overflow-y-auto">
{memos.map((m) => (
<div
key={m.id}
className="border border-purple-200 rounded px-3 py-2 text-xs space-y-0.5 bg-purple-50/30"
>
<div className="flex items-center justify-between">
<span className="font-medium">
{m.restaurant_name || "알 수 없음"}
</span>
{m.rating && (
<span className="text-yellow-500 shrink-0">
{"★".repeat(Math.round(m.rating))} {m.rating}
</span>
)}
</div>
{m.memo_text && (
<p className="text-gray-600 line-clamp-2">
{m.memo_text}
</p>
)}
<div className="text-gray-400 text-[10px]">
{m.visited_at && `방문: ${m.visited_at} · `}
{m.created_at?.slice(0, 10)}
<span className="ml-1 text-purple-400"></span>
</div>
</div>
))}
</div>
)}
</div>
</div> </div>
)} )}
</div> </div>
@@ -2398,7 +2574,7 @@ function DaemonPanel({ isAdmin }: { isAdmin: boolean }) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Schedule Config */} {/* Schedule Config */}
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-surface rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4"> </h2> <h2 className="text-lg font-semibold mb-4"> </h2>
<p className="text-xs text-gray-500 mb-4"> <p className="text-xs text-gray-500 mb-4">
, . , .
@@ -2491,7 +2667,7 @@ function DaemonPanel({ isAdmin }: { isAdmin: boolean }) {
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 disabled:opacity-50" className="px-4 py-2 bg-brand-600 text-white text-sm rounded hover:bg-brand-700 disabled:opacity-50"
> >
{saving ? "저장 중..." : "설정 저장"} {saving ? "저장 중..." : "설정 저장"}
</button> </button>
@@ -2500,7 +2676,7 @@ function DaemonPanel({ isAdmin }: { isAdmin: boolean }) {
</div> </div>
{/* Manual Triggers */} {/* Manual Triggers */}
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-surface rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4"> </h2> <h2 className="text-lg font-semibold mb-4"> </h2>
<p className="text-xs text-gray-500 mb-4"> <p className="text-xs text-gray-500 mb-4">
. . . .

View File

@@ -1,23 +1,52 @@
@import "tailwindcss"; @import "tailwindcss";
/* Force light mode: dark: classes only activate with .dark ancestor */
@custom-variant dark (&:is(.dark *));
:root { :root {
--background: #ffffff; --background: #FFFAF5;
--foreground: #171717; --foreground: #171717;
color-scheme: light dark; --surface: #FFFFFF;
--brand-50: #FFF8F0;
--brand-100: #FFEDD5;
--brand-200: #FFD6A5;
--brand-300: #FFBC72;
--brand-400: #F5A623;
--brand-500: #F59E3F;
--brand-600: #E8720C;
--brand-700: #C45A00;
--brand-800: #9A4500;
--brand-900: #6B3000;
--brand-950: #3D1A00;
color-scheme: only light !important;
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist); --color-surface: var(--surface);
--color-brand-50: var(--brand-50);
--color-brand-100: var(--brand-100);
--color-brand-200: var(--brand-200);
--color-brand-300: var(--brand-300);
--color-brand-400: var(--brand-400);
--color-brand-500: var(--brand-500);
--color-brand-600: var(--brand-600);
--color-brand-700: var(--brand-700);
--color-brand-800: var(--brand-800);
--color-brand-900: var(--brand-900);
--color-brand-950: var(--brand-950);
--font-sans: var(--font-pretendard), var(--font-geist), system-ui, sans-serif;
} }
@media (prefers-color-scheme: dark) { /* Dark mode CSS vars (disabled — activate by adding .dark class to <html>) */
:root { /*
--background: #0a0a0a; .dark {
--background: #12100E;
--foreground: #ededed; --foreground: #ededed;
--surface: #1C1916;
} }
} */
body { body {
background: var(--background); background: var(--background);
@@ -43,6 +72,31 @@ html, body, #__next {
overflow: auto !important; overflow: auto !important;
} }
/* Hide scrollbar but keep scrolling */
@layer utilities {
.scrollbar-hide {
-ms-overflow-style: none !important;
scrollbar-width: none !important;
overflow: -moz-scrollbars-none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
}
}
/* Material Symbols */
.material-symbols-rounded {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
font-size: inherit;
line-height: 1;
vertical-align: middle;
}
.material-symbols-rounded.filled {
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
/* Safe area for iOS bottom nav */ /* Safe area for iOS bottom nav */
.safe-area-bottom { .safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom, 0px); padding-bottom: env(safe-area-inset-bottom, 0px);

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist } from "next/font/google"; import { Geist } from "next/font/google";
import localFont from "next/font/local";
import "./globals.css"; import "./globals.css";
import { Providers } from "./providers"; import { Providers } from "./providers";
@@ -8,6 +9,14 @@ const geist = Geist({
subsets: ["latin"], subsets: ["latin"],
}); });
const pretendard = localFont({
src: [
{ path: "../fonts/PretendardVariable.woff2", style: "normal" },
],
variable: "--font-pretendard",
display: "swap",
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Tasteby - YouTube Restaurant Map", title: "Tasteby - YouTube Restaurant Map",
description: "YouTube food channel restaurant map service", description: "YouTube food channel restaurant map service",
@@ -19,8 +28,15 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="ko" className="dark:bg-gray-950" suppressHydrationWarning> <html lang="ko" className="bg-background" style={{ colorScheme: "only light" }} suppressHydrationWarning>
<body className={`${geist.variable} font-sans antialiased`}> <head>
<meta name="color-scheme" content="only light" />
<meta name="supported-color-schemes" content="light only" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet" />
</head>
<body className={`${pretendard.variable} ${geist.variable} font-sans antialiased`}>
<Providers>{children}</Providers> <Providers>{children}</Providers>
</body> </body>
</html> </html>

View File

@@ -4,7 +4,7 @@ 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 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, Memo } from "@/lib/api";
import { useAuth } from "@/lib/auth-context"; import { useAuth } from "@/lib/auth-context";
import MapView, { MapBounds, FlyTo } from "@/components/MapView"; import MapView, { MapBounds, FlyTo } from "@/components/MapView";
import SearchBar from "@/components/SearchBar"; import SearchBar from "@/components/SearchBar";
@@ -13,6 +13,45 @@ import RestaurantDetail from "@/components/RestaurantDetail";
import MyReviewsList from "@/components/MyReviewsList"; import MyReviewsList from "@/components/MyReviewsList";
import BottomSheet from "@/components/BottomSheet"; import BottomSheet from "@/components/BottomSheet";
import { getCuisineIcon } from "@/lib/cuisine-icons"; import { getCuisineIcon } from "@/lib/cuisine-icons";
import Icon from "@/components/Icon";
function useDragScroll() {
const ref = useRef<HTMLDivElement>(null);
const drag = useRef({ active: false, startX: 0, sl: 0, moved: false });
const onMouseDown = useCallback((e: React.MouseEvent) => {
const el = ref.current;
if (!el) return;
drag.current = { active: true, startX: e.clientX, sl: el.scrollLeft, moved: false };
el.style.cursor = "grabbing";
}, []);
const onMouseMove = useCallback((e: React.MouseEvent) => {
const d = drag.current;
if (!d.active) return;
const el = ref.current;
if (!el) return;
const dx = e.clientX - d.startX;
if (Math.abs(dx) > 5) d.moved = true;
el.scrollLeft = d.sl - dx;
}, []);
const onMouseUp = useCallback(() => {
drag.current.active = false;
const el = ref.current;
if (el) el.style.cursor = "grab";
}, []);
const onClickCapture = useCallback((e: React.MouseEvent) => {
if (drag.current.moved) {
e.preventDefault();
e.stopPropagation();
drag.current.moved = false;
}
}, []);
return { ref, onMouseDown, onMouseMove, onMouseUp, onMouseLeave: onMouseUp, onClickCapture, style: { cursor: "grab" } as const };
}
const CUISINE_TAXONOMY: { category: string; items: string[] }[] = [ const CUISINE_TAXONOMY: { category: string; items: string[] }[] = [
{ category: "한식", items: ["백반/한정식", "국밥/해장국", "찌개/전골/탕", "삼겹살/돼지구이", "소고기/한우구이", "곱창/막창", "닭/오리구이", "족발/보쌈", "회/횟집", "해산물", "분식", "면", "죽/죽집", "순대/순대국", "장어/민물", "주점/포차", "파인다이닝/코스"] }, { category: "한식", items: ["백반/한정식", "국밥/해장국", "찌개/전골/탕", "삼겹살/돼지구이", "소고기/한우구이", "곱창/막창", "닭/오리구이", "족발/보쌈", "회/횟집", "해산물", "분식", "면", "죽/죽집", "순대/순대국", "장어/민물", "주점/포차", "파인다이닝/코스"] },
@@ -68,7 +107,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) {
@@ -109,7 +148,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)!;
@@ -152,11 +191,14 @@ export default function Home() {
const [showFavorites, setShowFavorites] = useState(false); const [showFavorites, setShowFavorites] = useState(false);
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 [myMemos, setMyMemos] = useState<(Memo & { 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 [userLoc, setUserLoc] = useState<{ lat: number; lng: number }>({ lat: 37.498, lng: 127.0276 });
const [isSearchResult, setIsSearchResult] = useState(false); const [isSearchResult, setIsSearchResult] = useState(false);
const [resetCount, setResetCount] = useState(0); const [resetCount, setResetCount] = useState(0);
const geoApplied = useRef(false); const geoApplied = useRef(false);
const dd = useDragScroll();
const dm = useDragScroll();
const regionTree = useMemo(() => buildRegionTree(restaurants), [restaurants]); const regionTree = useMemo(() => buildRegionTree(restaurants), [restaurants]);
const countries = useMemo(() => [...regionTree.keys()].sort(), [regionTree]); const countries = useMemo(() => [...regionTree.keys()].sort(), [regionTree]);
@@ -223,16 +265,16 @@ export default function Home() {
api.recordVisit().then(() => api.getVisits().then(setVisits)).catch(console.error); api.recordVisit().then(() => api.getVisits().then(setVisits)).catch(console.error);
}, []); }, []);
// Load restaurants on mount and when channel filter changes // Load all restaurants on mount
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
setIsSearchResult(false); setIsSearchResult(false);
api api
.getRestaurants({ limit: 500, channel: channelFilter || undefined }) .getRestaurants({ limit: 500 })
.then(setRestaurants) .then(setRestaurants)
.catch(console.error) .catch(console.error)
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [channelFilter]); }, []);
// Auto-select region from user's geolocation (once) // Auto-select region from user's geolocation (once)
useEffect(() => { useEffect(() => {
@@ -316,6 +358,7 @@ export default function Home() {
setCountryFilter(country); setCountryFilter(country);
setCityFilter(""); setCityFilter("");
setDistrictFilter(""); setDistrictFilter("");
setBoundsFilterOn(false);
if (!country) { setRegionFlyTo(null); return; } if (!country) { setRegionFlyTo(null); return; }
const matched = restaurants.filter((r) => { const matched = restaurants.filter((r) => {
const p = parseRegion(r.region); const p = parseRegion(r.region);
@@ -413,7 +456,7 @@ export default function Home() {
setShowFavorites(false); setShowFavorites(false);
setShowMyReviews(false); setShowMyReviews(false);
setMyReviews([]); setMyReviews([]);
api.getRestaurants({ limit: 500, channel: channelFilter || undefined }).then(setRestaurants); api.getRestaurants({ limit: 500 }).then(setRestaurants);
} }
return; return;
} }
@@ -439,7 +482,7 @@ export default function Home() {
} catch { /* ignore */ } } catch { /* ignore */ }
// 프로필에서는 식당 목록을 원래대로 복원 // 프로필에서는 식당 목록을 원래대로 복원
if (showFavorites) { if (showFavorites) {
api.getRestaurants({ limit: 500, channel: channelFilter || undefined }).then(setRestaurants); api.getRestaurants({ limit: 500 }).then(setRestaurants);
} }
} else { } else {
// 홈 / 식당 목록 - 항상 전체 식당 목록으로 복원 // 홈 / 식당 목록 - 항상 전체 식당 목록으로 복원
@@ -448,16 +491,16 @@ export default function Home() {
setShowMyReviews(false); setShowMyReviews(false);
setMyReviews([]); setMyReviews([]);
if (needReload) { if (needReload) {
const data = await api.getRestaurants({ limit: 500, channel: channelFilter || undefined }); const data = await api.getRestaurants({ limit: 500 });
setRestaurants(data); setRestaurants(data);
} }
} }
}, [user, showFavorites, showMyReviews, channelFilter, mobileTab, handleReset]); }, [user, showFavorites, showMyReviews, mobileTab, handleReset]);
const handleToggleFavorites = async () => { const handleToggleFavorites = async () => {
if (showFavorites) { if (showFavorites) {
setShowFavorites(false); setShowFavorites(false);
const data = await api.getRestaurants({ limit: 500, channel: channelFilter || undefined }); const data = await api.getRestaurants({ limit: 500 });
setRestaurants(data); setRestaurants(data);
} else { } else {
try { try {
@@ -476,10 +519,15 @@ export default function Home() {
if (showMyReviews) { if (showMyReviews) {
setShowMyReviews(false); setShowMyReviews(false);
setMyReviews([]); setMyReviews([]);
setMyMemos([]);
} else { } else {
try { try {
const reviews = await api.getMyReviews(); const [reviews, memos] = await Promise.all([
api.getMyReviews(),
api.getMyMemos(),
]);
setMyReviews(reviews); setMyReviews(reviews);
setMyMemos(memos);
setShowMyReviews(true); setShowMyReviews(true);
setShowFavorites(false); setShowFavorites(false);
setSelected(null); setSelected(null);
@@ -492,7 +540,8 @@ export default function Home() {
const sidebarContent = showMyReviews ? ( const sidebarContent = showMyReviews ? (
<MyReviewsList <MyReviewsList
reviews={myReviews} reviews={myReviews}
onClose={() => { setShowMyReviews(false); setMyReviews([]); }} memos={myMemos}
onClose={() => { setShowMyReviews(false); setMyReviews([]); setMyMemos([]); }}
onSelectRestaurant={async (restaurantId) => { onSelectRestaurant={async (restaurantId) => {
try { try {
const r = await api.getRestaurant(restaurantId); const r = await api.getRestaurant(restaurantId);
@@ -521,7 +570,8 @@ export default function Home() {
const mobileListContent = showMyReviews ? ( const mobileListContent = showMyReviews ? (
<MyReviewsList <MyReviewsList
reviews={myReviews} reviews={myReviews}
onClose={() => { setShowMyReviews(false); setMyReviews([]); }} memos={myMemos}
onClose={() => { setShowMyReviews(false); setMyReviews([]); setMyMemos([]); }}
onSelectRestaurant={async (restaurantId) => { onSelectRestaurant={async (restaurantId) => {
try { try {
const r = await api.getRestaurant(restaurantId); const r = await api.getRestaurant(restaurantId);
@@ -542,53 +592,140 @@ export default function Home() {
); );
return ( return (
<div className="h-screen flex flex-col bg-white dark:bg-gray-950"> <div className="h-screen flex flex-col bg-background">
{/* ── Header row 1: Logo + User ── */} {/* ── Header row 1: Logo + User ── */}
<header className="bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b dark:border-gray-800 shrink-0"> <header className="bg-surface/80 backdrop-blur-md border-b dark:border-gray-800 shrink-0">
<div className="px-5 py-3 flex items-center justify-between"> <div className="px-5 py-3 flex items-center justify-between">
<button onClick={handleReset} className="text-lg font-bold whitespace-nowrap"> <button onClick={handleReset} className="shrink-0">
Tasteby <img src="/logo-80h.png" alt="Tasteby" className="h-7 md:h-8" />
</button> </button>
{/* Desktop: search + filters — two rows */} {/* Desktop: search + filters */}
<div className="hidden md:flex flex-col gap-2.5 mx-6"> <div className="hidden md:flex flex-col gap-2 mx-6 flex-1 min-w-0">
{/* Row 1: Search + dropdown filters */} {/* Row 1: Search + actions */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<div className="w-96 shrink-0"> <div className="w-80 shrink-0">
<SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} /> <SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} />
</div> </div>
<button <button
onClick={handleReset} onClick={handleReset}
className="p-1.5 text-gray-400 dark:text-gray-500 hover:text-orange-500 dark:hover:text-orange-400 transition-colors" className="p-1.5 text-gray-500 dark:text-gray-500 hover:text-brand-500 dark:hover:text-brand-400 transition-colors"
title="초기화" title="초기화"
> >
<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> <Icon name="refresh" size={18} />
</button> </button>
<select <span className="text-xs text-gray-500 dark:text-gray-500 tabular-nums">
value={channelFilter} {filteredRestaurants.length}
onChange={(e) => { </span>
setChannelFilter(e.target.value); <div className="w-px h-4 bg-gray-200 dark:bg-gray-700" />
<button
onClick={() => setViewMode(viewMode === "map" ? "list" : "map")}
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
viewMode === "map"
? "bg-blue-50 dark:bg-blue-900/30 border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400"
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-blue-300 hover:text-blue-500"
}`}
>
<Icon name={viewMode === "map" ? "map" : "list"} size={14} className="mr-0.5" />{viewMode === "map" ? "지도우선" : "목록우선"}
</button>
{user && (
<>
<button
onClick={handleToggleFavorites}
className={`px-2.5 py-1 text-xs rounded-lg 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-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-rose-300 hover:text-rose-500"
}`}
>
<Icon name="favorite" size={14} filled={showFavorites} className="mr-0.5" />{showFavorites ? "내 찜" : "찜"}
</button>
<button
onClick={handleToggleMyReviews}
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
showMyReviews
? "bg-brand-50 dark:bg-brand-900/30 border-brand-300 dark:border-brand-700 text-brand-600 dark:text-brand-400"
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-brand-300 hover:text-brand-500"
}`}
>
{showMyReviews ? "✎ 내 리뷰" : "✎ 리뷰"}
</button>
</>
)}
<div className="flex-1" />
{/* Desktop user area */}
{authLoading ? null : user ? (
<div className="flex items-center gap-2 shrink-0">
{user.avatar_url ? (
<img src={user.avatar_url} alt="" className="w-7 h-7 rounded-full border border-gray-200" />
) : (
<div className="w-7 h-7 rounded-full bg-brand-100 text-brand-700 flex items-center justify-center text-xs font-semibold border border-brand-200">
{(user.nickname || user.email || "?").charAt(0).toUpperCase()}
</div>
)}
<span className="text-xs font-medium text-gray-600 dark:text-gray-300 max-w-[80px] truncate">
{user.nickname || user.email}
</span>
<button
onClick={logout}
className="px-2 py-0.5 text-[10px] text-gray-500 dark:text-gray-500 border border-gray-200 dark:border-gray-700 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 transition-colors"
>
</button>
</div>
) : (
<div className="shrink-0">
<LoginMenu onGoogleSuccess={(credential) => login(credential).catch(console.error)} />
</div>
)}
</div>
{/* Row 2: Channel cards (toggle filter) */}
{!isSearchResult && channels.length > 0 && (
<div ref={dd.ref} onMouseDown={dd.onMouseDown} onMouseMove={dd.onMouseMove} onMouseUp={dd.onMouseUp} onMouseLeave={dd.onMouseLeave} onClickCapture={dd.onClickCapture} style={dd.style} className="overflow-x-auto scrollbar-hide select-none">
<div className="flex gap-2">
{channels.map((ch) => (
<button
key={ch.id}
onClick={() => {
setChannelFilter(channelFilter === ch.channel_name ? "" : ch.channel_name);
setSelected(null); setSelected(null);
setShowDetail(false); 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" 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-brand-50 dark:bg-brand-900/30 border-brand-300 dark:border-brand-700"
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-brand-200 dark:hover:border-brand-800"
}`}
style={{ width: "200px" }}
> >
<option value="">📺 </option> <Icon name="play_circle" size={16} filled className="shrink-0 text-red-500" />
{channels.map((ch) => ( <div className="min-w-0 flex-1">
<option key={ch.id} value={ch.channel_name}> <p className={`text-xs font-semibold truncate ${
📺 {ch.channel_name} channelFilter === ch.channel_name ? "text-brand-600 dark:text-brand-400" : "dark:text-gray-200"
</option> }`}>{ch.channel_name}</p>
{ch.description && <p className="text-[10px] text-gray-500 dark:text-gray-500 truncate">{ch.description}</p>}
</div>
</button>
))} ))}
</select> </div>
</div>
)}
{/* Row 3: Filters — grouped by category */}
<div className="flex items-center gap-1.5 text-xs">
{/* 음식 필터 그룹 */}
<div className="flex items-center gap-1.5 bg-gray-50 dark:bg-gray-800/50 rounded-lg px-2 py-1">
<span className="text-gray-500 dark:text-gray-500 text-[10px] font-medium shrink-0"></span>
<select <select
value={cuisineFilter} value={cuisineFilter}
onChange={(e) => setCuisineFilter(e.target.value)} onChange={(e) => { setCuisineFilter(e.target.value); if (e.target.value) setBoundsFilterOn(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" className={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
cuisineFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"
}`}
> >
<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>
{g.items.map((item) => ( {g.items.map((item) => (
<option key={`${g.category}|${item}`} value={`${g.category}|${item}`}> <option key={`${g.category}|${item}`} value={`${g.category}|${item}`}>
&nbsp;&nbsp;{item} &nbsp;&nbsp;{item}
@@ -597,59 +734,118 @@ export default function Home() {
</optgroup> </optgroup>
))} ))}
</select> </select>
<div className="w-px h-3 bg-gray-200 dark:bg-gray-700" />
<select <select
value={priceFilter} value={priceFilter}
onChange={(e) => setPriceFilter(e.target.value)} onChange={(e) => { setPriceFilter(e.target.value); if (e.target.value) setBoundsFilterOn(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" className={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
priceFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"
}`}
> >
<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>
))} ))}
</select> </select>
{(cuisineFilter || priceFilter) && (
<button
onClick={() => { setCuisineFilter(""); setPriceFilter(""); }}
className="text-gray-400 hover:text-brand-500 transition-colors"
title="음식 필터 초기화"
>
<Icon name="close" size={12} />
</button>
)}
</div> </div>
{/* Row 2: Region filters + Toggle buttons + count */} {/* 지역 필터 그룹 */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-1.5 bg-gray-50 dark:bg-gray-800/50 rounded-lg px-2 py-1">
<span className="text-gray-500 dark:text-gray-500 text-[10px] font-medium shrink-0"></span>
<select <select
value={countryFilter} value={countryFilter}
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={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
countryFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"
}`}
> >
<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>
))} ))}
</select> </select>
{countryFilter && cities.length > 0 && ( {countryFilter && cities.length > 0 && (
<>
<div className="w-px h-3 bg-gray-200 dark:bg-gray-700" />
<select <select
value={cityFilter} value={cityFilter}
onChange={(e) => handleCityChange(e.target.value)} onChange={(e) => handleCityChange(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={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
cityFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"
}`}
> >
<option value="">🏙 /</option> <option value="">/</option>
{cities.map((c) => ( {cities.map((c) => (
<option key={c} value={c}>🏙 {c}</option> <option key={c} value={c}>{c}</option>
))} ))}
</select> </select>
</>
)} )}
{cityFilter && districts.length > 0 && ( {cityFilter && districts.length > 0 && (
<>
<div className="w-px h-3 bg-gray-200 dark:bg-gray-700" />
<select <select
value={districtFilter} value={districtFilter}
onChange={(e) => handleDistrictChange(e.target.value)} onChange={(e) => handleDistrictChange(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={`bg-transparent border-none outline-none cursor-pointer pr-1 ${
districtFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"
}`}
> >
<option value="">🏘 /</option> <option value="">/</option>
{districts.map((d) => ( {districts.map((d) => (
<option key={d} value={d}>🏘 {d}</option> <option key={d} value={d}>{d}</option>
))} ))}
</select> </select>
</>
)} )}
<div className="w-px h-5 bg-gray-200 dark:bg-gray-700" /> {countryFilter && (
<button
onClick={() => { setCountryFilter(""); setCityFilter(""); setDistrictFilter(""); setRegionFlyTo(null); }}
className="text-gray-400 hover:text-brand-500 transition-colors"
title="지역 필터 초기화"
>
<Icon name="close" size={12} />
</button>
)}
</div>
{/* 필터 전체 해제 */}
{(channelFilter || cuisineFilter || priceFilter || countryFilter) && (
<button
onClick={() => {
setChannelFilter("");
setCuisineFilter("");
setPriceFilter("");
setCountryFilter("");
setCityFilter("");
setDistrictFilter("");
setRegionFlyTo(null);
}}
className="flex items-center gap-1 rounded-lg px-2 py-1 bg-gray-50 dark:bg-gray-800/50 text-gray-500 dark:text-gray-400 hover:text-brand-500 transition-colors"
>
<Icon name="close" size={12} />
<span></span>
</button>
)}
{/* 내위치 토글 */}
<button <button
onClick={() => { onClick={() => {
const next = !boundsFilterOn; const next = !boundsFilterOn;
setBoundsFilterOn(next); setBoundsFilterOn(next);
if (next) { if (next) {
// 내위치 ON 시 다른 필터 초기화
setCuisineFilter("");
setPriceFilter("");
setCountryFilter("");
setCityFilter("");
setDistrictFilter("");
if (navigator.geolocation) { if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }), (pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }),
@@ -661,76 +857,54 @@ export default function Home() {
} }
} }
}} }}
className={`px-3 py-1.5 text-sm border rounded-lg transition-colors ${ className={`flex items-center gap-1 rounded-lg px-2 py-1 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-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400"
: "hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400" : "bg-gray-50 dark:bg-gray-800/50 text-gray-500 dark:text-gray-400 hover:text-brand-500"
}`} }`}
title="내 위치 주변 식당만 표시" title="내 위치 주변 식당만 표시"
> >
{boundsFilterOn ? "📍 내위치 ON" : "📍 내위치"} <Icon name="location_on" size={14} />
</button> <span>{boundsFilterOn ? "내위치 ON" : "내위치"}</span>
<button
onClick={() => setViewMode(viewMode === "map" ? "list" : "map")}
className="px-3 py-1.5 text-sm border rounded-lg transition-colors hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-400"
title={viewMode === "map" ? "리스트 우선" : "지도 우선"}
>
{viewMode === "map" ? "🗺 지도" : "☰ 리스트"}
</button> </button>
</div>
</div>
{/* User area (mobile only - desktop moved to Row 1) */}
<div className="shrink-0 flex items-center gap-2 ml-auto md:hidden">
{user && ( {user && (
<> <>
<button <button
onClick={handleToggleFavorites} onClick={handleToggleFavorites}
className={`px-3.5 py-1.5 text-sm rounded-full border transition-colors ${ className={`px-2 py-0.5 text-[10px] rounded-full border transition-colors ${
showFavorites showFavorites
? "bg-rose-50 dark:bg-rose-900/30 border-rose-300 dark:border-rose-700 text-rose-600 dark:text-rose-400" ? "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 hover:bg-gray-100" : "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400"
}`} }`}
> >
{showFavorites ? "♥ 내 찜" : "찜"} <Icon name="favorite" size={14} filled={showFavorites} className="mr-0.5" />{showFavorites ? "찜" : "찜"}
</button> </button>
<button <button
onClick={handleToggleMyReviews} onClick={handleToggleMyReviews}
className={`px-3.5 py-1.5 text-sm rounded-full border transition-colors ${ className={`px-2 py-0.5 text-[10px] rounded-full border transition-colors ${
showMyReviews showMyReviews
? "bg-orange-50 dark:bg-orange-900/30 border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400" ? "bg-brand-50 dark:bg-brand-900/30 border-brand-300 dark:border-brand-700 text-brand-600 dark:text-brand-400"
: "border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-100" : "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400"
}`} }`}
> >
{showMyReviews ? "✎ 리뷰" : "✎ 리뷰"} {showMyReviews ? "✎ 리뷰" : "✎ 리뷰"}
</button> </button>
</> </>
)} )}
<span className="text-sm text-gray-500 whitespace-nowrap">
{filteredRestaurants.length}
</span>
</div>
</div>
{/* User area */}
<div className="shrink-0 flex items-center gap-3 ml-auto">
{authLoading ? null : user ? ( {authLoading ? null : user ? (
<div className="flex items-center gap-2"> <div className="flex items-center">
{user.avatar_url ? ( {user.avatar_url ? (
<img <img src={user.avatar_url} alt="" className="w-7 h-7 rounded-full border border-gray-200" />
src={user.avatar_url}
alt=""
className="w-8 h-8 rounded-full border border-gray-200"
/>
) : ( ) : (
<div className="w-8 h-8 rounded-full bg-orange-100 text-orange-700 flex items-center justify-center text-sm font-semibold border border-orange-200"> <div className="w-7 h-7 rounded-full bg-brand-100 text-brand-700 flex items-center justify-center text-xs font-semibold border border-brand-200">
{(user.nickname || user.email || "?").charAt(0).toUpperCase()} {(user.nickname || user.email || "?").charAt(0).toUpperCase()}
</div> </div>
)} )}
<span className="hidden sm:inline text-sm font-medium text-gray-700 dark:text-gray-300">
{user.nickname || user.email}
</span>
<button
onClick={logout}
className="ml-1 px-2.5 py-1 text-xs text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-700 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
>
</button>
</div> </div>
) : ( ) : (
<LoginMenu onGoogleSuccess={(credential) => login(credential).catch(console.error)} /> <LoginMenu onGoogleSuccess={(credential) => login(credential).catch(console.error)} />
@@ -742,58 +916,58 @@ export default function Home() {
<div className={`md:hidden px-4 pb-3 space-y-2 ${mobileTab === "favorites" || mobileTab === "profile" ? "hidden" : ""}`}> <div className={`md:hidden px-4 pb-3 space-y-2 ${mobileTab === "favorites" || mobileTab === "profile" ? "hidden" : ""}`}>
{/* Row 1: Search */} {/* Row 1: Search */}
<SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} /> <SearchBar key={resetCount} onSearch={handleSearch} isLoading={loading} />
{/* Row 2: Toolbar */} {/* Channel cards - toggle filter */}
<div className="flex items-center gap-2"> {(mobileTab === "home" || mobileTab === "list" || mobileTab === "nearby") && !isSearchResult && channels.length > 0 && (
<div ref={dm.ref} onMouseDown={dm.onMouseDown} onMouseMove={dm.onMouseMove} onMouseUp={dm.onMouseUp} onMouseLeave={dm.onMouseLeave} onClickCapture={dm.onClickCapture} style={dm.style} className="flex gap-2 overflow-x-auto pb-1 -mx-1 px-1 scrollbar-hide select-none">
{channels.map((ch) => (
<button <button
onClick={() => setShowMobileFilters(!showMobileFilters)} key={ch.id}
className={`px-3 py-1.5 text-xs border rounded-lg transition-colors relative ${ onClick={() => {
showMobileFilters || channelFilter || cuisineFilter || priceFilter || countryFilter setChannelFilter(channelFilter === ch.channel_name ? "" : ch.channel_name);
? "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"
}`}
>
{showMobileFilters ? "✕ 닫기" : "🔽 필터"}
{!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">
{[channelFilter, cuisineFilter, priceFilter, countryFilter].filter(Boolean).length}
</span>
)}
</button>
<span className="text-xs text-gray-400 ml-auto">
{filteredRestaurants.length}
</span>
</div>
{/* Collapsible filter panel */}
{showMobileFilters && (
<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 */}
<div className="flex items-center gap-2 flex-wrap">
<select
value={channelFilter}
onChange={(e) => {
setChannelFilter(e.target.value);
setSelected(null); setSelected(null);
setShowDetail(false); 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" className={`shrink-0 rounded-xl px-3 py-2 text-left border transition-all ${
channelFilter === ch.channel_name
? "bg-brand-50 dark:bg-brand-900/30 border-brand-300 dark:border-brand-700"
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700"
}`}
style={{ minWidth: "140px", maxWidth: "170px" }}
> >
<option value="">📺 </option> <div className="flex items-center gap-1.5">
{channels.map((ch) => ( <Icon name="play_circle" size={14} filled className="shrink-0 text-red-500" />
<option key={ch.id} value={ch.channel_name}> <p className={`text-xs font-semibold truncate ${
📺 {ch.channel_name} channelFilter === ch.channel_name ? "text-brand-600 dark:text-brand-400" : "dark:text-gray-200"
</option> }`}>{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>
))} ))}
</select> </div>
)}
</button>
))}
</div>
)}
{/* Row 2: Filters - always visible, 2 lines */}
<div className="space-y-1.5">
{/* Line 1: 음식 장르 + 가격 + 결과수 */}
<div className="flex items-center gap-1.5 text-xs">
<select <select
value={cuisineFilter} value={cuisineFilter}
onChange={(e) => setCuisineFilter(e.target.value)} onChange={(e) => { setCuisineFilter(e.target.value); if (e.target.value) setBoundsFilterOn(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" className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
cuisineFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
}`}
> >
<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>
{g.items.map((item) => ( {g.items.map((item) => (
<option key={`${g.category}|${item}`} value={`${g.category}|${item}`}> <option key={`${g.category}|${item}`} value={`${g.category}|${item}`}>
&nbsp;&nbsp;{item} &nbsp;&nbsp;{item}
@@ -804,36 +978,48 @@ export default function Home() {
</select> </select>
<select <select
value={priceFilter} value={priceFilter}
onChange={(e) => setPriceFilter(e.target.value)} onChange={(e) => { setPriceFilter(e.target.value); if (e.target.value) setBoundsFilterOn(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" className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
priceFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
}`}
> >
<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>
))} ))}
</select> </select>
{(cuisineFilter || priceFilter) && (
<button onClick={() => { setCuisineFilter(""); setPriceFilter(""); }} className="text-gray-400 hover:text-brand-500">
<Icon name="close" size={14} />
</button>
)}
<span className="text-[10px] text-gray-400 ml-auto tabular-nums">{filteredRestaurants.length}</span>
</div> </div>
{/* Region filters */} {/* Line 2: 나라 + 시 + 구 + 내위치 */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-1.5 text-xs">
<select <select
value={countryFilter} value={countryFilter}
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 py-1 bg-white dark:bg-gray-800 ${
countryFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
}`}
> >
<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>
))} ))}
</select> </select>
{countryFilter && cities.length > 0 && ( {countryFilter && cities.length > 0 && (
<select <select
value={cityFilter} value={cityFilter}
onChange={(e) => handleCityChange(e.target.value)} onChange={(e) => handleCityChange(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 py-1 bg-white dark:bg-gray-800 ${
cityFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
}`}
> >
<option value="">🏙 /</option> <option value="">/</option>
{cities.map((c) => ( {cities.map((c) => (
<option key={c} value={c}>🏙 {c}</option> <option key={c} value={c}>{c}</option>
))} ))}
</select> </select>
)} )}
@@ -841,22 +1027,31 @@ export default function Home() {
<select <select
value={districtFilter} value={districtFilter}
onChange={(e) => handleDistrictChange(e.target.value)} onChange={(e) => handleDistrictChange(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 py-1 bg-white dark:bg-gray-800 ${
districtFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
}`}
> >
<option value="">🏘 /</option> <option value="">/</option>
{districts.map((d) => ( {districts.map((d) => (
<option key={d} value={d}>🏘 {d}</option> <option key={d} value={d}>{d}</option>
))} ))}
</select> </select>
)} )}
</div> {countryFilter && (
{/* Toggle buttons */} <button onClick={() => { setCountryFilter(""); setCityFilter(""); setDistrictFilter(""); setRegionFlyTo(null); }} className="text-gray-400 hover:text-brand-500">
<div className="flex items-center gap-2 flex-wrap"> <Icon name="close" size={14} />
</button>
)}
<button <button
onClick={() => { onClick={() => {
const next = !boundsFilterOn; const next = !boundsFilterOn;
setBoundsFilterOn(next); setBoundsFilterOn(next);
if (next) { if (next) {
setCuisineFilter("");
setPriceFilter("");
setCountryFilter("");
setCityFilter("");
setDistrictFilter("");
if (navigator.geolocation) { if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }), (pos) => setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 15 }),
@@ -868,17 +1063,17 @@ export default function Home() {
} }
} }
}} }}
className={`px-2.5 py-1.5 text-xs border rounded-lg transition-colors ${ className={`flex items-center gap-0.5 rounded-lg px-2 py-1 border 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-brand-50 dark:bg-brand-900/30 border-brand-300 dark:border-brand-700 text-brand-600 dark:text-brand-400"
: "text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800" : "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400"
}`} }`}
> >
{boundsFilterOn ? "📍 내위치 ON" : "📍 내위치"} <Icon name="location_on" size={12} />
<span>{boundsFilterOn ? "내위치 ON" : "내위치"}</span>
</button> </button>
</div> </div>
</div> </div>
)}
</div> </div>
</header> </header>
@@ -888,7 +1083,7 @@ export default function Home() {
<div className="hidden md:flex flex-1 overflow-hidden"> <div className="hidden md:flex flex-1 overflow-hidden">
{viewMode === "map" ? ( {viewMode === "map" ? (
<> <>
<aside className="w-80 bg-white dark:bg-gray-950 border-r dark:border-gray-800 overflow-y-auto shrink-0"> <aside className="w-80 bg-surface border-r dark:border-gray-800 overflow-y-auto shrink-0">
{sidebarContent} {sidebarContent}
</aside> </aside>
<main className="flex-1 relative"> <main className="flex-1 relative">
@@ -910,7 +1105,7 @@ export default function Home() {
</> </>
) : ( ) : (
<> <>
<aside className="flex-1 bg-white dark:bg-gray-950 overflow-y-auto"> <aside className="flex-1 bg-surface overflow-y-auto">
{sidebarContent} {sidebarContent}
</aside> </aside>
<main className="w-[40%] shrink-0 relative border-l dark:border-gray-800"> <main className="w-[40%] shrink-0 relative border-l dark:border-gray-800">
@@ -937,9 +1132,8 @@ export default function Home() {
<div className="md:hidden flex-1 flex flex-col overflow-hidden pb-14"> <div className="md:hidden flex-1 flex flex-col overflow-hidden pb-14">
{/* Tab content — takes all remaining space above fixed nav */} {/* Tab content — takes all remaining space above fixed nav */}
{mobileTab === "nearby" ? ( {mobileTab === "nearby" ? (
/* 내주변: 지도 + 리스트 분할, 영역필터 ON */ /* 내주변: 지도만 전체 표시, 영역필터 ON */
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 relative overflow-hidden">
<div className="h-[45%] relative shrink-0">
<MapView <MapView
restaurants={filteredRestaurants} restaurants={filteredRestaurants}
selected={selected} selected={selected}
@@ -949,8 +1143,8 @@ export default function Home() {
onMyLocation={handleMyLocation} onMyLocation={handleMyLocation}
activeChannel={channelFilter || undefined} 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"> <div className="absolute top-2 left-2 bg-surface/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"> <span className="text-xs font-medium text-brand-600 dark:text-brand-400">
{filteredRestaurants.length} {filteredRestaurants.length}
</span> </span>
</div> </div>
@@ -960,13 +1154,9 @@ export default function Home() {
</div> </div>
)} )}
</div> </div>
<div className="flex-1 overflow-y-auto">
{mobileListContent}
</div>
</div>
) : mobileTab === "profile" ? ( ) : mobileTab === "profile" ? (
/* 내정보 */ /* 내정보 */
<div className="flex-1 overflow-y-auto bg-white dark:bg-gray-950"> <div className="flex-1 overflow-y-auto bg-surface">
{!user ? ( {!user ? (
<div className="flex flex-col items-center justify-center h-full gap-4 px-6"> <div className="flex flex-col items-center justify-center h-full gap-4 px-6">
<p className="text-gray-500 dark:text-gray-400 text-sm"> </p> <p className="text-gray-500 dark:text-gray-400 text-sm"> </p>
@@ -986,7 +1176,7 @@ export default function Home() {
{user.avatar_url ? ( {user.avatar_url ? (
<img src={user.avatar_url} alt="" className="w-12 h-12 rounded-full border border-gray-200" /> <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"> <div className="w-12 h-12 rounded-full bg-brand-100 text-brand-700 flex items-center justify-center text-lg font-semibold border border-brand-200">
{(user.nickname || user.email || "?").charAt(0).toUpperCase()} {(user.nickname || user.email || "?").charAt(0).toUpperCase()}
</div> </div>
)} )}
@@ -1011,6 +1201,7 @@ export default function Home() {
) : ( ) : (
<MyReviewsList <MyReviewsList
reviews={myReviews} reviews={myReviews}
memos={myMemos}
onClose={() => {}} onClose={() => {}}
onSelectRestaurant={async (restaurantId) => { onSelectRestaurant={async (restaurantId) => {
try { try {
@@ -1026,7 +1217,7 @@ export default function Home() {
</div> </div>
) : ( ) : (
/* 홈 / 식당 목록 / 찜: 리스트 표시 */ /* 홈 / 식당 목록 / 찜: 리스트 표시 */
<div className="flex-1 overflow-y-auto bg-white dark:bg-gray-950"> <div className="flex-1 overflow-y-auto bg-surface">
{mobileTab === "favorites" && !user ? ( {mobileTab === "favorites" && !user ? (
<div className="flex flex-col items-center justify-center h-full gap-4 px-6"> <div className="flex flex-col items-center justify-center h-full gap-4 px-6">
<p className="text-gray-500 dark:text-gray-400 text-sm"> </p> <p className="text-gray-500 dark:text-gray-400 text-sm"> </p>
@@ -1054,35 +1245,25 @@ export default function Home() {
</div> </div>
{/* ── Mobile Bottom Nav (fixed) ── */} {/* ── Mobile Bottom Nav (fixed) ── */}
<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"> <nav className="md:hidden fixed bottom-0 left-0 right-0 z-40 border-t dark:border-gray-800 bg-surface safe-area-bottom">
<div className="flex items-stretch h-14"> <div className="flex items-stretch h-14">
{([ {([
{ key: "home", label: "홈", icon: ( { key: "home", label: "홈", iconName: "home" },
<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: "식당 목록", iconName: "restaurant_menu" },
)}, { key: "nearby", label: "내주변", iconName: "near_me" },
{ key: "list", label: "식당 목록", icon: ( { key: "favorites", label: "", iconName: "favorite" },
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"><path d="M3 4h18v2H3zm0 7h18v2H3zm0 7h18v2H3z"/></svg> { key: "profile", label: "내정보", iconName: "person" },
)}, ] as { key: "home" | "list" | "nearby" | "favorites" | "profile"; label: string; iconName: string }[]).map((tab) => (
{ 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 <button
key={tab.key} key={tab.key}
onClick={() => handleMobileTab(tab.key)} onClick={() => handleMobileTab(tab.key)}
className={`flex-1 flex flex-col items-center justify-center gap-0.5 py-2 transition-colors ${ className={`flex-1 flex flex-col items-center justify-center gap-0.5 py-2 transition-colors ${
mobileTab === tab.key mobileTab === tab.key
? "text-orange-600 dark:text-orange-400" ? "text-brand-600 dark:text-brand-400"
: "text-gray-400 dark:text-gray-500" : "text-gray-500 dark:text-gray-500"
}`} }`}
> >
{tab.icon} <Icon name={tab.iconName} size={22} filled={mobileTab === tab.key} />
<span className="text-[10px] font-medium">{tab.label}</span> <span className="text-[10px] font-medium">{tab.label}</span>
</button> </button>
))} ))}
@@ -1090,15 +1271,15 @@ export default function Home() {
</nav> </nav>
{/* Desktop Footer */} {/* 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"> <footer className="hidden md:flex shrink-0 border-t dark:border-gray-800 bg-surface/60 backdrop-blur-sm py-2.5 items-center justify-center gap-2 text-[11px] text-gray-500 dark:text-gray-500 group">
<div className="relative"> <div className="relative">
<img <img
src="/icon.jpg" src="/icon.jpg"
alt="SDJ Labs" alt="SDJ Labs"
className="w-6 h-6 rounded-full border-2 border-orange-200 shadow-sm group-hover:scale-110 group-hover:rotate-12 transition-all duration-300" className="w-6 h-6 rounded-full border-2 border-brand-200 shadow-sm group-hover:scale-110 group-hover:rotate-12 transition-all duration-300"
/> />
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-orange-300 rounded-full animate-ping opacity-75" /> <span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-brand-300 rounded-full animate-ping opacity-75" />
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-orange-400 rounded-full" /> <span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-brand-400 rounded-full" />
</div> </div>
<span className="font-medium tracking-wide group-hover:text-gray-600 transition-colors"> <span className="font-medium tracking-wide group-hover:text-gray-600 transition-colors">
SDJ Labs Co., Ltd. SDJ Labs Co., Ltd.

View File

@@ -89,7 +89,7 @@ export default function BottomSheet({ open, onClose, children }: BottomSheetProp
{/* Sheet */} {/* Sheet */}
<div <div
ref={sheetRef} ref={sheetRef}
className="fixed bottom-0 left-0 right-0 z-50 md:hidden flex flex-col bg-white/85 dark:bg-gray-900/90 backdrop-blur-xl rounded-t-2xl shadow-2xl" className="fixed bottom-0 left-0 right-0 z-50 md:hidden flex flex-col bg-surface/85 backdrop-blur-xl rounded-t-2xl shadow-2xl"
style={{ style={{
height: `${height * 100}vh`, height: `${height * 100}vh`,
transition: dragging ? "none" : "height 0.3s cubic-bezier(0.2, 0, 0, 1)", transition: dragging ? "none" : "height 0.3s cubic-bezier(0.2, 0, 0, 1)",

View File

@@ -0,0 +1,23 @@
"use client";
interface IconProps {
name: string;
size?: number;
filled?: boolean;
className?: string;
}
/**
* Material Symbols Rounded icon wrapper.
* Usage: <Icon name="search" size={20} />
*/
export default function Icon({ name, size = 20, filled, className = "" }: IconProps) {
return (
<span
className={`material-symbols-rounded ${filled ? "filled" : ""} ${className}`}
style={{ fontSize: size }}
>
{name}
</span>
);
}

View File

@@ -15,7 +15,7 @@ export default function LoginMenu({ onGoogleSuccess }: LoginMenuProps) {
<> <>
<button <button
onClick={() => setOpen(true)} 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" className="px-3 py-1.5 text-sm font-medium text-gray-600 dark:text-gray-300 hover:text-brand-600 dark:hover:text-brand-400 border border-gray-300 dark:border-gray-600 hover:border-brand-400 dark:hover:border-brand-500 rounded-lg transition-colors"
> >
</button> </button>
@@ -26,7 +26,7 @@ export default function LoginMenu({ onGoogleSuccess }: LoginMenuProps) {
style={{ zIndex: 99999 }} style={{ zIndex: 99999 }}
onClick={(e) => { if (e.target === e.currentTarget) setOpen(false); }} 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="bg-surface rounded-2xl shadow-2xl p-6 mx-4 w-full max-w-xs space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-base font-semibold dark:text-gray-100"></h3> <h3 className="text-base font-semibold dark:text-gray-100"></h3>
<button <button

View File

@@ -8,8 +8,10 @@ import {
InfoWindow, InfoWindow,
useMap, useMap,
} from "@vis.gl/react-google-maps"; } from "@vis.gl/react-google-maps";
import Supercluster from "supercluster";
import type { Restaurant } from "@/lib/api"; import type { Restaurant } from "@/lib/api";
import { getCuisineIcon } from "@/lib/cuisine-icons"; import { getCuisineIcon } from "@/lib/cuisine-icons";
import Icon from "@/components/Icon";
const SEOUL_CENTER = { lat: 37.5665, lng: 126.978 }; const SEOUL_CENTER = { lat: 37.5665, lng: 126.978 };
const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || ""; const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || "";
@@ -61,10 +63,83 @@ interface MapViewProps {
activeChannel?: string; activeChannel?: string;
} }
type RestaurantProps = { restaurant: Restaurant };
type RestaurantFeature = Supercluster.PointFeature<RestaurantProps>;
function useSupercluster(restaurants: Restaurant[]) {
const indexRef = useRef<Supercluster<{ restaurant: Restaurant }> | null>(null);
const points: RestaurantFeature[] = useMemo(
() =>
restaurants.map((r) => ({
type: "Feature" as const,
geometry: { type: "Point" as const, coordinates: [r.longitude, r.latitude] },
properties: { restaurant: r },
})),
[restaurants]
);
const index = useMemo(() => {
const sc = new Supercluster<{ restaurant: Restaurant }>({
radius: 60,
maxZoom: 16,
minPoints: 2,
});
sc.load(points);
indexRef.current = sc;
return sc;
}, [points]);
const getClusters = useCallback(
(bounds: MapBounds, zoom: number) => {
return index.getClusters(
[bounds.west, bounds.south, bounds.east, bounds.north],
Math.floor(zoom)
);
},
[index]
);
const getExpansionZoom = useCallback(
(clusterId: number): number => {
try {
return index.getClusterExpansionZoom(clusterId);
} catch {
return 17;
}
},
[index]
);
return { getClusters, getExpansionZoom, index };
}
function getClusterSize(count: number): number {
if (count < 10) return 36;
if (count < 50) return 42;
if (count < 100) return 48;
return 54;
}
function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeChannel }: Omit<MapViewProps, "onMyLocation" | "onBoundsChanged">) { 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 [zoom, setZoom] = useState(13);
const [bounds, setBounds] = useState<MapBounds | null>(null);
const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]); const channelColors = useMemo(() => getChannelColorMap(restaurants), [restaurants]);
const { getClusters, getExpansionZoom } = useSupercluster(restaurants);
// Build a lookup for restaurants by id
const restaurantMap = useMemo(() => {
const m: Record<string, Restaurant> = {};
restaurants.forEach((r) => { m[r.id] = r; });
return m;
}, [restaurants]);
const clusters = useMemo(() => {
if (!bounds) return [];
return getClusters(bounds, zoom);
}, [bounds, zoom, getClusters]);
const handleMarkerClick = useCallback( const handleMarkerClick = useCallback(
(r: Restaurant) => { (r: Restaurant) => {
@@ -74,6 +149,41 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
[onSelectRestaurant] [onSelectRestaurant]
); );
const handleClusterClick = useCallback(
(clusterId: number, lng: number, lat: number) => {
if (!map) return;
const expansionZoom = Math.min(getExpansionZoom(clusterId), 18);
map.panTo({ lat, lng });
map.setZoom(expansionZoom);
},
[map, getExpansionZoom]
);
// Track camera changes for clustering
useEffect(() => {
if (!map) return;
const listener = map.addListener("idle", () => {
const b = map.getBounds();
const z = map.getZoom();
if (b && z != null) {
const ne = b.getNorthEast();
const sw = b.getSouthWest();
setBounds({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
setZoom(z);
}
});
// Trigger initial bounds
const b = map.getBounds();
const z = map.getZoom();
if (b && z != null) {
const ne = b.getNorthEast();
const sw = b.getSouthWest();
setBounds({ north: ne.lat(), south: sw.lat(), east: ne.lng(), west: sw.lng() });
setZoom(z);
}
return () => google.maps.event.removeListener(listener);
}, [map]);
// Fly to a specific location (region filter) // Fly to a specific location (region filter)
useEffect(() => { useEffect(() => {
if (!map || !flyTo) return; if (!map || !flyTo) return;
@@ -91,7 +201,46 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
return ( return (
<> <>
{restaurants.map((r) => { {clusters.map((feature) => {
const [lng, lat] = feature.geometry.coordinates;
const isCluster = feature.properties && "cluster" in feature.properties && feature.properties.cluster;
if (isCluster) {
const { cluster_id, point_count } = feature.properties as Supercluster.ClusterProperties;
const size = getClusterSize(point_count);
return (
<AdvancedMarker
key={`cluster-${cluster_id}`}
position={{ lat, lng }}
onClick={() => handleClusterClick(cluster_id, lng, lat)}
zIndex={100}
>
<div
style={{
width: size,
height: size,
borderRadius: "50%",
background: "linear-gradient(135deg, #E8720C 0%, #f59e0b 100%)",
border: "3px solid #fff",
boxShadow: "0 2px 8px rgba(0,0,0,0.25)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: size > 42 ? 15 : 13,
fontWeight: 700,
cursor: "pointer",
transition: "transform 0.2s ease",
}}
>
{point_count}
</div>
</AdvancedMarker>
);
}
// Individual marker
const r = (feature.properties as { restaurant: Restaurant }).restaurant;
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 chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0]; const chKey = activeChannel && r.channels?.includes(activeChannel) ? activeChannel : r.channels?.[0];
@@ -124,7 +273,7 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
textDecoration: isClosed ? "line-through" : "none", textDecoration: isClosed ? "line-through" : "none",
}} }}
> >
<span style={{ marginRight: 3 }}>{getCuisineIcon(r.cuisine_type)}</span> <span className="material-symbols-rounded" style={{ fontSize: 14, marginRight: 3, verticalAlign: "middle", color: "#E8720C" }}>{getCuisineIcon(r.cuisine_type)}</span>
{r.name} {r.name}
</div> </div>
<div <div
@@ -149,7 +298,7 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
> >
<div style={{ backgroundColor: "#ffffff", color: "#171717", colorScheme: "light" }} className="max-w-xs p-1"> <div style={{ backgroundColor: "#ffffff", color: "#171717", colorScheme: "light" }} className="max-w-xs p-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="font-bold text-base" style={{ color: "#171717" }}>{getCuisineIcon(infoTarget.cuisine_type)} {infoTarget.name}</h3> <h3 className="font-bold text-base" style={{ color: "#171717" }}><span className="material-symbols-rounded" style={{ fontSize: 18, verticalAlign: "middle", color: "#E8720C", marginRight: 4 }}>{getCuisineIcon(infoTarget.cuisine_type)}</span>{infoTarget.name}</h3>
{infoTarget.business_status === "CLOSED_PERMANENTLY" && ( {infoTarget.business_status === "CLOSED_PERMANENTLY" && (
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold"></span> <span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold"></span>
)} )}
@@ -166,16 +315,16 @@ function MapContent({ restaurants, selected, onSelectRestaurant, flyTo, activeCh
</p> </p>
)} )}
{infoTarget.cuisine_type && ( {infoTarget.cuisine_type && (
<p className="text-sm text-gray-600">{infoTarget.cuisine_type}</p> <p className="text-xs text-gray-500">{infoTarget.cuisine_type}</p>
)} )}
{infoTarget.address && ( {infoTarget.address && (
<p className="text-xs text-gray-500 mt-1">{infoTarget.address}</p> <p className="text-[11px] text-gray-400 mt-1">{infoTarget.address}</p>
)} )}
{infoTarget.price_range && ( {infoTarget.price_range && (
<p className="text-xs text-gray-500">{infoTarget.price_range}</p> <p className="text-[11px] text-gray-400">{infoTarget.price_range}</p>
)} )}
{infoTarget.phone && ( {infoTarget.phone && (
<p className="text-xs text-gray-500">{infoTarget.phone}</p> <p className="text-[11px] text-gray-400">{infoTarget.phone}</p>
)} )}
<button <button
onClick={() => onSelectRestaurant?.(infoTarget)} onClick={() => onSelectRestaurant?.(infoTarget)}
@@ -231,16 +380,14 @@ export default function MapView({ restaurants, selected, onSelectRestaurant, onB
{onMyLocation && ( {onMyLocation && (
<button <button
onClick={onMyLocation} 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" className="absolute top-2 right-2 w-9 h-9 bg-surface rounded-lg shadow-md flex items-center justify-center text-gray-600 dark:text-gray-300 hover:text-brand-500 dark:hover:text-brand-400 transition-colors z-10"
title="내 위치" title="내 위치"
> >
<svg viewBox="0 0 24 24" className="w-5 h-5 fill-current"> <Icon name="my_location" size={20} />
<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> </button>
)} )}
{channelNames.length > 0 && ( {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-surface/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">
<span <span

View File

@@ -0,0 +1,194 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { Memo } from "@/lib/api";
import { useAuth } from "@/lib/auth-context";
import Icon from "@/components/Icon";
interface MemoSectionProps {
restaurantId: string;
}
function StarSelector({
value,
onChange,
}: {
value: number;
onChange: (v: number) => void;
}) {
return (
<div className="flex items-center gap-1">
<span className="text-xs text-gray-500 mr-1">:</span>
{[0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5].map((v) => (
<button
key={v}
type="button"
onClick={() => onChange(v)}
className={`w-6 h-6 text-xs rounded border ${
value === v
? "bg-yellow-500 text-white border-yellow-600"
: "bg-white text-gray-600 border-gray-300 hover:border-yellow-400"
}`}
>
{v}
</button>
))}
</div>
);
}
function StarDisplay({ rating }: { rating: number }) {
const stars = [];
for (let i = 1; i <= 5; i++) {
stars.push(
<span key={i} className={rating >= i - 0.5 ? "text-yellow-500" : "text-gray-300"}>
</span>
);
}
return <span className="text-sm">{stars}</span>;
}
export default function MemoSection({ restaurantId }: MemoSectionProps) {
const { user } = useAuth();
const [memo, setMemo] = useState<Memo | null>(null);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState(false);
// Form state
const [rating, setRating] = useState(3);
const [text, setText] = useState("");
const [visitedAt, setVisitedAt] = useState(new Date().toISOString().slice(0, 10));
const [submitting, setSubmitting] = useState(false);
const loadMemo = useCallback(() => {
if (!user) { setLoading(false); return; }
setLoading(true);
api.getMemo(restaurantId)
.then(setMemo)
.catch(() => setMemo(null))
.finally(() => setLoading(false));
}, [restaurantId, user]);
useEffect(() => {
loadMemo();
}, [loadMemo]);
if (!user) return null;
const startEdit = () => {
if (memo) {
setRating(memo.rating || 3);
setText(memo.memo_text || "");
setVisitedAt(memo.visited_at || new Date().toISOString().slice(0, 10));
} else {
setRating(3);
setText("");
setVisitedAt(new Date().toISOString().slice(0, 10));
}
setEditing(true);
setShowForm(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
try {
const saved = await api.upsertMemo(restaurantId, {
rating,
memo_text: text || undefined,
visited_at: visitedAt || undefined,
});
setMemo(saved);
setShowForm(false);
setEditing(false);
} finally {
setSubmitting(false);
}
};
const handleDelete = async () => {
if (!confirm("메모를 삭제하시겠습니까?")) return;
await api.deleteMemo(restaurantId);
setMemo(null);
};
return (
<div className="mt-4">
<div className="flex items-center gap-2 mb-2">
<Icon name="edit_note" size={18} className="text-brand-600" />
<h3 className="font-semibold text-sm"> </h3>
<span className="text-[10px] text-gray-400 bg-gray-100 px-1.5 py-0.5 rounded"></span>
</div>
{loading ? (
<div className="animate-pulse space-y-2">
<div className="h-3 w-32 bg-gray-200 rounded" />
<div className="h-3 w-full bg-gray-200 rounded" />
</div>
) : showForm ? (
<form onSubmit={handleSubmit} className="space-y-3 border border-brand-200 rounded-lg p-3 bg-brand-50/30">
<StarSelector value={rating} onChange={setRating} />
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="나만의 메모를 작성하세요 (선택)"
className="w-full border rounded p-2 text-sm resize-none"
rows={3}
/>
<div className="flex items-center gap-2">
<label className="text-xs text-gray-500">:</label>
<input
type="date"
value={visitedAt}
onChange={(e) => setVisitedAt(e.target.value)}
className="border rounded px-2 py-1 text-xs"
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={submitting}
className="px-3 py-1 bg-brand-500 text-white text-sm rounded hover:bg-brand-600 disabled:opacity-50"
>
{submitting ? "저장 중..." : editing && memo ? "수정" : "저장"}
</button>
<button
type="button"
onClick={() => { setShowForm(false); setEditing(false); }}
className="px-3 py-1 bg-gray-200 text-gray-700 text-sm rounded hover:bg-gray-300"
>
</button>
</div>
</form>
) : memo ? (
<div className="border border-brand-200 rounded-lg p-3 bg-brand-50/30">
<div className="flex items-center gap-2 mb-1">
{memo.rating && <StarDisplay rating={memo.rating} />}
{memo.visited_at && (
<span className="text-xs text-gray-400">: {memo.visited_at}</span>
)}
</div>
{memo.memo_text && (
<p className="text-sm text-gray-700 mt-1">{memo.memo_text}</p>
)}
<div className="flex gap-2 mt-2">
<button onClick={startEdit} className="text-xs text-blue-600 hover:underline"></button>
<button onClick={handleDelete} className="text-xs text-red-600 hover:underline"></button>
</div>
</div>
) : (
<button
onClick={startEdit}
className="px-3 py-1.5 border border-dashed border-brand-300 text-brand-600 text-sm rounded-lg hover:bg-brand-50 transition-colors"
>
<Icon name="add" size={14} className="mr-0.5" />
</button>
)}
</div>
);
}

View File

@@ -1,36 +1,72 @@
"use client"; "use client";
import type { Review } from "@/lib/api"; import { useState } from "react";
import type { Review, Memo } from "@/lib/api";
import Icon from "@/components/Icon";
interface MyReview extends Review { interface MyReview extends Review {
restaurant_id: string; restaurant_id: string;
restaurant_name: string | null; restaurant_name: string | null;
} }
interface MyMemo extends Memo {
restaurant_name: string | null;
}
interface MyReviewsListProps { interface MyReviewsListProps {
reviews: MyReview[]; reviews: MyReview[];
memos: MyMemo[];
onClose: () => void; onClose: () => void;
onSelectRestaurant: (restaurantId: string) => void; onSelectRestaurant: (restaurantId: string) => void;
} }
export default function MyReviewsList({ export default function MyReviewsList({
reviews, reviews,
memos,
onClose, onClose,
onSelectRestaurant, onSelectRestaurant,
}: MyReviewsListProps) { }: MyReviewsListProps) {
const [tab, setTab] = useState<"reviews" | "memos">("reviews");
return ( return (
<div className="p-4 space-y-3"> <div className="p-4 space-y-3">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h2 className="font-bold text-lg"> ({reviews.length})</h2> <h2 className="font-bold text-lg"> </h2>
<button <button
onClick={onClose} onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-xl leading-none" className="text-gray-400 hover:text-gray-600"
> >
x <Icon name="close" size={18} />
</button> </button>
</div> </div>
{reviews.length === 0 ? ( <div className="flex gap-1 border-b">
<button
onClick={() => setTab("reviews")}
className={`px-3 py-1.5 text-sm font-medium border-b-2 transition-colors ${
tab === "reviews"
? "border-brand-500 text-brand-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
<Icon name="rate_review" size={14} className="mr-1" />
({reviews.length})
</button>
<button
onClick={() => setTab("memos")}
className={`px-3 py-1.5 text-sm font-medium border-b-2 transition-colors ${
tab === "memos"
? "border-brand-500 text-brand-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
<Icon name="edit_note" size={14} className="mr-1" />
({memos.length})
</button>
</div>
{tab === "reviews" ? (
reviews.length === 0 ? (
<p className="text-sm text-gray-500 py-8 text-center"> <p className="text-sm text-gray-500 py-8 text-center">
. .
</p> </p>
@@ -63,6 +99,44 @@ export default function MyReviewsList({
</button> </button>
))} ))}
</div> </div>
)
) : (
memos.length === 0 ? (
<p className="text-sm text-gray-500 py-8 text-center">
.
</p>
) : (
<div className="space-y-2">
{memos.map((m) => (
<button
key={m.id}
onClick={() => onSelectRestaurant(m.restaurant_id)}
className="w-full text-left border border-brand-200 rounded-lg p-3 bg-brand-50/30 hover:bg-brand-50 transition-colors"
>
<div className="flex items-center justify-between mb-1">
<span className="font-semibold text-sm truncate">
{m.restaurant_name || "알 수 없는 식당"}
</span>
{m.rating && (
<span className="text-yellow-500 text-sm shrink-0 ml-2">
{"★".repeat(Math.round(m.rating))}
<span className="text-gray-500 ml-1">{m.rating}</span>
</span>
)}
</div>
{m.memo_text && (
<p className="text-xs text-gray-600 line-clamp-2">
{m.memo_text}
</p>
)}
<div className="flex items-center gap-2 mt-1.5 text-[10px] text-gray-400">
{m.visited_at && <span>: {m.visited_at}</span>}
<span className="text-brand-400"></span>
</div>
</button>
))}
</div>
)
)} )}
</div> </div>
); );

View File

@@ -4,7 +4,9 @@ import { useEffect, useState } from "react";
import { api, getToken } from "@/lib/api"; import { api, getToken } from "@/lib/api";
import type { Restaurant, VideoLink } from "@/lib/api"; import type { Restaurant, VideoLink } from "@/lib/api";
import ReviewSection from "@/components/ReviewSection"; import ReviewSection from "@/components/ReviewSection";
import MemoSection from "@/components/MemoSection";
import { RestaurantDetailSkeleton } from "@/components/Skeleton"; import { RestaurantDetailSkeleton } from "@/components/Skeleton";
import Icon from "@/components/Icon";
interface RestaurantDetailProps { interface RestaurantDetailProps {
restaurant: Restaurant; restaurant: Restaurant;
@@ -50,7 +52,7 @@ export default function RestaurantDetail({
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="text-lg font-bold dark:text-gray-100">{restaurant.name}</h2> <h2 className="text-xl font-bold dark:text-gray-100">{restaurant.name}</h2>
{getToken() && ( {getToken() && (
<button <button
onClick={handleToggleFavorite} onClick={handleToggleFavorite}
@@ -60,7 +62,7 @@ export default function RestaurantDetail({
}`} }`}
title={favorited ? "찜 해제" : "찜하기"} title={favorited ? "찜 해제" : "찜하기"}
> >
{favorited ? "♥" : "♡"} <Icon name="favorite" size={20} filled={favorited} />
</button> </button>
)} )}
{restaurant.business_status === "CLOSED_PERMANENTLY" && ( {restaurant.business_status === "CLOSED_PERMANENTLY" && (
@@ -78,7 +80,7 @@ export default function RestaurantDetail({
onClick={onClose} onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none" className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl leading-none"
> >
x <Icon name="close" size={18} />
</button> </button>
</div> </div>
@@ -93,31 +95,31 @@ export default function RestaurantDetail({
</div> </div>
)} )}
<div className="space-y-1 text-sm dark:text-gray-300"> <div className="space-y-1 text-xs text-gray-500 dark:text-gray-400">
{restaurant.cuisine_type && ( {restaurant.cuisine_type && (
<p> <p>
<span className="text-gray-500 dark:text-gray-400">:</span> {restaurant.cuisine_type} <span className="text-gray-400 dark:text-gray-500"></span> <span className="text-gray-600 dark:text-gray-300">{restaurant.cuisine_type}</span>
</p> </p>
)} )}
{restaurant.address && ( {restaurant.address && (
<p> <p>
<span className="text-gray-500 dark:text-gray-400">:</span> {restaurant.address} <span className="text-gray-400 dark:text-gray-500"></span> <span className="text-gray-600 dark:text-gray-300">{restaurant.address}</span>
</p> </p>
)} )}
{restaurant.region && ( {restaurant.region && (
<p> <p>
<span className="text-gray-500 dark:text-gray-400">:</span> {restaurant.region} <span className="text-gray-400 dark:text-gray-500"></span> <span className="text-gray-600 dark:text-gray-300">{restaurant.region}</span>
</p> </p>
)} )}
{restaurant.price_range && ( {restaurant.price_range && (
<p> <p>
<span className="text-gray-500 dark:text-gray-400">:</span> {restaurant.price_range} <span className="text-gray-400 dark:text-gray-500"></span> <span className="text-gray-600 dark:text-gray-300">{restaurant.price_range}</span>
</p> </p>
)} )}
{restaurant.phone && ( {restaurant.phone && (
<p> <p>
<span className="text-gray-500 dark:text-gray-400">:</span>{" "} <span className="text-gray-400 dark:text-gray-500"></span>{" "}
<a href={`tel:${restaurant.phone}`} className="text-orange-600 dark:text-orange-400 hover:underline"> <a href={`tel:${restaurant.phone}`} className="text-brand-600 dark:text-brand-400 hover:underline">
{restaurant.phone} {restaurant.phone}
</a> </a>
</p> </p>
@@ -125,13 +127,14 @@ export default function RestaurantDetail({
{restaurant.google_place_id && ( {restaurant.google_place_id && (
<p className="flex gap-3"> <p className="flex gap-3">
<a <a
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(restaurant.name)}`} href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(restaurant.name + (restaurant.address ? " " + restaurant.address : restaurant.region ? " " + restaurant.region.replace(/\|/g, " ") : ""))}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-orange-600 dark:text-orange-400 hover:underline text-xs" className="text-brand-600 dark:text-brand-400 hover:underline text-xs"
> >
Google Maps에서 Google Maps에서
</a> </a>
{(!restaurant.region || restaurant.region.split("|")[0] === "한국") && (
<a <a
href={`https://map.naver.com/v5/search/${encodeURIComponent(restaurant.name)}`} href={`https://map.naver.com/v5/search/${encodeURIComponent(restaurant.name)}`}
target="_blank" target="_blank"
@@ -140,6 +143,7 @@ export default function RestaurantDetail({
> >
</a> </a>
)}
</p> </p>
)} )}
</div> </div>
@@ -195,7 +199,7 @@ export default function RestaurantDetail({
<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-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="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> <Icon name="play_circle" size={11} filled className="text-red-400" />
{v.channel_name} {v.channel_name}
</span> </span>
)} )}
@@ -211,9 +215,7 @@ export default function RestaurantDetail({
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm font-medium text-red-600 dark:text-red-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"> <Icon name="play_circle" size={16} filled className="flex-shrink-0" />
<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 && (
@@ -221,7 +223,7 @@ export default function RestaurantDetail({
{v.foods_mentioned.map((f, i) => ( {v.foods_mentioned.map((f, i) => (
<span <span
key={i} key={i}
className="px-2 py-0.5 bg-orange-50 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 rounded text-xs" className="px-2 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400 rounded text-xs"
> >
{f} {f}
</span> </span>
@@ -256,6 +258,7 @@ export default function RestaurantDetail({
)} )}
<ReviewSection restaurantId={restaurant.id} /> <ReviewSection restaurantId={restaurant.id} />
<MemoSection restaurantId={restaurant.id} />
</div> </div>
); );
} }

View File

@@ -2,6 +2,7 @@
import type { Restaurant } from "@/lib/api"; import type { Restaurant } from "@/lib/api";
import { getCuisineIcon } from "@/lib/cuisine-icons"; import { getCuisineIcon } from "@/lib/cuisine-icons";
import Icon from "@/components/Icon";
import { RestaurantListSkeleton } from "@/components/Skeleton"; import { RestaurantListSkeleton } from "@/components/Skeleton";
interface RestaurantListProps { interface RestaurantListProps {
@@ -39,13 +40,13 @@ export default function RestaurantList({
onClick={() => onSelect(r)} onClick={() => onSelect(r)}
className={`w-full text-left p-3 rounded-xl shadow-sm border transition-all hover:shadow-md hover:-translate-y-0.5 ${ className={`w-full text-left p-3 rounded-xl shadow-sm border transition-all hover:shadow-md hover:-translate-y-0.5 ${
selectedId === r.id selectedId === r.id
? "bg-orange-50 dark:bg-orange-900/20 border-orange-300 dark:border-orange-700 shadow-orange-100 dark:shadow-orange-900/10" ? "bg-brand-50 dark:bg-brand-900/20 border-brand-300 dark:border-brand-700 shadow-brand-100 dark:shadow-brand-900/10"
: "bg-white dark:bg-gray-900 border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800" : "bg-surface border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800"
}`} }`}
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<h4 className="font-semibold text-sm dark:text-gray-100"> <h4 className="font-bold text-[15px] text-gray-900 dark:text-gray-100">
<span className="mr-1">{getCuisineIcon(r.cuisine_type)}</span> <Icon name={getCuisineIcon(r.cuisine_type)} size={16} className="mr-0.5 text-brand-600" />
{r.name} {r.name}
</h4> </h4>
{r.rating && ( {r.rating && (
@@ -56,27 +57,27 @@ export default function RestaurantList({
</div> </div>
<div className="flex flex-wrap gap-x-2 gap-y-0.5 mt-1.5 text-xs"> <div className="flex flex-wrap gap-x-2 gap-y-0.5 mt-1.5 text-xs">
{r.cuisine_type && ( {r.cuisine_type && (
<span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-600 dark:text-gray-400">{r.cuisine_type}</span> <span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400">{r.cuisine_type}</span>
)} )}
{r.price_range && ( {r.price_range && (
<span className="px-1.5 py-0.5 bg-gray-50 dark:bg-gray-800 rounded text-gray-600 dark:text-gray-400">{r.price_range}</span> <span className="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-gray-700 dark:text-gray-400">{r.price_range}</span>
)} )}
</div> </div>
{r.region && ( {r.region && (
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500 truncate">{r.region}</p> <p className="mt-1 text-[11px] text-gray-400 dark:text-gray-500 truncate">{r.region}</p>
)} )}
{r.foods_mentioned && r.foods_mentioned.length > 0 && ( {r.foods_mentioned && r.foods_mentioned.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5"> <div className="flex flex-wrap gap-1 mt-1.5">
{r.foods_mentioned.slice(0, 5).map((f, i) => ( {r.foods_mentioned.slice(0, 5).map((f, i) => (
<span <span
key={i} key={i}
className="px-1.5 py-0.5 bg-orange-50 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 rounded text-[10px]" className="px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400 rounded text-[10px]"
> >
{f} {f}
</span> </span>
))} ))}
{r.foods_mentioned.length > 5 && ( {r.foods_mentioned.length > 5 && (
<span className="text-[10px] text-gray-400">+{r.foods_mentioned.length - 5}</span> <span className="text-[10px] text-gray-500">+{r.foods_mentioned.length - 5}</span>
)} )}
</div> </div>
)} )}
@@ -85,8 +86,9 @@ export default function RestaurantList({
{r.channels.map((ch) => ( {r.channels.map((ch) => (
<span <span
key={ch} key={ch}
className="px-1.5 py-0.5 bg-orange-50 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 rounded-full text-[10px] font-medium" className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 rounded-full text-[10px] font-medium"
> >
<Icon name="play_circle" size={11} filled className="shrink-0 text-red-400" />
{ch} {ch}
</span> </span>
))} ))}

View File

@@ -124,7 +124,7 @@ function ReviewForm({
<button <button
type="submit" type="submit"
disabled={submitting} disabled={submitting}
className="px-3 py-1 bg-orange-500 dark:bg-orange-600 text-white text-sm rounded hover:bg-orange-600 dark:hover:bg-orange-500 disabled:opacity-50" className="px-3 py-1 bg-brand-500 dark:bg-brand-600 text-white text-sm rounded hover:bg-brand-600 dark:hover:bg-brand-500 disabled:opacity-50"
> >
{submitting ? "저장 중..." : submitLabel} {submitting ? "저장 중..." : submitLabel}
</button> </button>
@@ -225,7 +225,7 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
{user && !myReview && !showForm && ( {user && !myReview && !showForm && (
<button <button
onClick={() => setShowForm(true)} onClick={() => setShowForm(true)}
className="mb-3 px-3 py-1 bg-orange-500 dark:bg-orange-600 text-white text-sm rounded hover:bg-orange-600 dark:hover:bg-orange-500" className="mb-3 px-3 py-1 bg-brand-500 dark:bg-brand-600 text-white text-sm rounded hover:bg-brand-600 dark:hover:bg-brand-500"
> >
</button> </button>
@@ -234,6 +234,7 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
{showForm && ( {showForm && (
<div className="mb-3"> <div className="mb-3">
<ReviewForm <ReviewForm
initialDate={new Date().toISOString().slice(0, 10)}
onSubmit={handleCreate} onSubmit={handleCreate}
onCancel={() => setShowForm(false)} onCancel={() => setShowForm(false)}
submitLabel="작성" submitLabel="작성"

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import Icon from "@/components/Icon";
interface SearchBarProps { interface SearchBarProps {
onSearch: (query: string, mode: "keyword" | "semantic" | "hybrid") => void; onSearch: (query: string, mode: "keyword" | "semantic" | "hybrid") => void;
@@ -9,40 +10,31 @@ interface SearchBarProps {
export default function SearchBar({ onSearch, isLoading }: SearchBarProps) { export default function SearchBar({ onSearch, isLoading }: SearchBarProps) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [mode, setMode] = useState<"keyword" | "semantic" | "hybrid">("hybrid");
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (query.trim()) { if (query.trim()) {
onSearch(query.trim(), mode); onSearch(query.trim(), "hybrid");
} }
}; };
return ( return (
<form onSubmit={handleSubmit} className="flex gap-1.5 items-center"> <form onSubmit={handleSubmit} className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none">
<Icon name="search" size={16} />
</span>
<input <input
type="text" type="text"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="식당, 지역, 음식..." placeholder="식당, 지역, 음식 검색..."
className="flex-1 min-w-0 px-3 py-2 border border-gray-300 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-400 text-sm bg-white dark:bg-gray-800 dark:text-gray-200 dark:placeholder-gray-500" className="w-full pl-9 pr-3 py-2 bg-gray-100 dark:bg-gray-800 border border-transparent focus:border-brand-400 focus:bg-surface rounded-xl text-sm outline-none transition-all dark:text-gray-200 dark:placeholder-gray-500"
/> />
<select {isLoading && (
value={mode} <div className="absolute right-3 top-1/2 -translate-y-1/2">
onChange={(e) => setMode(e.target.value as typeof mode)} <div className="w-4 h-4 border-2 border-brand-400 border-t-transparent rounded-full animate-spin" />
className="shrink-0 px-2 py-2 border border-gray-300 dark:border-gray-700 rounded-lg text-sm bg-white dark:bg-gray-800 dark:text-gray-300" </div>
> )}
<option value="hybrid"></option>
<option value="keyword"></option>
<option value="semantic"></option>
</select>
<button
type="submit"
disabled={isLoading || !query.trim()}
className="shrink-0 px-3 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 disabled:opacity-50 text-sm"
>
{isLoading ? "..." : "검색"}
</button>
</form> </form>
); );
} }

Binary file not shown.

View File

@@ -70,6 +70,9 @@ 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;
sort_order: number | null;
video_count: number; video_count: number;
last_scanned_at: string | null; last_scanned_at: string | null;
} }
@@ -126,6 +129,17 @@ export interface Review {
user_avatar_url: string | null; user_avatar_url: string | null;
} }
export interface Memo {
id: string;
user_id: string;
restaurant_id: string;
rating: number | null;
memo_text: string | null;
visited_at: string | null;
created_at: string;
updated_at: string;
}
export interface DaemonConfig { export interface DaemonConfig {
scan_enabled: boolean; scan_enabled: boolean;
scan_interval_min: number; scan_interval_min: number;
@@ -253,6 +267,28 @@ export const api = {
); );
}, },
// Memos
getMemo(restaurantId: string) {
return fetchApi<Memo>(`/api/restaurants/${restaurantId}/memo`);
},
upsertMemo(restaurantId: string, data: { rating?: number; memo_text?: string; visited_at?: string }) {
return fetchApi<Memo>(`/api/restaurants/${restaurantId}/memo`, {
method: "POST",
body: JSON.stringify(data),
});
},
deleteMemo(restaurantId: string) {
return fetchApi<void>(`/api/restaurants/${restaurantId}/memo`, {
method: "DELETE",
});
},
getMyMemos() {
return fetchApi<(Memo & { restaurant_name: string | null })[]>("/api/users/me/memos");
},
// Stats // Stats
recordVisit() { recordVisit() {
return fetchApi<{ ok: boolean }>("/api/stats/visit", { method: "POST" }); return fetchApi<{ ok: boolean }>("/api/stats/visit", { method: "POST" });
@@ -278,6 +314,7 @@ export const api = {
created_at: string | null; created_at: string | null;
favorite_count: number; favorite_count: number;
review_count: number; review_count: number;
memo_count: number;
}[]; }[];
total: number; total: number;
}>(`/api/admin/users${qs ? `?${qs}` : ""}`); }>(`/api/admin/users${qs ? `?${qs}` : ""}`);
@@ -312,6 +349,20 @@ export const api = {
>(`/api/admin/users/${userId}/reviews`); >(`/api/admin/users/${userId}/reviews`);
}, },
getAdminUserMemos(userId: string) {
return fetchApi<
{
id: string;
restaurant_id: string;
rating: number | null;
memo_text: string | null;
visited_at: string | null;
created_at: string;
restaurant_name: string | null;
}[]
>(`/api/admin/users/${userId}/memos`);
},
// Admin // Admin
addChannel(channelId: string, channelName: string, titleFilter?: string) { addChannel(channelId: string, channelName: string, titleFilter?: string) {
return fetchApi<{ id: string; channel_id: string }>("/api/channels", { return fetchApi<{ id: string; channel_id: string }>("/api/channels", {
@@ -350,6 +401,13 @@ export const api = {
); );
}, },
updateChannel(id: string, data: { description?: string; tags?: string; sort_order?: number }) {
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",
@@ -497,6 +555,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

@@ -1,39 +1,39 @@
/** /**
* Cuisine type → icon mapping. * Cuisine type → Material Symbols icon name mapping.
* Works with "대분류|소분류" format (e.g. "한식|국밥/해장국"). * Works with "대분류|소분류" format (e.g. "한식|국밥/해장국").
*/ */
const CUISINE_ICON_MAP: Record<string, string> = { const CUISINE_ICON_MAP: Record<string, string> = {
"한식": "🍚", "한식": "rice_bowl",
"일식": "🍣", "일식": "set_meal",
"중식": "🥟", "중식": "ramen_dining",
"양식": "🍝", "양식": "dinner_dining",
"아시아": "🍜", "아시아": "ramen_dining",
"기타": "🍴", "기타": "flatware",
}; };
// Sub-category overrides for more specific icons // Sub-category overrides for more specific icons
const SUB_ICON_RULES: { keyword: string; icon: string }[] = [ const SUB_ICON_RULES: { keyword: string; icon: string }[] = [
{ keyword: "회/횟집", icon: "🐟" }, { keyword: "회/횟집", icon: "set_meal" },
{ keyword: "해산물", icon: "🦐" }, { keyword: "해산물", icon: "set_meal" },
{ keyword: "삼겹살/돼지구이", icon: "🥩" }, { keyword: "삼겹살/돼지구이", icon: "kebab_dining" },
{ keyword: "소고기/한우구이", icon: "🥩" }, { keyword: "소고기/한우구이", icon: "kebab_dining" },
{ keyword: "곱창/막창", icon: "🥩" }, { keyword: "곱창/막창", icon: "kebab_dining" },
{ keyword: "닭/오리구이", icon: "🍗" }, { keyword: "닭/오리구이", icon: "takeout_dining" },
{ keyword: "스테이크", icon: "🥩" }, { keyword: "스테이크", icon: "kebab_dining" },
{ keyword: "햄버거", icon: "🍔" }, { keyword: "햄버거", icon: "lunch_dining" },
{ keyword: "피자", icon: "🍕" }, { keyword: "피자", icon: "local_pizza" },
{ keyword: "카페/디저트", icon: "" }, { keyword: "카페/디저트", icon: "coffee" },
{ keyword: "베이커리", icon: "🥐" }, { keyword: "베이커리", icon: "bakery_dining" },
{ keyword: "치킨", icon: "🍗" }, { keyword: "치킨", icon: "takeout_dining" },
{ keyword: "주점/포차", icon: "🍺" }, { keyword: "주점/포차", icon: "local_bar" },
{ keyword: "이자카야", icon: "🍶" }, { keyword: "이자카야", icon: "sake" },
{ keyword: "라멘", icon: "🍜" }, { keyword: "라멘", icon: "ramen_dining" },
{ keyword: "국밥/해장국", icon: "🍲" }, { keyword: "국밥/해장국", icon: "soup_kitchen" },
{ keyword: "분식", icon: "🍜" }, { keyword: "분식", icon: "ramen_dining" },
]; ];
const DEFAULT_ICON = "🍴"; const DEFAULT_ICON = "flatware";
export function getCuisineIcon(cuisineType: string | null | undefined): string { export function getCuisineIcon(cuisineType: string | null | undefined): string {
if (!cuisineType) return DEFAULT_ICON; if (!cuisineType) return DEFAULT_ICON;