diff --git a/src/app/api/projectMemos/route.ts b/src/app/api/projectMemos/route.ts index 5059c5d..1190992 100644 --- a/src/app/api/projectMemos/route.ts +++ b/src/app/api/projectMemos/route.ts @@ -309,40 +309,87 @@ export async function DELETE(request: Request) { /** * PATCH /api/projectMemos - * 메모 고정/해제 + * 메모 고정/해제, 이모지 반응 토글, 색상 레이블 변경 */ export async function PATCH(request: Request) { try { + const userId = await getAuthUserId(); const { searchParams } = new URL(request.url); const memoId = searchParams.get("memoId"); const body = await request.json(); - const { is_pinned } = body; if (!memoId) { return Response.json({ error: "메모 ID가 필수입니다" }, { status: 400 }); } - if (typeof is_pinned !== "boolean") { - return Response.json( - { error: "is_pinned 값이 필요합니다" }, - { status: 400 } - ); + // 이모지 반응 토글 + if (body.emoji !== undefined) { + const { data: memo, error: fetchError } = await supabase + .from("project_memos") + .select("reactions") + .eq("memo_id", memoId) + .single(); + + if (fetchError || !memo) { + return Response.json({ error: "메모를 찾을 수 없습니다" }, { status: 404 }); + } + + const reactions: Record = memo.reactions || {}; + const emoji: string = body.emoji; + const users = reactions[emoji] || []; + + if (users.includes(userId)) { + reactions[emoji] = users.filter((id) => id !== userId); + if (reactions[emoji].length === 0) delete reactions[emoji]; + } else { + reactions[emoji] = [...users, userId]; + } + + const { data, error } = await supabase + .from("project_memos") + .update({ reactions }) + .eq("memo_id", memoId) + .select() + .single(); + + if (error) throw error; + return Response.json(data, { status: 200 }); } - const { data, error } = await supabase - .from("project_memos") - .update({ - is_pinned, - pinned_at: is_pinned ? new Date().toISOString() : null, - }) - .eq("memo_id", memoId) - .select() - .single(); + // 색상 레이블 변경 (작성자만) + if ("label_color" in body) { + const memo = await getMemoById(memoId); + checkAuthor(memo, userId); - if (error) throw error; + const { data, error } = await supabase + .from("project_memos") + .update({ label_color: body.label_color ?? null }) + .eq("memo_id", memoId) + .select() + .single(); - return Response.json(data, { status: 200 }); + if (error) throw error; + return Response.json(data, { status: 200 }); + } + + // 고정/해제 + if (typeof body.is_pinned === "boolean") { + const { data, error } = await supabase + .from("project_memos") + .update({ + is_pinned: body.is_pinned, + pinned_at: body.is_pinned ? new Date().toISOString() : null, + }) + .eq("memo_id", memoId) + .select() + .single(); + + if (error) throw error; + return Response.json(data, { status: 200 }); + } + + return Response.json({ error: "변경할 필드가 없습니다" }, { status: 400 }); } catch (error) { - return errorResponse(error, "메모 고정에 실패했습니다"); + return errorResponse(error, "메모 업데이트에 실패했습니다"); } } diff --git a/src/components/features/kanban/MemoView.tsx b/src/components/features/kanban/MemoView.tsx index d6242ef..a8325f4 100644 --- a/src/components/features/kanban/MemoView.tsx +++ b/src/components/features/kanban/MemoView.tsx @@ -13,7 +13,7 @@ */ import { useState, useEffect, useMemo, useCallback, useRef } from "react"; -import { ProjectMemo } from "@/types/projectMemo"; +import { ProjectMemo, MemoLabelColor } from "@/types/projectMemo"; import { supabase } from "@/lib/supabase/supabase"; import { useSession } from "next-auth/react"; import { Icon } from "@/components/shared/Icon"; @@ -22,12 +22,32 @@ import Modal from "@/components/ui/Modal"; import Button from "@/components/ui/Button"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { ThumbsUp, ThumbsDown, Heart, Flame, Eye } from "lucide-react"; // 메모 최대 길이 제한 const MEMO_MAX_LENGTH = 5000; // 필터 타입: 전체, 내 메모, 고정된 메모 type FilterType = "all" | "mine" | "pinned"; + +// 메모지 색상 팔레트 (Google Keep 스타일) +const MEMO_COLORS: { value: MemoLabelColor; card: string; dot: string }[] = [ + { value: "red", card: "bg-red-50 dark:bg-red-900/30", dot: "bg-red-400" }, + { value: "orange", card: "bg-orange-50 dark:bg-orange-900/30", dot: "bg-orange-400" }, + { value: "yellow", card: "bg-yellow-50 dark:bg-yellow-900/30", dot: "bg-yellow-400" }, + { value: "green", card: "bg-green-50 dark:bg-green-900/30", dot: "bg-green-400" }, + { value: "blue", card: "bg-blue-50 dark:bg-blue-900/30", dot: "bg-blue-400" }, + { value: "purple", card: "bg-purple-50 dark:bg-purple-900/30", dot: "bg-purple-400" }, +]; + +const REACTION_SET = [ + { key: "thumbsUp", Icon: ThumbsUp, label: "동의" }, + { key: "thumbsDown", Icon: ThumbsDown, label: "재고 필요" }, + { key: "heart", Icon: Heart, label: "마음에 들어" }, + { key: "flame", Icon: Flame, label: "중요" }, + { key: "eye", Icon: Eye, label: "보고 있어" }, +] as const; + // 정렬 타입: 최신순, 오래된순 type SortType = "newest" | "oldest"; @@ -62,6 +82,10 @@ const MemoView = ({ projectId }: MemoFormProps) => { const { openModal, closeModal, modalProps } = useModal(); const [deletingMemoId, setDeletingMemoId] = useState(null); + // === 색상 피커 / 이모지 상태 === + const [colorPickerMemoId, setColorPickerMemoId] = useState(null); + const [emojiPickerMemoId, setEmojiPickerMemoId] = useState(null); + // === 인라인 편집 상태 === const [editingMemoId, setEditingMemoId] = useState(null); const [editContent, setEditContent] = useState(""); @@ -353,6 +377,44 @@ const MemoView = ({ projectId }: MemoFormProps) => { return session?.user?.user_id === memoUserId; }; + // 메모지 색상 변경 + const handleColorChange = async (memoId: string, color: MemoLabelColor) => { + setColorPickerMemoId(null); + setMemos((prev) => + prev.map((m) => (m.memo_id === memoId ? { ...m, label_color: color } : m)) + ); + await fetch(`/api/projectMemos?memoId=${memoId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label_color: color }), + }); + }; + + // 이모지 반응 토글 + const handleReaction = async (memoId: string, emoji: string) => { + if (!session?.user?.user_id) return; + const userId = session.user.user_id; + setMemos((prev) => + prev.map((m) => { + if (m.memo_id !== memoId) return m; + const reactions = { ...m.reactions }; + const users = reactions[emoji] ? [...reactions[emoji]] : []; + if (users.includes(userId)) { + reactions[emoji] = users.filter((id) => id !== userId); + if (reactions[emoji].length === 0) delete reactions[emoji]; + } else { + reactions[emoji] = [...users, userId]; + } + return { ...m, reactions }; + }) + ); + await fetch(`/api/projectMemos?memoId=${memoId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ emoji }), + }); + }; + // 인라인 편집 시작 const handleStartEdit = (memo: ProjectMemo) => { setEditingMemoId(memo.memo_id); @@ -698,14 +760,18 @@ const MemoView = ({ projectId }: MemoFormProps) => { ) : ( <> - {filteredMemos.map((memo) => ( + {filteredMemos.map((memo) => { + const colorStyle = MEMO_COLORS.find((c) => c.value === memo.label_color); + const cardClass = colorStyle + ? `${colorStyle.card} border border-border` + : memo.is_pinned + ? "bg-main-50 dark:bg-main-900/30 border-2 border-main-200 dark:border-main-700/50" + : "bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700/50"; + + return (
{/* 상단: 날짜 + 버튼들 */}
@@ -757,9 +823,35 @@ const MemoView = ({ projectId }: MemoFormProps) => {
- {/* 편집/삭제 버튼 */} + {/* 편집/색상/삭제 버튼 */} {isAuthor(memo.user_id) && editingMemoId !== memo.memo_id && (
+ {/* 색상 피커 */} +
+ + {colorPickerMemoId === memo.memo_id && ( +
+ {MEMO_COLORS.map((c) => ( + +
+ )} +
)} + {/* 아이콘 반응 */} +
+ {/* 기존 반응 */} + {REACTION_SET.map(({ key, Icon: ReactionIcon, label }) => { + const users = memo.reactions?.[key] ?? []; + if (users.length === 0) return null; + const active = users.includes(session?.user?.user_id || ""); + return ( + + ); + })} + {/* 반응 추가 버튼 */} +
+ + {emojiPickerMemoId === memo.memo_id && ( +
+ {REACTION_SET.map(({ key, Icon: ReactionIcon, label }) => ( + + ))} +
+ )} +
+
+ {/* 작성자 */}
@@ -857,7 +996,8 @@ const MemoView = ({ projectId }: MemoFormProps) => {
- ))} + ); + })} {/* 더보기 버튼 */} {hasMore && ( diff --git a/src/types/projectMemo.ts b/src/types/projectMemo.ts index 60410e0..202a373 100644 --- a/src/types/projectMemo.ts +++ b/src/types/projectMemo.ts @@ -7,6 +7,15 @@ // 프로젝트 메모 인터페이스 +export type MemoLabelColor = + | "red" + | "orange" + | "yellow" + | "green" + | "blue" + | "purple" + | null; + export interface ProjectMemo { memo_id: string; project_id: string; @@ -18,6 +27,8 @@ export interface ProjectMemo { deleted_at: string | null; created_at: string; updated_at: string; + reactions: Record; + label_color: MemoLabelColor; author?: { user_id: string; user_name: string;