Compare commits
7 Commits
88c1b4243e
...
18776b9b4b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18776b9b4b | ||
|
|
177532e6e7 | ||
|
|
64d58cb553 | ||
|
|
a766a74f20 | ||
|
|
4b1f7c13b7 | ||
|
|
75e0066dbe | ||
|
|
3134994817 |
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,18 +182,13 @@ 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"));
|
||||||
@@ -222,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();
|
||||||
@@ -277,12 +269,8 @@ public class RestaurantController {
|
|||||||
AuthUtil.requireAdmin();
|
AuthUtil.requireAdmin();
|
||||||
var r = restaurantService.findById(id);
|
var r = restaurantService.findById(id);
|
||||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
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());
|
||||||
@@ -321,18 +309,13 @@ 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"));
|
||||||
@@ -360,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();
|
||||||
@@ -407,119 +388,96 @@ 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) {
|
|
||||||
if (item instanceof Map<?, ?> map) {
|
|
||||||
results.add(Map.of(
|
|
||||||
"title", String.valueOf(map.get("title")),
|
|
||||||
"url", String.valueOf(map.get("url"))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.info("[CATCHTABLE] Found {} results for '{}'", results.size(), restaurantName);
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
22
backend-java/src/main/java/com/tasteby/domain/Memo.java
Normal file
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
34
frontend/package-lock.json
generated
34
frontend/package-lock.json
generated
@@ -9,10 +9,12 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-oauth/google": "^0.13.4",
|
"@react-oauth/google": "^0.13.4",
|
||||||
|
"@types/supercluster": "^7.1.3",
|
||||||
"@vis.gl/react-google-maps": "^1.7.1",
|
"@vis.gl/react-google-maps": "^1.7.1",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"supercluster": "^8.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -1544,6 +1546,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/google.maps": {
|
"node_modules/@types/google.maps": {
|
||||||
"version": "3.58.1",
|
"version": "3.58.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz",
|
||||||
@@ -1595,6 +1603,15 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/supercluster": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.56.1",
|
"version": "8.56.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
|
||||||
@@ -4555,6 +4572,12 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kdbush": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -6086,6 +6109,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/supercluster": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"kdbush": "^4.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
|||||||
@@ -10,10 +10,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-oauth/google": "^0.13.4",
|
"@react-oauth/google": "^0.13.4",
|
||||||
|
"@types/supercluster": "^7.1.3",
|
||||||
"@vis.gl/react-google-maps": "^1.7.1",
|
"@vis.gl/react-google-maps": "^1.7.1",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"supercluster": "^8.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
@@ -2148,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 {
|
||||||
@@ -2171,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);
|
||||||
@@ -2178,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;
|
||||||
|
|
||||||
@@ -2200,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 {
|
||||||
@@ -2233,6 +2248,7 @@ function UsersPanel() {
|
|||||||
<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>
|
||||||
@@ -2284,6 +2300,15 @@ function UsersPanel() {
|
|||||||
<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>
|
||||||
@@ -2343,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">
|
||||||
@@ -2419,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>
|
||||||
|
|||||||
@@ -101,3 +101,12 @@ html, body, #__next {
|
|||||||
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { GoogleLogin } from "@react-oauth/google";
|
import { GoogleLogin } from "@react-oauth/google";
|
||||||
import LoginMenu from "@/components/LoginMenu";
|
import LoginMenu from "@/components/LoginMenu";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { Restaurant, Channel, Review } from "@/lib/api";
|
import type { Restaurant, Channel, Review, Memo } from "@/lib/api";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
import { useAuth } from "@/lib/auth-context";
|
||||||
import MapView, { MapBounds, FlyTo } from "@/components/MapView";
|
import MapView, { MapBounds, FlyTo } from "@/components/MapView";
|
||||||
import SearchBar from "@/components/SearchBar";
|
import SearchBar from "@/components/SearchBar";
|
||||||
@@ -12,6 +12,7 @@ import RestaurantList from "@/components/RestaurantList";
|
|||||||
import RestaurantDetail from "@/components/RestaurantDetail";
|
import RestaurantDetail from "@/components/RestaurantDetail";
|
||||||
import MyReviewsList from "@/components/MyReviewsList";
|
import MyReviewsList from "@/components/MyReviewsList";
|
||||||
import BottomSheet from "@/components/BottomSheet";
|
import BottomSheet from "@/components/BottomSheet";
|
||||||
|
import FilterSheet, { FilterOption } from "@/components/FilterSheet";
|
||||||
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
import { getCuisineIcon } from "@/lib/cuisine-icons";
|
||||||
import Icon from "@/components/Icon";
|
import Icon from "@/components/Icon";
|
||||||
|
|
||||||
@@ -187,10 +188,12 @@ export default function Home() {
|
|||||||
const [countryFilter, setCountryFilter] = useState("");
|
const [countryFilter, setCountryFilter] = useState("");
|
||||||
const [cityFilter, setCityFilter] = useState("");
|
const [cityFilter, setCityFilter] = useState("");
|
||||||
const [districtFilter, setDistrictFilter] = useState("");
|
const [districtFilter, setDistrictFilter] = useState("");
|
||||||
|
const [openSheet, setOpenSheet] = useState<"cuisine" | "price" | "country" | "city" | "district" | null>(null);
|
||||||
const [regionFlyTo, setRegionFlyTo] = useState<FlyTo | null>(null);
|
const [regionFlyTo, setRegionFlyTo] = useState<FlyTo | null>(null);
|
||||||
const [showFavorites, setShowFavorites] = useState(false);
|
const [showFavorites, setShowFavorites] = useState(false);
|
||||||
const [showMyReviews, setShowMyReviews] = useState(false);
|
const [showMyReviews, setShowMyReviews] = useState(false);
|
||||||
const [myReviews, setMyReviews] = useState<(Review & { restaurant_id: string; restaurant_name: string | null })[]>([]);
|
const [myReviews, setMyReviews] = useState<(Review & { restaurant_id: string; restaurant_name: string | null })[]>([]);
|
||||||
|
const [myMemos, setMyMemos] = useState<(Memo & { restaurant_name: string | null })[]>([]);
|
||||||
const [visits, setVisits] = useState<{ today: number; total: number } | null>(null);
|
const [visits, setVisits] = useState<{ today: number; total: number } | null>(null);
|
||||||
const [userLoc, setUserLoc] = useState<{ lat: number; lng: number }>({ lat: 37.498, lng: 127.0276 });
|
const [userLoc, setUserLoc] = useState<{ lat: number; lng: number }>({ lat: 37.498, lng: 127.0276 });
|
||||||
const [isSearchResult, setIsSearchResult] = useState(false);
|
const [isSearchResult, setIsSearchResult] = useState(false);
|
||||||
@@ -246,6 +249,34 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
}, [restaurants, isSearchResult, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]);
|
}, [restaurants, isSearchResult, channelFilter, cuisineFilter, priceFilter, countryFilter, cityFilter, districtFilter, boundsFilterOn, mapBounds, userLoc]);
|
||||||
|
|
||||||
|
// FilterSheet option builders
|
||||||
|
const cuisineOptions = useMemo<FilterOption[]>(() => {
|
||||||
|
const opts: FilterOption[] = [];
|
||||||
|
for (const g of CUISINE_TAXONOMY) {
|
||||||
|
opts.push({ label: `${g.category} 전체`, value: g.category, group: g.category });
|
||||||
|
for (const item of g.items) {
|
||||||
|
opts.push({ label: item, value: `${g.category}|${item}`, group: g.category });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const priceOptions = useMemo<FilterOption[]>(() =>
|
||||||
|
PRICE_GROUPS.map((g) => ({ label: g.label, value: g.label })),
|
||||||
|
[]);
|
||||||
|
|
||||||
|
const countryOptions = useMemo<FilterOption[]>(() =>
|
||||||
|
countries.map((c) => ({ label: c, value: c })),
|
||||||
|
[countries]);
|
||||||
|
|
||||||
|
const cityOptions = useMemo<FilterOption[]>(() =>
|
||||||
|
cities.map((c) => ({ label: c, value: c })),
|
||||||
|
[cities]);
|
||||||
|
|
||||||
|
const districtOptions = useMemo<FilterOption[]>(() =>
|
||||||
|
districts.map((d) => ({ label: d, value: d })),
|
||||||
|
[districts]);
|
||||||
|
|
||||||
// Set desktop default to map mode on mount + get user location
|
// Set desktop default to map mode on mount + get user location
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.innerWidth >= 768) setViewMode("map");
|
if (window.innerWidth >= 768) setViewMode("map");
|
||||||
@@ -343,7 +374,7 @@ export default function Home() {
|
|||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(pos) => {
|
(pos) => {
|
||||||
setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude });
|
setUserLoc({ lat: pos.coords.latitude, lng: pos.coords.longitude });
|
||||||
setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 16 });
|
setRegionFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 17 });
|
||||||
},
|
},
|
||||||
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 16 }),
|
() => setRegionFlyTo({ lat: 37.498, lng: 127.0276, zoom: 16 }),
|
||||||
{ timeout: 5000 },
|
{ timeout: 5000 },
|
||||||
@@ -518,10 +549,15 @@ export default function Home() {
|
|||||||
if (showMyReviews) {
|
if (showMyReviews) {
|
||||||
setShowMyReviews(false);
|
setShowMyReviews(false);
|
||||||
setMyReviews([]);
|
setMyReviews([]);
|
||||||
|
setMyMemos([]);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const reviews = await api.getMyReviews();
|
const [reviews, memos] = await Promise.all([
|
||||||
|
api.getMyReviews(),
|
||||||
|
api.getMyMemos(),
|
||||||
|
]);
|
||||||
setMyReviews(reviews);
|
setMyReviews(reviews);
|
||||||
|
setMyMemos(memos);
|
||||||
setShowMyReviews(true);
|
setShowMyReviews(true);
|
||||||
setShowFavorites(false);
|
setShowFavorites(false);
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
@@ -534,7 +570,8 @@ export default function Home() {
|
|||||||
const sidebarContent = showMyReviews ? (
|
const sidebarContent = showMyReviews ? (
|
||||||
<MyReviewsList
|
<MyReviewsList
|
||||||
reviews={myReviews}
|
reviews={myReviews}
|
||||||
onClose={() => { setShowMyReviews(false); setMyReviews([]); }}
|
memos={myMemos}
|
||||||
|
onClose={() => { setShowMyReviews(false); setMyReviews([]); setMyMemos([]); }}
|
||||||
onSelectRestaurant={async (restaurantId) => {
|
onSelectRestaurant={async (restaurantId) => {
|
||||||
try {
|
try {
|
||||||
const r = await api.getRestaurant(restaurantId);
|
const r = await api.getRestaurant(restaurantId);
|
||||||
@@ -563,7 +600,8 @@ export default function Home() {
|
|||||||
const mobileListContent = showMyReviews ? (
|
const mobileListContent = showMyReviews ? (
|
||||||
<MyReviewsList
|
<MyReviewsList
|
||||||
reviews={myReviews}
|
reviews={myReviews}
|
||||||
onClose={() => { setShowMyReviews(false); setMyReviews([]); }}
|
memos={myMemos}
|
||||||
|
onClose={() => { setShowMyReviews(false); setMyReviews([]); setMyMemos([]); }}
|
||||||
onSelectRestaurant={async (restaurantId) => {
|
onSelectRestaurant={async (restaurantId) => {
|
||||||
try {
|
try {
|
||||||
const r = await api.getRestaurant(restaurantId);
|
const r = await api.getRestaurant(restaurantId);
|
||||||
@@ -618,7 +656,7 @@ export default function Home() {
|
|||||||
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-blue-300 hover:text-blue-500"
|
: "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-blue-300 hover:text-blue-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{viewMode === "map" ? "🗺 지도우선" : "☰ 목록우선"}
|
<Icon name={viewMode === "map" ? "map" : "list"} size={14} className="mr-0.5" />{viewMode === "map" ? "지도우선" : "목록우선"}
|
||||||
</button>
|
</button>
|
||||||
{user && (
|
{user && (
|
||||||
<>
|
<>
|
||||||
@@ -949,37 +987,30 @@ export default function Home() {
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{/* Line 1: 음식 장르 + 가격 + 결과수 */}
|
{/* Line 1: 음식 장르 + 가격 + 결과수 */}
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
<select
|
<button
|
||||||
value={cuisineFilter}
|
onClick={() => setOpenSheet("cuisine")}
|
||||||
onChange={(e) => { setCuisineFilter(e.target.value); if (e.target.value) setBoundsFilterOn(false); }}
|
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
|
||||||
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
|
cuisineFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
|
||||||
cuisineFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<option value="">🍽 장르</option>
|
<Icon name="restaurant" size={14} className={`mr-1 ${cuisineFilter ? "text-brand-500" : "text-gray-400"}`} />
|
||||||
{CUISINE_TAXONOMY.map((g) => (
|
<span className={cuisineFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
|
||||||
<optgroup key={g.category} label={`── ${g.category} ──`}>
|
{cuisineFilter ? (cuisineFilter.includes("|") ? cuisineFilter.split("|")[1] : cuisineFilter) : "장르"}
|
||||||
<option value={g.category}>{g.category} 전체</option>
|
</span>
|
||||||
{g.items.map((item) => (
|
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
|
||||||
<option key={`${g.category}|${item}`} value={`${g.category}|${item}`}>
|
</button>
|
||||||
{item}
|
<button
|
||||||
</option>
|
onClick={() => setOpenSheet("price")}
|
||||||
))}
|
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
|
||||||
</optgroup>
|
priceFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={priceFilter}
|
|
||||||
onChange={(e) => { setPriceFilter(e.target.value); if (e.target.value) setBoundsFilterOn(false); }}
|
|
||||||
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
|
|
||||||
priceFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<option value="">💰 가격</option>
|
<Icon name="payments" size={14} className={`mr-1 ${priceFilter ? "text-brand-500" : "text-gray-400"}`} />
|
||||||
{PRICE_GROUPS.map((g) => (
|
<span className={priceFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
|
||||||
<option key={g.label} value={g.label}>{g.label}</option>
|
{priceFilter || "가격"}
|
||||||
))}
|
</span>
|
||||||
</select>
|
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
|
||||||
|
</button>
|
||||||
{(cuisineFilter || priceFilter) && (
|
{(cuisineFilter || priceFilter) && (
|
||||||
<button onClick={() => { setCuisineFilter(""); setPriceFilter(""); }} className="text-gray-400 hover:text-brand-500">
|
<button onClick={() => { setCuisineFilter(""); setPriceFilter(""); }} className="text-gray-400 hover:text-brand-500">
|
||||||
<Icon name="close" size={14} />
|
<Icon name="close" size={14} />
|
||||||
@@ -989,45 +1020,43 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
{/* Line 2: 나라 + 시 + 구 + 내위치 */}
|
{/* Line 2: 나라 + 시 + 구 + 내위치 */}
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
<select
|
<button
|
||||||
value={countryFilter}
|
onClick={() => setOpenSheet("country")}
|
||||||
onChange={(e) => handleCountryChange(e.target.value)}
|
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
|
||||||
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
|
countryFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
|
||||||
countryFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<option value="">🌍 나라</option>
|
<Icon name="public" size={14} className={`mr-1 ${countryFilter ? "text-brand-500" : "text-gray-400"}`} />
|
||||||
{countries.map((c) => (
|
<span className={countryFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
|
||||||
<option key={c} value={c}>{c}</option>
|
{countryFilter || "나라"}
|
||||||
))}
|
</span>
|
||||||
</select>
|
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
|
||||||
|
</button>
|
||||||
{countryFilter && cities.length > 0 && (
|
{countryFilter && cities.length > 0 && (
|
||||||
<select
|
<button
|
||||||
value={cityFilter}
|
onClick={() => setOpenSheet("city")}
|
||||||
onChange={(e) => handleCityChange(e.target.value)}
|
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
|
||||||
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
|
cityFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
|
||||||
cityFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<option value="">시/도</option>
|
<span className={cityFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
|
||||||
{cities.map((c) => (
|
{cityFilter || "시/도"}
|
||||||
<option key={c} value={c}>{c}</option>
|
</span>
|
||||||
))}
|
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
|
||||||
</select>
|
</button>
|
||||||
)}
|
)}
|
||||||
{cityFilter && districts.length > 0 && (
|
{cityFilter && districts.length > 0 && (
|
||||||
<select
|
<button
|
||||||
value={districtFilter}
|
onClick={() => setOpenSheet("district")}
|
||||||
onChange={(e) => handleDistrictChange(e.target.value)}
|
className={`inline-flex items-center rounded-full px-3 py-1.5 ${
|
||||||
className={`border dark:border-gray-700 rounded-lg px-2 py-1 bg-white dark:bg-gray-800 ${
|
districtFilter ? "bg-brand-50 dark:bg-brand-900/30 ring-1 ring-brand-300 dark:ring-brand-700" : "bg-gray-100 dark:bg-gray-800"
|
||||||
districtFilter ? "text-brand-600 dark:text-brand-400 border-brand-300 dark:border-brand-700" : "text-gray-500 dark:text-gray-400"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<option value="">구/군</option>
|
<span className={districtFilter ? "text-brand-600 dark:text-brand-400 font-medium" : "text-gray-500 dark:text-gray-400"}>
|
||||||
{districts.map((d) => (
|
{districtFilter || "구/군"}
|
||||||
<option key={d} value={d}>{d}</option>
|
</span>
|
||||||
))}
|
<Icon name="expand_more" size={14} className="ml-0.5 text-gray-400" />
|
||||||
</select>
|
</button>
|
||||||
)}
|
)}
|
||||||
{countryFilter && (
|
{countryFilter && (
|
||||||
<button onClick={() => { setCountryFilter(""); setCityFilter(""); setDistrictFilter(""); setRegionFlyTo(null); }} className="text-gray-400 hover:text-brand-500">
|
<button onClick={() => { setCountryFilter(""); setCityFilter(""); setDistrictFilter(""); setRegionFlyTo(null); }} className="text-gray-400 hover:text-brand-500">
|
||||||
@@ -1193,6 +1222,7 @@ export default function Home() {
|
|||||||
) : (
|
) : (
|
||||||
<MyReviewsList
|
<MyReviewsList
|
||||||
reviews={myReviews}
|
reviews={myReviews}
|
||||||
|
memos={myMemos}
|
||||||
onClose={() => {}}
|
onClose={() => {}}
|
||||||
onSelectRestaurant={async (restaurantId) => {
|
onSelectRestaurant={async (restaurantId) => {
|
||||||
try {
|
try {
|
||||||
@@ -1276,6 +1306,47 @@ export default function Home() {
|
|||||||
SDJ Labs Co., Ltd.
|
SDJ Labs Co., Ltd.
|
||||||
</span>
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
{/* Mobile Filter Sheets */}
|
||||||
|
<FilterSheet
|
||||||
|
open={openSheet === "cuisine"}
|
||||||
|
onClose={() => setOpenSheet(null)}
|
||||||
|
title="음식 장르"
|
||||||
|
options={cuisineOptions}
|
||||||
|
value={cuisineFilter}
|
||||||
|
onChange={(v) => { setCuisineFilter(v); if (v) setBoundsFilterOn(false); }}
|
||||||
|
/>
|
||||||
|
<FilterSheet
|
||||||
|
open={openSheet === "price"}
|
||||||
|
onClose={() => setOpenSheet(null)}
|
||||||
|
title="가격대"
|
||||||
|
options={priceOptions}
|
||||||
|
value={priceFilter}
|
||||||
|
onChange={(v) => { setPriceFilter(v); if (v) setBoundsFilterOn(false); }}
|
||||||
|
/>
|
||||||
|
<FilterSheet
|
||||||
|
open={openSheet === "country"}
|
||||||
|
onClose={() => setOpenSheet(null)}
|
||||||
|
title="나라"
|
||||||
|
options={countryOptions}
|
||||||
|
value={countryFilter}
|
||||||
|
onChange={handleCountryChange}
|
||||||
|
/>
|
||||||
|
<FilterSheet
|
||||||
|
open={openSheet === "city"}
|
||||||
|
onClose={() => setOpenSheet(null)}
|
||||||
|
title="시/도"
|
||||||
|
options={cityOptions}
|
||||||
|
value={cityFilter}
|
||||||
|
onChange={handleCityChange}
|
||||||
|
/>
|
||||||
|
<FilterSheet
|
||||||
|
open={openSheet === "district"}
|
||||||
|
onClose={() => setOpenSheet(null)}
|
||||||
|
title="구/군"
|
||||||
|
options={districtOptions}
|
||||||
|
value={districtFilter}
|
||||||
|
onChange={handleDistrictChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
112
frontend/src/components/FilterSheet.tsx
Normal file
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ 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";
|
import Icon from "@/components/Icon";
|
||||||
@@ -62,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) => {
|
||||||
@@ -75,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;
|
||||||
@@ -92,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];
|
||||||
@@ -167,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)}
|
||||||
|
|||||||
194
frontend/src/components/MemoSection.tsx
Normal file
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,6 +4,7 @@ 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";
|
import Icon from "@/components/Icon";
|
||||||
|
|
||||||
@@ -51,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}
|
||||||
@@ -94,30 +95,30 @@ 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-brand-600 dark:text-brand-400 hover:underline">
|
<a href={`tel:${restaurant.phone}`} className="text-brand-600 dark:text-brand-400 hover:underline">
|
||||||
{restaurant.phone}
|
{restaurant.phone}
|
||||||
</a>
|
</a>
|
||||||
@@ -257,6 +258,7 @@ export default function RestaurantDetail({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ReviewSection restaurantId={restaurant.id} />
|
<ReviewSection restaurantId={restaurant.id} />
|
||||||
|
<MemoSection restaurantId={restaurant.id} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,28 +44,44 @@ export default function RestaurantList({
|
|||||||
: "bg-surface 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 text-gray-900 dark:text-gray-100">
|
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||||
|
<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" />
|
<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-700 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-100 dark:bg-gray-800 rounded text-gray-700 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-500 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) => (
|
||||||
@@ -81,19 +97,6 @@ export default function RestaurantList({
|
|||||||
)}
|
)}
|
||||||
</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="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 rounded-full text-[10px] font-medium"
|
|
||||||
>
|
|
||||||
<Icon name="play_circle" size={11} filled className="shrink-0 text-red-400" />
|
|
||||||
{ch}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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="작성"
|
||||||
|
|||||||
@@ -129,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;
|
||||||
@@ -256,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" });
|
||||||
@@ -281,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}` : ""}`);
|
||||||
@@ -315,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", {
|
||||||
|
|||||||
Reference in New Issue
Block a user