Compare commits
15 Commits
2a0ee1d2cc
...
v0.1.12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2861b6b79 | ||
|
|
dda0da52c4 | ||
|
|
18776b9b4b | ||
|
|
177532e6e7 | ||
|
|
64d58cb553 | ||
|
|
a766a74f20 | ||
|
|
4b1f7c13b7 | ||
|
|
75e0066dbe | ||
|
|
3134994817 | ||
|
|
88c1b4243e | ||
|
|
824c171158 | ||
|
|
4f8b4f435e | ||
|
|
50018c17fa | ||
|
|
ec8330a978 | ||
|
|
e85e135c8b |
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,9 +77,10 @@ public class ChannelController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, String> body) {
|
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
|
||||||
AuthUtil.requireAdmin();
|
AuthUtil.requireAdmin();
|
||||||
channelService.update(id, body.get("description"), body.get("tags"));
|
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();
|
cache.flush();
|
||||||
return Map.of("ok", true);
|
return Map.of("ok", true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("[\\s·\\-_()()\\[\\]【】]", "").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void emit(SseEmitter emitter, Map<String, Object> data) {
|
private void emit(SseEmitter emitter, Map<String, Object> data) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public class Channel {
|
|||||||
private String titleFilter;
|
private String titleFilter;
|
||||||
private String description;
|
private String description;
|
||||||
private String tags;
|
private String tags;
|
||||||
|
private Integer sortOrder;
|
||||||
private int videoCount;
|
private int videoCount;
|
||||||
private String lastVideoAt;
|
private String lastVideoAt;
|
||||||
}
|
}
|
||||||
|
|||||||
22
backend-java/src/main/java/com/tasteby/domain/Memo.java
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ public interface ChannelMapper {
|
|||||||
|
|
||||||
Channel findByChannelId(@Param("channelId") String channelId);
|
Channel findByChannelId(@Param("channelId") String channelId);
|
||||||
|
|
||||||
void updateDescriptionTags(@Param("id") String id,
|
void updateChannel(@Param("id") String id,
|
||||||
@Param("description") String description,
|
@Param("description") String description,
|
||||||
@Param("tags") String tags);
|
@Param("tags") String tags,
|
||||||
|
@Param("sortOrder") Integer sortOrder);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public class ChannelService {
|
|||||||
return mapper.findByChannelId(channelId);
|
return mapper.findByChannelId(channelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void update(String id, String description, String tags) {
|
public void update(String id, String description, String tags, Integer sortOrder) {
|
||||||
mapper.updateDescriptionTags(id, description, tags);
|
mapper.updateChannel(id, description, tags, sortOrder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -9,17 +9,18 @@
|
|||||||
<result property="titleFilter" column="title_filter"/>
|
<result property="titleFilter" column="title_filter"/>
|
||||||
<result property="description" column="description"/>
|
<result property="description" column="description"/>
|
||||||
<result property="tags" column="tags"/>
|
<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, c.description, c.tags,
|
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">
|
||||||
@@ -37,8 +38,8 @@
|
|||||||
WHERE id = #{id} AND is_active = 1
|
WHERE id = #{id} AND is_active = 1
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
<update id="updateDescriptionTags">
|
<update id="updateChannel">
|
||||||
UPDATE channels SET description = #{description}, tags = #{tags}
|
UPDATE channels SET description = #{description}, tags = #{tags}, sort_order = #{sortOrder}
|
||||||
WHERE id = #{id}
|
WHERE id = #{id}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
262
docs/deployment-guide.md
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
# Tasteby 배포 가이드
|
||||||
|
|
||||||
|
## 환경 요약
|
||||||
|
|
||||||
|
| 항목 | Dev (개발) | Prod (운영) |
|
||||||
|
|------|-----------|-------------|
|
||||||
|
| URL | dev.tasteby.net | www.tasteby.net |
|
||||||
|
| 호스트 | 로컬 Mac mini | OKE (Oracle Kubernetes Engine) |
|
||||||
|
| 프로세스 관리 | PM2 | Kubernetes Deployment |
|
||||||
|
| 프론트엔드 실행 | `npm run dev` (Next.js dev server) | `node server.js` (standalone 빌드) |
|
||||||
|
| 백엔드 실행 | `./gradlew bootRun` | `java -jar app.jar` (bootJar 빌드) |
|
||||||
|
| Redis | 로컬 Redis 서버 | K8s Pod (redis:7-alpine) |
|
||||||
|
| TLS | Nginx(192.168.0.147) + Certbot | cert-manager + Let's Encrypt |
|
||||||
|
| 리버스 프록시 | Nginx (192.168.0.147 → 192.168.0.208) | Nginx Ingress Controller (K8s) |
|
||||||
|
| 도메인 DNS | dev.tasteby.net → Mac mini IP | www.tasteby.net → OCI NLB 217.142.131.194 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Dev 환경 (dev.tasteby.net)
|
||||||
|
|
||||||
|
### 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
브라우저 → dev.tasteby.net (HTTPS)
|
||||||
|
↓
|
||||||
|
Nginx (192.168.0.147) — Certbot Let's Encrypt TLS
|
||||||
|
├── /api/* → proxy_pass http://192.168.0.208:8000 (tasteby-api)
|
||||||
|
└── /* → proxy_pass http://192.168.0.208:3001 (tasteby-web)
|
||||||
|
↓
|
||||||
|
Mac mini (192.168.0.208) — PM2 프로세스 매니저
|
||||||
|
├── tasteby-api → ./gradlew bootRun (:8000)
|
||||||
|
└── tasteby-web → npm run dev (:3001)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **192.168.0.147**: Nginx 리버스 프록시 서버 (TLS 종료, Certbot 자동 갱신)
|
||||||
|
- **192.168.0.208**: Mac mini (실제 앱 서버, PM2 관리)
|
||||||
|
|
||||||
|
### PM2 프로세스 구성 (ecosystem.config.js)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: "tasteby-api",
|
||||||
|
cwd: "/Users/joungmin/workspaces/tasteby/backend-java",
|
||||||
|
script: "./start.sh", // gradlew bootRun 실행
|
||||||
|
interpreter: "/bin/bash",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tasteby-web",
|
||||||
|
cwd: "/Users/joungmin/workspaces/tasteby/frontend",
|
||||||
|
script: "npm",
|
||||||
|
args: "run dev", // ⚠️ 절대 standalone으로 바꾸지 말 것!
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 백엔드 start.sh
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
export JAVA_HOME="/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home"
|
||||||
|
export PATH="/opt/homebrew/opt/openjdk@21/bin:$PATH"
|
||||||
|
set -a
|
||||||
|
source /Users/joungmin/workspaces/tasteby/backend/.env # 환경변수 로드
|
||||||
|
set +a
|
||||||
|
exec ./gradlew bootRun
|
||||||
|
```
|
||||||
|
|
||||||
|
### 코드 수정 후 반영 방법
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 프론트엔드: npm run dev라서 코드 수정 시 자동 Hot Reload (재시작 불필요)
|
||||||
|
|
||||||
|
# 백엔드: 코드 수정 후 재시작 필요
|
||||||
|
pm2 restart tasteby-api
|
||||||
|
|
||||||
|
# 전체 재시작
|
||||||
|
pm2 restart tasteby-api tasteby-web
|
||||||
|
|
||||||
|
# PM2 상태 확인
|
||||||
|
pm2 status
|
||||||
|
|
||||||
|
# 로그 확인
|
||||||
|
pm2 logs tasteby-api --lines 50
|
||||||
|
pm2 logs tasteby-web --lines 50
|
||||||
|
```
|
||||||
|
|
||||||
|
### 주의사항
|
||||||
|
|
||||||
|
- `tasteby-web`은 반드시 `npm run dev`로 실행 (dev server)
|
||||||
|
- standalone 모드(`node .next/standalone/server.js`)로 바꾸면 static/public 파일을 못 찾아서 404 발생
|
||||||
|
- standalone은 prod(Docker/K8s) 전용
|
||||||
|
- dev 포트: 프론트 3001, 백엔드 8000 (3000은 Gitea가 사용 중)
|
||||||
|
- 환경변수는 `backend/.env`에서 로드
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Prod 환경 (www.tasteby.net)
|
||||||
|
|
||||||
|
### 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
브라우저 → www.tasteby.net (HTTPS)
|
||||||
|
↓
|
||||||
|
OCI Network Load Balancer (217.142.131.194)
|
||||||
|
↓ 80→NodePort:32530, 443→NodePort:31437
|
||||||
|
Nginx Ingress Controller (K8s)
|
||||||
|
├── /api/* → backend Service (:8000)
|
||||||
|
└── /* → frontend Service (:3001)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 클러스터 정보
|
||||||
|
|
||||||
|
- **OKE 클러스터**: tasteby-cluster-prod
|
||||||
|
- **노드**: ARM64 × 2 (2 CPU / 8GB)
|
||||||
|
- **네임스페이스**: tasteby
|
||||||
|
- **K8s context**: `context-c6ap7ecrdeq`
|
||||||
|
|
||||||
|
### Pod 구성
|
||||||
|
|
||||||
|
| Pod | Image | Port | 리소스 |
|
||||||
|
|-----|-------|------|--------|
|
||||||
|
| backend | `icn.ocir.io/idyhsdamac8c/tasteby/backend:TAG` | 8000 | 500m~1 CPU, 768Mi~1536Mi |
|
||||||
|
| frontend | `icn.ocir.io/idyhsdamac8c/tasteby/frontend:TAG` | 3001 | 200m~500m CPU, 256Mi~512Mi |
|
||||||
|
| redis | `docker.io/library/redis:7-alpine` | 6379 | 100m~200m CPU, 128Mi~256Mi |
|
||||||
|
|
||||||
|
### 배포 명령어 (deploy.sh)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 전체 배포 (백엔드 + 프론트엔드)
|
||||||
|
./deploy.sh "배포 메시지"
|
||||||
|
|
||||||
|
# 백엔드만 배포
|
||||||
|
./deploy.sh --backend-only "백엔드 수정 사항"
|
||||||
|
|
||||||
|
# 프론트엔드만 배포
|
||||||
|
./deploy.sh --frontend-only "프론트 수정 사항"
|
||||||
|
|
||||||
|
# 드라이런 (실제 배포 없이 확인)
|
||||||
|
./deploy.sh --dry-run "테스트"
|
||||||
|
```
|
||||||
|
|
||||||
|
### deploy.sh 동작 순서
|
||||||
|
|
||||||
|
1. **버전 계산**: 최신 git tag에서 patch +1 (v0.1.9 → v0.1.10)
|
||||||
|
2. **Docker 빌드**: Colima로 `linux/arm64` 이미지 빌드 (로컬 Mac에서)
|
||||||
|
- 백엔드: `backend-java/Dockerfile` → multi-stage (JDK build → JRE runtime)
|
||||||
|
- 프론트: `frontend/Dockerfile` → multi-stage (node build → standalone runtime)
|
||||||
|
3. **OCIR Push**: `icn.ocir.io/idyhsdamac8c/tasteby/{backend,frontend}:TAG` + `:latest`
|
||||||
|
4. **K8s 배포**: `kubectl set image` → `kubectl rollout status` (롤링 업데이트)
|
||||||
|
5. **Git tag**: `vX.Y.Z` 태그 생성 후 origin push
|
||||||
|
|
||||||
|
### Docker 빌드 상세
|
||||||
|
|
||||||
|
**백엔드 Dockerfile** (multi-stage):
|
||||||
|
```dockerfile
|
||||||
|
# Build: eclipse-temurin:21-jdk에서 gradlew bootJar
|
||||||
|
# Runtime: eclipse-temurin:21-jre에서 java -jar app.jar
|
||||||
|
# JVM 옵션: -XX:MaxRAMPercentage=75.0 -XX:+UseG1GC
|
||||||
|
```
|
||||||
|
|
||||||
|
**프론트엔드 Dockerfile** (multi-stage):
|
||||||
|
```dockerfile
|
||||||
|
# Build: node:22-alpine에서 npm ci + npm run build
|
||||||
|
# Runtime: node:22-alpine에서 standalone 출력물 복사 + node server.js
|
||||||
|
# ⚠️ standalone 모드는 Docker(prod) 전용. .next/static과 public을 직접 복사해야 함
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ingress 설정
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 주요 annotation
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod # 자동 TLS 인증서
|
||||||
|
nginx.ingress.kubernetes.io/ssl-redirect: "true" # HTTP → HTTPS 리다이렉트
|
||||||
|
nginx.ingress.kubernetes.io/from-to-www-redirect: "true" # tasteby.net → www 리다이렉트
|
||||||
|
|
||||||
|
# 라우팅
|
||||||
|
www.tasteby.net/api/* → backend:8000
|
||||||
|
www.tasteby.net/* → frontend:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
### TLS 인증서 (cert-manager)
|
||||||
|
|
||||||
|
- ClusterIssuer: `letsencrypt-prod`
|
||||||
|
- HTTP-01 challenge 방식 (포트 80 필수)
|
||||||
|
- Secret: `tasteby-tls`
|
||||||
|
- 인증서 상태 확인: `kubectl get certificate -n tasteby`
|
||||||
|
|
||||||
|
### 운영 확인 명령어
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pod 상태
|
||||||
|
kubectl get pods -n tasteby
|
||||||
|
|
||||||
|
# 로그 확인
|
||||||
|
kubectl logs -f deployment/backend -n tasteby
|
||||||
|
kubectl logs -f deployment/frontend -n tasteby
|
||||||
|
|
||||||
|
# 인증서 상태
|
||||||
|
kubectl get certificate -n tasteby
|
||||||
|
|
||||||
|
# Ingress 상태
|
||||||
|
kubectl get ingress -n tasteby
|
||||||
|
|
||||||
|
# 롤백 (이전 이미지로)
|
||||||
|
kubectl rollout undo deployment/backend -n tasteby
|
||||||
|
kubectl rollout undo deployment/frontend -n tasteby
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. OCI 네트워크 구성
|
||||||
|
|
||||||
|
### VCN 서브넷
|
||||||
|
|
||||||
|
| 서브넷 | CIDR | 용도 |
|
||||||
|
|--------|------|------|
|
||||||
|
| oke-k8sApiEndpoint-subnet | 10.0.0.0/28 | K8s API 서버 |
|
||||||
|
| oke-nodesubnet | 10.0.10.0/24 | 워커 노드 |
|
||||||
|
| oke-svclbsubnet | 10.0.20.0/24 | NLB (로드밸런서) |
|
||||||
|
|
||||||
|
### 보안 리스트 (Security List)
|
||||||
|
|
||||||
|
**LB 서브넷** (oke-svclbsubnet):
|
||||||
|
- Ingress: `0.0.0.0/0` → TCP 80, 443
|
||||||
|
- Egress: `10.0.10.0/24` → all (노드 서브넷 전체 허용)
|
||||||
|
|
||||||
|
**노드 서브넷** (oke-nodesubnet):
|
||||||
|
- Ingress: `10.0.10.0/24` → all (노드 간 통신)
|
||||||
|
- Ingress: `10.0.0.0/28` → TCP all (API 서버)
|
||||||
|
- Ingress: `0.0.0.0/0` → TCP 22 (SSH)
|
||||||
|
- Ingress: `10.0.20.0/24` → TCP 30000-32767 (LB → NodePort)
|
||||||
|
- Ingress: `0.0.0.0/0` → TCP 30000-32767 (NLB preserve-source 대응)
|
||||||
|
|
||||||
|
> ⚠️ NLB `is-preserve-source: true` 설정으로 클라이언트 원본 IP가 보존됨.
|
||||||
|
> 따라서 노드 서브넷에 `0.0.0.0/0` → NodePort 인바운드가 반드시 필요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. OCIR (컨테이너 레지스트리) 인증
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 로그인
|
||||||
|
docker login icn.ocir.io -u idyhsdamac8c/oracleidentitycloudservice/<email> -p <auth-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Registry: `icn.ocir.io/idyhsdamac8c/tasteby/`
|
||||||
|
- K8s imagePullSecret: `ocir-secret` (namespace: tasteby)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 자주 하는 실수 / 주의사항
|
||||||
|
|
||||||
|
| 실수 | 원인 | 해결 |
|
||||||
|
|------|------|------|
|
||||||
|
| dev에서 static 404 | PM2를 standalone 모드로 바꿈 | `npm run dev`로 원복 |
|
||||||
|
| prod HTTPS 타임아웃 | NLB 보안 리스트 NodePort 불일치 | egress를 노드 서브넷 all 허용 |
|
||||||
|
| 인증서 발급 실패 | 포트 80 방화벽 차단 | LB 서브넷 ingress 80 + 노드 서브넷 NodePort 허용 |
|
||||||
|
| OKE에서 이미지 pull 실패 | CRI-O short name 불가 | `docker.io/library/` 풀네임 사용 |
|
||||||
|
| NLB 헬스체크 실패 | preserve-source + 노드 보안 리스트 | 0.0.0.0/0 → NodePort 인바운드 추가 |
|
||||||
16
frontend/dev-restart.sh
Executable 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"
|
||||||
85
frontend/docs/design-concepts.md
Normal 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. 로고/아이콘 톤 맞춤
|
||||||
61
frontend/package-lock.json
generated
@@ -9,10 +9,13 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-oauth/google": "^0.13.4",
|
"@react-oauth/google": "^0.13.4",
|
||||||
|
"@tabler/icons-react": "^3.40.0",
|
||||||
|
"@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",
|
||||||
@@ -1255,6 +1258,32 @@
|
|||||||
"tslib": "^2.8.0"
|
"tslib": "^2.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tabler/icons": {
|
||||||
|
"version": "3.40.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.40.0.tgz",
|
||||||
|
"integrity": "sha512-V/Q4VgNPKubRTiLdmWjV/zscYcj5IIk+euicUtaVVqF6luSC9rDngYWgST5/yh3Mrg/mYUwRv1YVTk71Jp0twQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/codecalm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tabler/icons-react": {
|
||||||
|
"version": "3.40.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.40.0.tgz",
|
||||||
|
"integrity": "sha512-oO5+6QCnna4a//mYubx4euZfECtzQZFDGsDMIdzZUhbdyBCT+3bRVFBPueGIcemWld4Vb/0UQ39C/cmGfGylAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tabler/icons": "3.40.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/codecalm"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">= 16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
|
||||||
@@ -1544,6 +1573,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 +1630,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 +4599,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 +6136,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",
|
||||||
|
|||||||
@@ -10,10 +10,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-oauth/google": "^0.13.4",
|
"@react-oauth/google": "^0.13.4",
|
||||||
|
"@tabler/icons-react": "^3.40.0",
|
||||||
|
"@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",
|
||||||
|
|||||||
BIN
frontend/public/logo-120h.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
frontend/public/logo-200h.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
frontend/public/logo-80h.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/public/logo-dark-120h.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
frontend/public/logo-dark-80h.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/public/logo-dark.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
frontend/public/logo.png
Normal file
|
After Width: | Height: | Size: 947 KiB |
@@ -41,33 +41,34 @@ 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>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{isAdmin && <CacheFlushButton />}
|
{isAdmin && <CacheFlushButton />}
|
||||||
<a href="/" className="text-sm text-blue-600 hover:underline">
|
<a href="/" className="text-sm text-brand-600 hover:underline">
|
||||||
← 메인으로
|
← 메인으로
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,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" ? "유저 관리" : "데몬 설정"}
|
||||||
@@ -134,10 +135,11 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
const [editingChannel, setEditingChannel] = useState<string | null>(null);
|
const [editingChannel, setEditingChannel] = useState<string | null>(null);
|
||||||
const [editDesc, setEditDesc] = useState("");
|
const [editDesc, setEditDesc] = useState("");
|
||||||
const [editTags, setEditTags] = useState("");
|
const [editTags, setEditTags] = useState("");
|
||||||
|
const [editOrder, setEditOrder] = useState<number>(99);
|
||||||
|
|
||||||
const handleSaveChannel = async (id: string) => {
|
const handleSaveChannel = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await api.updateChannel(id, { description: editDesc, tags: editTags });
|
await api.updateChannel(id, { description: editDesc, tags: editTags, sort_order: editOrder });
|
||||||
setEditingChannel(null);
|
setEditingChannel(null);
|
||||||
load();
|
load();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -170,46 +172,47 @@ 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-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>
|
||||||
{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>
|
||||||
@@ -217,14 +220,14 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
</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>
|
||||||
) : (
|
) : (
|
||||||
@@ -234,11 +237,11 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<td className="px-4 py-3 text-xs">
|
<td className="px-4 py-3 text-xs">
|
||||||
{editingChannel === ch.id ? (
|
{editingChannel === ch.id ? (
|
||||||
<input value={editDesc} onChange={(e) => setEditDesc(e.target.value)}
|
<input value={editDesc} onChange={(e) => setEditDesc(e.target.value)}
|
||||||
className="border rounded px-2 py-1 text-xs w-32 bg-white text-gray-900" placeholder="설명" />
|
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={() => {
|
<span className="text-gray-600 cursor-pointer" onClick={() => {
|
||||||
if (!isAdmin) return;
|
if (!isAdmin) return;
|
||||||
setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || "");
|
setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || ""); setEditOrder(ch.sort_order ?? 99);
|
||||||
}}>{ch.description || <span className="text-gray-400">-</span>}</span>
|
}}>{ch.description || <span className="text-gray-400">-</span>}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@@ -246,17 +249,25 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
{editingChannel === ch.id ? (
|
{editingChannel === ch.id ? (
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<input value={editTags} onChange={(e) => setEditTags(e.target.value)}
|
<input value={editTags} onChange={(e) => setEditTags(e.target.value)}
|
||||||
className="border rounded px-2 py-1 text-xs w-40 bg-white text-gray-900" placeholder="태그 (쉼표 구분)" />
|
className="border rounded px-2 py-1 text-xs w-40 bg-surface text-gray-900" placeholder="태그 (쉼표 구분)" />
|
||||||
<button onClick={() => handleSaveChannel(ch.id)} className="text-blue-600 text-xs hover:underline">저장</button>
|
<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>
|
<button onClick={() => setEditingChannel(null)} className="text-gray-400 text-xs hover:underline">취소</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-500 cursor-pointer" onClick={() => {
|
<span className="text-gray-500 cursor-pointer" onClick={() => {
|
||||||
if (!isAdmin) return;
|
if (!isAdmin) return;
|
||||||
setEditingChannel(ch.id); setEditDesc(ch.description || ""); setEditTags(ch.tags || "");
|
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>
|
}}>{ch.tags ? ch.tags.split(",").map(t => t.trim()).join(", ") : <span className="text-gray-400">-</span>}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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>
|
||||||
@@ -267,7 +278,7 @@ function ChannelsPanel({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
{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>
|
||||||
@@ -728,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",
|
||||||
@@ -740,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) => (
|
||||||
@@ -750,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>
|
||||||
@@ -766,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
|
||||||
@@ -798,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>
|
||||||
@@ -826,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>
|
||||||
@@ -839,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>
|
||||||
@@ -870,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
|
||||||
@@ -913,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"
|
||||||
@@ -936,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}
|
||||||
>
|
>
|
||||||
@@ -947,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>
|
||||||
@@ -1039,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>
|
||||||
@@ -1057,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>
|
||||||
@@ -1075,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>
|
||||||
@@ -1094,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})
|
||||||
@@ -1147,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">
|
||||||
@@ -1168,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>
|
||||||
@@ -1181,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}
|
||||||
>
|
>
|
||||||
@@ -1280,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
|
||||||
@@ -1350,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} 변수 사용)"
|
||||||
/>
|
/>
|
||||||
@@ -1364,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
|
||||||
@@ -1427,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>
|
||||||
@@ -1441,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) {
|
||||||
@@ -1505,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>
|
||||||
)}
|
)}
|
||||||
@@ -1535,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>
|
||||||
@@ -1556,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>
|
||||||
@@ -1697,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
|
||||||
@@ -1759,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`, {
|
||||||
@@ -1815,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>
|
||||||
)}
|
)}
|
||||||
@@ -1837,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>
|
||||||
@@ -1854,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>
|
||||||
@@ -1899,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>
|
||||||
@@ -1921,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>
|
||||||
@@ -1939,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>
|
||||||
@@ -1954,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>
|
||||||
)}
|
)}
|
||||||
@@ -1977,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>
|
||||||
@@ -2005,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>
|
||||||
)}
|
)}
|
||||||
@@ -2057,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>
|
||||||
@@ -2071,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>}
|
||||||
@@ -2105,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 {
|
||||||
@@ -2128,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);
|
||||||
@@ -2135,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;
|
||||||
|
|
||||||
@@ -2157,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 {
|
||||||
@@ -2182,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>
|
||||||
@@ -2200,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">
|
||||||
@@ -2234,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>
|
||||||
@@ -2275,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
|
||||||
@@ -2300,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">
|
||||||
@@ -2342,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 ? (
|
||||||
@@ -2376,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>
|
||||||
@@ -2466,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">
|
||||||
데몬이 실행 중일 때, 아래 설정에 따라 자동으로 채널 스캔 및 영상 처리를 수행합니다.
|
데몬이 실행 중일 때, 아래 설정에 따라 자동으로 채널 스캔 및 영상 처리를 수행합니다.
|
||||||
@@ -2559,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>
|
||||||
@@ -2568,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">
|
||||||
스케줄과 관계없이 즉시 실행합니다. 처리 시간이 걸릴 수 있습니다.
|
스케줄과 관계없이 즉시 실행합니다. 처리 시간이 걸릴 수 있습니다.
|
||||||
|
|||||||
@@ -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,7 +72,41 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Filter sheet slide-up animation */
|
||||||
|
@keyframes slide-up {
|
||||||
|
from { transform: translateY(100%); }
|
||||||
|
to { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.animate-slide-up {
|
||||||
|
animation: slide-up 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
112
frontend/src/components/FilterSheet.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import Icon from "@/components/Icon";
|
||||||
|
|
||||||
|
export interface FilterOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
group?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterSheetProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
options: FilterOption[];
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilterSheet({ open, onClose, title, options, value, onChange }: FilterSheetProps) {
|
||||||
|
const sheetRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => { document.body.style.overflow = ""; };
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Group options by group field
|
||||||
|
const grouped = options.reduce<Record<string, FilterOption[]>>((acc, opt) => {
|
||||||
|
const key = opt.group || "";
|
||||||
|
if (!acc[key]) acc[key] = [];
|
||||||
|
acc[key].push(opt);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
const groups = Object.keys(grouped);
|
||||||
|
|
||||||
|
const handleSelect = (v: string) => {
|
||||||
|
onChange(v);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60] bg-black/30 md:hidden"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sheet */}
|
||||||
|
<div
|
||||||
|
ref={sheetRef}
|
||||||
|
className="fixed bottom-0 left-0 right-0 z-[61] md:hidden bg-surface rounded-t-2xl shadow-2xl max-h-[70vh] flex flex-col animate-slide-up"
|
||||||
|
>
|
||||||
|
{/* Handle */}
|
||||||
|
<div className="flex justify-center pt-2 pb-1 shrink-0">
|
||||||
|
<div className="w-10 h-1 bg-gray-300 dark:bg-gray-600 rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-100 dark:border-gray-800 shrink-0">
|
||||||
|
<h3 className="font-bold text-base text-gray-900 dark:text-gray-100">{title}</h3>
|
||||||
|
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600">
|
||||||
|
<Icon name="close" size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options */}
|
||||||
|
<div className="flex-1 overflow-y-auto overscroll-contain pb-safe">
|
||||||
|
{/* 전체(초기화) */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleSelect("")}
|
||||||
|
className={`w-full text-left px-4 py-3 flex items-center justify-between border-b border-gray-50 dark:border-gray-800/50 ${
|
||||||
|
!value ? "text-brand-600 dark:text-brand-400 font-medium bg-brand-50/50 dark:bg-brand-900/20" : "text-gray-700 dark:text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-[15px]">전체</span>
|
||||||
|
{!value && <Icon name="check" size={18} className="text-brand-500" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group}>
|
||||||
|
{group && (
|
||||||
|
<div className="px-4 py-2.5 text-xs font-semibold text-gray-400 dark:text-gray-500 tracking-wider bg-gray-50 dark:bg-gray-800/50 sticky top-0">
|
||||||
|
{group}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{grouped[group].map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => handleSelect(opt.value)}
|
||||||
|
className={`w-full text-left px-4 py-3 flex items-center justify-between border-b border-gray-50 dark:border-gray-800/50 active:bg-gray-100 dark:active:bg-gray-800 ${
|
||||||
|
value === opt.value
|
||||||
|
? "text-brand-600 dark:text-brand-400 font-medium bg-brand-50/50 dark:bg-brand-900/20"
|
||||||
|
: "text-gray-700 dark:text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-[15px]">{opt.label}</span>
|
||||||
|
{value === opt.value && <Icon name="check" size={18} className="text-brand-500" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
frontend/src/components/Icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
194
frontend/src/components/MemoSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,59 +40,63 @@ 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">
|
{/* 1줄: 식당명 + 지역 + 별점 (전체 폭) */}
|
||||||
<h4 className="font-semibold text-sm dark:text-gray-100">
|
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||||
<span className="mr-1">{getCuisineIcon(r.cuisine_type)}</span>
|
<h4 className="font-bold text-[15px] text-gray-900 dark:text-gray-100 shrink-0">
|
||||||
|
<Icon name={getCuisineIcon(r.cuisine_type)} size={16} className="mr-0.5 text-brand-600" />
|
||||||
{r.name}
|
{r.name}
|
||||||
</h4>
|
</h4>
|
||||||
|
{r.region && (
|
||||||
|
<span className="text-[11px] text-gray-400 dark:text-gray-500 truncate">{r.region}</span>
|
||||||
|
)}
|
||||||
{r.rating && (
|
{r.rating && (
|
||||||
<span className="text-xs text-yellow-600 dark:text-yellow-400 font-medium whitespace-nowrap shrink-0">
|
<span className="text-xs text-yellow-600 dark:text-yellow-400 font-medium whitespace-nowrap shrink-0">★ {r.rating}</span>
|
||||||
★ {r.rating}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-x-2 gap-y-0.5 mt-1.5 text-xs">
|
{/* 2줄: 종류/가격(왼) + 유튜브채널(우) */}
|
||||||
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
|
<div className="flex gap-x-2 text-xs flex-1 min-w-0">
|
||||||
{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 shrink-0">{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 truncate min-w-0">{r.price_range}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{r.region && (
|
{r.channels && r.channels.length > 0 && (
|
||||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500 truncate">{r.region}</p>
|
<div className="shrink-0 flex flex-wrap gap-1 justify-end">
|
||||||
|
{r.channels.map((ch) => (
|
||||||
|
<span
|
||||||
|
key={ch}
|
||||||
|
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 truncate max-w-[120px]"
|
||||||
|
>
|
||||||
|
<Icon name="play_circle" size={11} filled className="shrink-0 text-red-400" />
|
||||||
|
<span className="truncate">{ch}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 3줄: 태그 (전체 폭) */}
|
||||||
{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>
|
||||||
)}
|
)}
|
||||||
{r.channels && r.channels.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
|
||||||
{r.channels.map((ch) => (
|
|
||||||
<span
|
|
||||||
key={ch}
|
|
||||||
className="px-1.5 py-0.5 bg-orange-50 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 rounded-full text-[10px] font-medium"
|
|
||||||
>
|
|
||||||
{ch}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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="작성"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
frontend/src/fonts/PretendardVariable.woff2
Normal file
@@ -72,6 +72,7 @@ export interface Channel {
|
|||||||
title_filter: string | null;
|
title_filter: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
tags: 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;
|
||||||
}
|
}
|
||||||
@@ -128,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;
|
||||||
@@ -255,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" });
|
||||||
@@ -280,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}` : ""}`);
|
||||||
@@ -314,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", {
|
||||||
@@ -352,7 +401,7 @@ export const api = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
updateChannel(id: string, data: { description?: string; tags?: string }) {
|
updateChannel(id: string, data: { description?: string; tags?: string; sort_order?: number }) {
|
||||||
return fetchApi<{ ok: boolean }>(`/api/channels/${id}`, {
|
return fetchApi<{ ok: boolean }>(`/api/channels/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|||||||
@@ -1,49 +1,154 @@
|
|||||||
/**
|
/**
|
||||||
* Cuisine type → icon mapping.
|
* Cuisine type → icon mapping.
|
||||||
|
* Material Symbols icon name for RestaurantList (existing usage).
|
||||||
|
* Tabler icon component name for genre card chips (home tab).
|
||||||
|
*
|
||||||
* Works with "대분류|소분류" format (e.g. "한식|국밥/해장국").
|
* Works with "대분류|소분류" format (e.g. "한식|국밥/해장국").
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// ── Material Symbols (for RestaurantList etc.) ──
|
||||||
|
|
||||||
const CUISINE_ICON_MAP: Record<string, string> = {
|
const CUISINE_ICON_MAP: Record<string, string> = {
|
||||||
"한식": "🍚",
|
"한식": "rice_bowl",
|
||||||
"일식": "🍣",
|
"일식": "set_meal",
|
||||||
"중식": "🥟",
|
"중식": "skillet",
|
||||||
"양식": "🍝",
|
"양식": "dinner_dining",
|
||||||
"아시아": "🍜",
|
"아시아": "restaurant",
|
||||||
"기타": "🍴",
|
"기타": "flatware",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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: "rice_bowl" },
|
||||||
{ keyword: "해산물", icon: "🦐" },
|
{ keyword: "국밥/해장국", icon: "soup_kitchen" },
|
||||||
{ keyword: "삼겹살/돼지구이", icon: "🥩" },
|
{ keyword: "찌개/전골/탕", icon: "outdoor_grill" },
|
||||||
{ keyword: "소고기/한우구이", icon: "🥩" },
|
{ keyword: "삼겹살/돼지구이", icon: "kebab_dining" },
|
||||||
{ keyword: "곱창/막창", icon: "🥩" },
|
{ keyword: "소고기/한우구이", icon: "local_fire_department" },
|
||||||
{ keyword: "닭/오리구이", icon: "🍗" },
|
{ keyword: "곱창/막창", icon: "local_fire_department" },
|
||||||
{ keyword: "스테이크", icon: "🥩" },
|
{ keyword: "닭/오리구이", icon: "takeout_dining" },
|
||||||
{ keyword: "햄버거", icon: "🍔" },
|
{ keyword: "족발/보쌈", icon: "stockpot" },
|
||||||
{ keyword: "피자", icon: "🍕" },
|
{ keyword: "회/횟집", icon: "phishing" },
|
||||||
{ keyword: "카페/디저트", icon: "☕" },
|
{ keyword: "해산물", icon: "set_meal" },
|
||||||
{ keyword: "베이커리", icon: "🥐" },
|
{ keyword: "분식", icon: "egg_alt" },
|
||||||
{ keyword: "치킨", icon: "🍗" },
|
{ keyword: "면", icon: "ramen_dining" },
|
||||||
{ keyword: "주점/포차", icon: "🍺" },
|
{ keyword: "죽/죽집", icon: "soup_kitchen" },
|
||||||
{ keyword: "이자카야", icon: "🍶" },
|
{ keyword: "순대/순대국", icon: "soup_kitchen" },
|
||||||
{ keyword: "라멘", icon: "🍜" },
|
{ keyword: "장어/민물", icon: "phishing" },
|
||||||
{ keyword: "국밥/해장국", icon: "🍲" },
|
{ keyword: "주점/포차", icon: "local_bar" },
|
||||||
{ keyword: "분식", icon: "🍜" },
|
{ keyword: "파인다이닝/코스", icon: "auto_awesome" },
|
||||||
|
{ keyword: "스시/오마카세", icon: "set_meal" },
|
||||||
|
{ keyword: "라멘", icon: "ramen_dining" },
|
||||||
|
{ keyword: "돈카츠", icon: "lunch_dining" },
|
||||||
|
{ keyword: "텐동/튀김", icon: "tapas" },
|
||||||
|
{ keyword: "이자카야", icon: "sake" },
|
||||||
|
{ keyword: "야키니쿠", icon: "kebab_dining" },
|
||||||
|
{ keyword: "카레", icon: "skillet" },
|
||||||
|
{ keyword: "소바/우동", icon: "ramen_dining" },
|
||||||
|
{ keyword: "중화요리", icon: "skillet" },
|
||||||
|
{ keyword: "마라/훠궈", icon: "outdoor_grill" },
|
||||||
|
{ keyword: "딤섬/만두", icon: "egg_alt" },
|
||||||
|
{ keyword: "양꼬치", icon: "kebab_dining" },
|
||||||
|
{ keyword: "파스타/이탈리안", icon: "dinner_dining" },
|
||||||
|
{ keyword: "스테이크", icon: "restaurant" },
|
||||||
|
{ keyword: "햄버거", icon: "lunch_dining" },
|
||||||
|
{ keyword: "피자", icon: "local_pizza" },
|
||||||
|
{ keyword: "프렌치", icon: "auto_awesome" },
|
||||||
|
{ keyword: "바베큐", icon: "outdoor_grill" },
|
||||||
|
{ keyword: "브런치", icon: "brunch_dining" },
|
||||||
|
{ keyword: "비건/샐러드", icon: "eco" },
|
||||||
|
{ keyword: "베트남", icon: "ramen_dining" },
|
||||||
|
{ keyword: "태국", icon: "restaurant" },
|
||||||
|
{ keyword: "인도/중동", icon: "skillet" },
|
||||||
|
{ keyword: "동남아기타", icon: "restaurant" },
|
||||||
|
{ keyword: "치킨", icon: "takeout_dining" },
|
||||||
|
{ keyword: "카페/디저트", icon: "coffee" },
|
||||||
|
{ keyword: "베이커리", icon: "bakery_dining" },
|
||||||
|
{ keyword: "뷔페", icon: "brunch_dining" },
|
||||||
|
{ keyword: "퓨전", icon: "auto_awesome" },
|
||||||
];
|
];
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
// Check sub-category first
|
|
||||||
for (const rule of SUB_ICON_RULES) {
|
for (const rule of SUB_ICON_RULES) {
|
||||||
if (cuisineType.includes(rule.keyword)) return rule.icon;
|
if (cuisineType.includes(rule.keyword)) return rule.icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to main category (prefix before |)
|
|
||||||
const main = cuisineType.split("|")[0];
|
const main = cuisineType.split("|")[0];
|
||||||
return CUISINE_ICON_MAP[main] || DEFAULT_ICON;
|
return CUISINE_ICON_MAP[main] || DEFAULT_ICON;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tabler Icons (for genre card chips) ──
|
||||||
|
// Returns Tabler icon component name (PascalCase without "Icon" prefix)
|
||||||
|
|
||||||
|
const TABLER_CUISINE_MAP: Record<string, string> = {
|
||||||
|
"한식": "BowlChopsticks",
|
||||||
|
"일식": "Fish",
|
||||||
|
"중식": "Soup",
|
||||||
|
"양식": "Pizza",
|
||||||
|
"아시아": "BowlSpoon",
|
||||||
|
"기타": "Cookie",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TABLER_SUB_RULES: { keyword: string; icon: string }[] = [
|
||||||
|
// 한식
|
||||||
|
{ keyword: "백반/한정식", icon: "BowlChopsticks" },
|
||||||
|
{ keyword: "국밥/해장국", icon: "Soup" },
|
||||||
|
{ keyword: "찌개/전골/탕", icon: "Cooker" },
|
||||||
|
{ keyword: "삼겹살/돼지구이", icon: "Meat" },
|
||||||
|
{ keyword: "소고기/한우구이", icon: "Grill" },
|
||||||
|
{ keyword: "곱창/막창", icon: "GrillFork" },
|
||||||
|
{ keyword: "닭/오리구이", icon: "Meat" },
|
||||||
|
{ keyword: "족발/보쌈", icon: "Meat" },
|
||||||
|
{ keyword: "회/횟집", icon: "Fish" },
|
||||||
|
{ keyword: "해산물", icon: "Fish" },
|
||||||
|
{ keyword: "분식", icon: "EggFried" },
|
||||||
|
{ keyword: "면", icon: "BowlChopsticks" },
|
||||||
|
{ keyword: "죽/죽집", icon: "BowlSpoon" },
|
||||||
|
{ keyword: "순대/순대국", icon: "Soup" },
|
||||||
|
{ keyword: "장어/민물", icon: "Fish" },
|
||||||
|
{ keyword: "주점/포차", icon: "Beer" },
|
||||||
|
{ keyword: "파인다이닝/코스", icon: "GlassChampagne" },
|
||||||
|
// 일식
|
||||||
|
{ keyword: "스시/오마카세", icon: "Fish" },
|
||||||
|
{ keyword: "라멘", icon: "Soup" },
|
||||||
|
{ keyword: "돈카츠", icon: "Meat" },
|
||||||
|
{ keyword: "텐동/튀김", icon: "EggFried" },
|
||||||
|
{ keyword: "이자카야", icon: "GlassCocktail" },
|
||||||
|
{ keyword: "야키니쿠", icon: "Grill" },
|
||||||
|
{ keyword: "카레", icon: "BowlSpoon" },
|
||||||
|
{ keyword: "소바/우동", icon: "BowlChopsticks" },
|
||||||
|
// 중식
|
||||||
|
{ keyword: "중화요리", icon: "Soup" },
|
||||||
|
{ keyword: "마라/훠궈", icon: "Pepper" },
|
||||||
|
{ keyword: "딤섬/만두", icon: "Egg" },
|
||||||
|
{ keyword: "양꼬치", icon: "Grill" },
|
||||||
|
// 양식
|
||||||
|
{ keyword: "파스타/이탈리안", icon: "BowlSpoon" },
|
||||||
|
{ keyword: "스테이크", icon: "Meat" },
|
||||||
|
{ keyword: "햄버거", icon: "Burger" },
|
||||||
|
{ keyword: "피자", icon: "Pizza" },
|
||||||
|
{ keyword: "프렌치", icon: "GlassChampagne" },
|
||||||
|
{ keyword: "바베큐", icon: "GrillSpatula" },
|
||||||
|
{ keyword: "브런치", icon: "EggFried" },
|
||||||
|
{ keyword: "비건/샐러드", icon: "Salad" },
|
||||||
|
// 아시아
|
||||||
|
{ keyword: "베트남", icon: "BowlChopsticks" },
|
||||||
|
{ keyword: "태국", icon: "Pepper" },
|
||||||
|
{ keyword: "인도/중동", icon: "BowlSpoon" },
|
||||||
|
{ keyword: "동남아기타", icon: "BowlSpoon" },
|
||||||
|
// 기타
|
||||||
|
{ keyword: "치킨", icon: "Meat" },
|
||||||
|
{ keyword: "카페/디저트", icon: "Coffee" },
|
||||||
|
{ keyword: "베이커리", icon: "Bread" },
|
||||||
|
{ keyword: "뷔페", icon: "Cheese" },
|
||||||
|
{ keyword: "퓨전", icon: "Cookie" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getTablerCuisineIcon(cuisineType: string | null | undefined): string {
|
||||||
|
if (!cuisineType) return "Bowl";
|
||||||
|
for (const rule of TABLER_SUB_RULES) {
|
||||||
|
if (cuisineType.includes(rule.keyword)) return rule.icon;
|
||||||
|
}
|
||||||
|
const main = cuisineType.split("|")[0];
|
||||||
|
return TABLER_CUISINE_MAP[main] || "Bowl";
|
||||||
|
}
|
||||||
|
|||||||