비공개 메모 기능 추가 + 아이콘 개선
- 식당별 1:1 비공개 메모 CRUD (user_memos 테이블) - 내 기록에 리뷰/메모 탭 분리 - 백오피스 유저 관리에 메모 수/상세 표시 - 리뷰/메모 작성 시 현재 날짜 기본값 - 지도우선/목록우선 버튼 Material Symbols 아이콘 적용 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.domain.Memo;
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.domain.Review;
|
||||
import com.tasteby.service.MemoService;
|
||||
import com.tasteby.service.ReviewService;
|
||||
import com.tasteby.service.UserService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -15,10 +17,12 @@ public class AdminUserController {
|
||||
|
||||
private final UserService userService;
|
||||
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.reviewService = reviewService;
|
||||
this.memoService = memoService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@@ -39,4 +43,9 @@ public class AdminUserController {
|
||||
public List<Review> userReviews(@PathVariable String userId) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
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 int favoriteCount;
|
||||
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="favoriteCount" column="favorite_count"/>
|
||||
<result property="reviewCount" column="review_count"/>
|
||||
<result property="memoCount" column="memo_count"/>
|
||||
</resultMap>
|
||||
|
||||
<select id="findByProviderAndProviderId" resultMap="userResultMap">
|
||||
@@ -38,10 +39,12 @@
|
||||
<select id="findAllWithCounts" resultMap="userResultMap">
|
||||
SELECT u.id, u.email, u.nickname, u.avatar_url, u.provider, u.created_at,
|
||||
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
|
||||
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_memos GROUP BY user_id) memo ON memo.user_id = u.id
|
||||
ORDER BY u.created_at DESC
|
||||
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
|
||||
</select>
|
||||
|
||||
@@ -2148,6 +2148,7 @@ interface AdminUser {
|
||||
created_at: string | null;
|
||||
favorite_count: number;
|
||||
review_count: number;
|
||||
memo_count: number;
|
||||
}
|
||||
|
||||
interface UserFavorite {
|
||||
@@ -2171,6 +2172,16 @@ interface UserReview {
|
||||
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() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -2178,6 +2189,7 @@ function UsersPanel() {
|
||||
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
|
||||
const [favorites, setFavorites] = useState<UserFavorite[]>([]);
|
||||
const [reviews, setReviews] = useState<UserReview[]>([]);
|
||||
const [memos, setMemos] = useState<UserMemo[]>([]);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const perPage = 20;
|
||||
|
||||
@@ -2200,17 +2212,20 @@ function UsersPanel() {
|
||||
setSelectedUser(null);
|
||||
setFavorites([]);
|
||||
setReviews([]);
|
||||
setMemos([]);
|
||||
return;
|
||||
}
|
||||
setSelectedUser(u);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const [favs, revs] = await Promise.all([
|
||||
const [favs, revs, mems] = await Promise.all([
|
||||
api.getAdminUserFavorites(u.id),
|
||||
api.getAdminUserReviews(u.id),
|
||||
api.getAdminUserMemos(u.id),
|
||||
]);
|
||||
setFavorites(favs);
|
||||
setReviews(revs);
|
||||
setMemos(mems);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
@@ -2233,6 +2248,7 @@ function UsersPanel() {
|
||||
<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-left px-4 py-2">가입일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -2284,6 +2300,15 @@ function UsersPanel() {
|
||||
<span className="text-gray-300">0</span>
|
||||
)}
|
||||
</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">
|
||||
{u.created_at?.slice(0, 10) || "-"}
|
||||
</td>
|
||||
@@ -2343,7 +2368,7 @@ function UsersPanel() {
|
||||
{detailLoading ? (
|
||||
<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 */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm mb-2 text-red-600">
|
||||
@@ -2419,6 +2444,46 @@ function UsersPanel() {
|
||||
</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>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { GoogleLogin } from "@react-oauth/google";
|
||||
import LoginMenu from "@/components/LoginMenu";
|
||||
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 MapView, { MapBounds, FlyTo } from "@/components/MapView";
|
||||
import SearchBar from "@/components/SearchBar";
|
||||
@@ -191,6 +191,7 @@ export default function Home() {
|
||||
const [showFavorites, setShowFavorites] = useState(false);
|
||||
const [showMyReviews, setShowMyReviews] = useState(false);
|
||||
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 [userLoc, setUserLoc] = useState<{ lat: number; lng: number }>({ lat: 37.498, lng: 127.0276 });
|
||||
const [isSearchResult, setIsSearchResult] = useState(false);
|
||||
@@ -518,10 +519,15 @@ export default function Home() {
|
||||
if (showMyReviews) {
|
||||
setShowMyReviews(false);
|
||||
setMyReviews([]);
|
||||
setMyMemos([]);
|
||||
} else {
|
||||
try {
|
||||
const reviews = await api.getMyReviews();
|
||||
const [reviews, memos] = await Promise.all([
|
||||
api.getMyReviews(),
|
||||
api.getMyMemos(),
|
||||
]);
|
||||
setMyReviews(reviews);
|
||||
setMyMemos(memos);
|
||||
setShowMyReviews(true);
|
||||
setShowFavorites(false);
|
||||
setSelected(null);
|
||||
@@ -534,7 +540,8 @@ export default function Home() {
|
||||
const sidebarContent = showMyReviews ? (
|
||||
<MyReviewsList
|
||||
reviews={myReviews}
|
||||
onClose={() => { setShowMyReviews(false); setMyReviews([]); }}
|
||||
memos={myMemos}
|
||||
onClose={() => { setShowMyReviews(false); setMyReviews([]); setMyMemos([]); }}
|
||||
onSelectRestaurant={async (restaurantId) => {
|
||||
try {
|
||||
const r = await api.getRestaurant(restaurantId);
|
||||
@@ -563,7 +570,8 @@ export default function Home() {
|
||||
const mobileListContent = showMyReviews ? (
|
||||
<MyReviewsList
|
||||
reviews={myReviews}
|
||||
onClose={() => { setShowMyReviews(false); setMyReviews([]); }}
|
||||
memos={myMemos}
|
||||
onClose={() => { setShowMyReviews(false); setMyReviews([]); setMyMemos([]); }}
|
||||
onSelectRestaurant={async (restaurantId) => {
|
||||
try {
|
||||
const r = await api.getRestaurant(restaurantId);
|
||||
@@ -618,7 +626,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"
|
||||
}`}
|
||||
>
|
||||
{viewMode === "map" ? "🗺 지도우선" : "☰ 목록우선"}
|
||||
<Icon name={viewMode === "map" ? "map" : "list"} size={14} className="mr-0.5" />{viewMode === "map" ? "지도우선" : "목록우선"}
|
||||
</button>
|
||||
{user && (
|
||||
<>
|
||||
@@ -1193,6 +1201,7 @@ export default function Home() {
|
||||
) : (
|
||||
<MyReviewsList
|
||||
reviews={myReviews}
|
||||
memos={myMemos}
|
||||
onClose={() => {}}
|
||||
onSelectRestaurant={async (restaurantId) => {
|
||||
try {
|
||||
|
||||
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,68 +1,142 @@
|
||||
"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 {
|
||||
restaurant_id: string;
|
||||
restaurant_name: string | null;
|
||||
}
|
||||
|
||||
interface MyMemo extends Memo {
|
||||
restaurant_name: string | null;
|
||||
}
|
||||
|
||||
interface MyReviewsListProps {
|
||||
reviews: MyReview[];
|
||||
memos: MyMemo[];
|
||||
onClose: () => void;
|
||||
onSelectRestaurant: (restaurantId: string) => void;
|
||||
}
|
||||
|
||||
export default function MyReviewsList({
|
||||
reviews,
|
||||
memos,
|
||||
onClose,
|
||||
onSelectRestaurant,
|
||||
}: MyReviewsListProps) {
|
||||
const [tab, setTab] = useState<"reviews" | "memos">("reviews");
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="font-bold text-lg">내 리뷰 ({reviews.length})</h2>
|
||||
<h2 className="font-bold text-lg">내 기록</h2>
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
|
||||
{reviews.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 py-8 text-center">
|
||||
아직 작성한 리뷰가 없습니다.
|
||||
</p>
|
||||
<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>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{reviews.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => onSelectRestaurant(r.restaurant_id)}
|
||||
className="w-full text-left border rounded-lg p-3 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-semibold text-sm truncate">
|
||||
{r.restaurant_name || "알 수 없는 식당"}
|
||||
</span>
|
||||
<span className="text-yellow-500 text-sm shrink-0 ml-2">
|
||||
{"★".repeat(Math.round(r.rating))}
|
||||
<span className="text-gray-500 ml-1">{r.rating}</span>
|
||||
</span>
|
||||
</div>
|
||||
{r.review_text && (
|
||||
<p className="text-xs text-gray-600 line-clamp-2">
|
||||
{r.review_text}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1.5 text-[10px] text-gray-400">
|
||||
{r.visited_at && <span>방문: {r.visited_at}</span>}
|
||||
{r.created_at && <span>{r.created_at.slice(0, 10)}</span>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{reviews.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => onSelectRestaurant(r.restaurant_id)}
|
||||
className="w-full text-left border rounded-lg p-3 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-semibold text-sm truncate">
|
||||
{r.restaurant_name || "알 수 없는 식당"}
|
||||
</span>
|
||||
<span className="text-yellow-500 text-sm shrink-0 ml-2">
|
||||
{"★".repeat(Math.round(r.rating))}
|
||||
<span className="text-gray-500 ml-1">{r.rating}</span>
|
||||
</span>
|
||||
</div>
|
||||
{r.review_text && (
|
||||
<p className="text-xs text-gray-600 line-clamp-2">
|
||||
{r.review_text}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1.5 text-[10px] text-gray-400">
|
||||
{r.visited_at && <span>방문: {r.visited_at}</span>}
|
||||
{r.created_at && <span>{r.created_at.slice(0, 10)}</span>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { api, getToken } from "@/lib/api";
|
||||
import type { Restaurant, VideoLink } from "@/lib/api";
|
||||
import ReviewSection from "@/components/ReviewSection";
|
||||
import MemoSection from "@/components/MemoSection";
|
||||
import { RestaurantDetailSkeleton } from "@/components/Skeleton";
|
||||
import Icon from "@/components/Icon";
|
||||
|
||||
@@ -257,6 +258,7 @@ export default function RestaurantDetail({
|
||||
)}
|
||||
|
||||
<ReviewSection restaurantId={restaurant.id} />
|
||||
<MemoSection restaurantId={restaurant.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -234,6 +234,7 @@ export default function ReviewSection({ restaurantId }: ReviewSectionProps) {
|
||||
{showForm && (
|
||||
<div className="mb-3">
|
||||
<ReviewForm
|
||||
initialDate={new Date().toISOString().slice(0, 10)}
|
||||
onSubmit={handleCreate}
|
||||
onCancel={() => setShowForm(false)}
|
||||
submitLabel="작성"
|
||||
|
||||
@@ -129,6 +129,17 @@ export interface Review {
|
||||
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 {
|
||||
scan_enabled: boolean;
|
||||
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
|
||||
recordVisit() {
|
||||
return fetchApi<{ ok: boolean }>("/api/stats/visit", { method: "POST" });
|
||||
@@ -281,6 +314,7 @@ export const api = {
|
||||
created_at: string | null;
|
||||
favorite_count: number;
|
||||
review_count: number;
|
||||
memo_count: number;
|
||||
}[];
|
||||
total: number;
|
||||
}>(`/api/admin/users${qs ? `?${qs}` : ""}`);
|
||||
@@ -315,6 +349,20 @@ export const api = {
|
||||
>(`/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
|
||||
addChannel(channelId: string, channelName: string, titleFilter?: string) {
|
||||
return fetchApi<{ id: string; channel_id: string }>("/api/channels", {
|
||||
|
||||
Reference in New Issue
Block a user