diff --git a/prisma/seed.js b/prisma/seed.js index 188c230..3beb110 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -313,6 +313,7 @@ async function upsertBoards(admin, categoryMap) { const created = []; // 텍스트/특수랭킹/특수출석 뷰 타입 ID 조회 (사전에 upsertViewTypes로 생성됨) const mainText = await prisma.boardViewType.findUnique({ where: { key: "main_text" } }); + const mainPreview = await prisma.boardViewType.findUnique({ where: { key: "main_preview" } }); const listText = await prisma.boardViewType.findUnique({ where: { key: "list_text" } }); const mainSpecial = await prisma.boardViewType.findUnique({ where: { key: "main_special_rank" } }); const listSpecial = await prisma.boardViewType.findUnique({ where: { key: "list_special_rank" } }); @@ -356,6 +357,7 @@ async function upsertBoards(admin, categoryMap) { ...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}), ...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}), ...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}), + ...(b.slug === "test" && mainPreview ? { mainPageViewTypeId: mainPreview.id } : {}), }, create: { name: b.name, @@ -371,6 +373,7 @@ async function upsertBoards(admin, categoryMap) { ...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}), ...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}), ...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}), + ...(b.slug === "test" && mainPreview ? { mainPageViewTypeId: mainPreview.id } : {}), }, }); created.push(board); diff --git a/public/uploads/1762701326416-gknp8r0e4af.webp b/public/uploads/1762701326416-gknp8r0e4af.webp new file mode 100644 index 0000000..bda5920 Binary files /dev/null and b/public/uploads/1762701326416-gknp8r0e4af.webp differ diff --git a/public/uploads/1762702099453-si2e8ubylu9.webp b/public/uploads/1762702099453-si2e8ubylu9.webp new file mode 100644 index 0000000..1f4b3ab Binary files /dev/null and b/public/uploads/1762702099453-si2e8ubylu9.webp differ diff --git a/public/uploads/1762703335687-i85lpr0bgo.webp b/public/uploads/1762703335687-i85lpr0bgo.webp new file mode 100644 index 0000000..f1a8cef Binary files /dev/null and b/public/uploads/1762703335687-i85lpr0bgo.webp differ diff --git a/public/uploads/1762704770941-j2nzhl8ww1.webp b/public/uploads/1762704770941-j2nzhl8ww1.webp new file mode 100644 index 0000000..8373104 Binary files /dev/null and b/public/uploads/1762704770941-j2nzhl8ww1.webp differ diff --git a/src/app/api/attendance/me-stats/route.ts b/src/app/api/attendance/me-stats/route.ts index 6d12113..d9a34bd 100644 --- a/src/app/api/attendance/me-stats/route.ts +++ b/src/app/api/attendance/me-stats/route.ts @@ -1,3 +1,4 @@ +export const dynamic = "force-dynamic"; import { NextResponse } from "next/server"; import prisma from "@/lib/prisma"; import { getUserIdFromRequest } from "@/lib/auth"; diff --git a/src/app/api/attendance/rankings/route.ts b/src/app/api/attendance/rankings/route.ts index 508fbfc..03e6584 100644 --- a/src/app/api/attendance/rankings/route.ts +++ b/src/app/api/attendance/rankings/route.ts @@ -1,3 +1,4 @@ +export const dynamic = "force-dynamic"; import { NextResponse } from "next/server"; import prisma from "@/lib/prisma"; @@ -12,8 +13,8 @@ export async function GET() { // 전체 출석 누적 상위 (Top 10) const overallGroups = await prisma.attendance.groupBy({ by: ["userId"], - _count: { _all: true }, - orderBy: { _count: { _all: "desc" } }, + _count: { userId: true }, + orderBy: { _count: { userId: "desc" } }, take: 20, }); const overallUserIds = overallGroups.map((g) => g.userId); @@ -25,7 +26,7 @@ export async function GET() { const overall = overallGroups.map((g) => ({ userId: g.userId, nickname: userMeta.get(g.userId)?.nickname ?? "회원", - count: g._count._all, + count: g._count.userId ?? 0, profileImage: userMeta.get(g.userId)?.profileImage ?? null, grade: userMeta.get(g.userId)?.grade ?? 0, })); diff --git a/src/app/api/attendance/route.ts b/src/app/api/attendance/route.ts index e2be37f..45291b6 100644 --- a/src/app/api/attendance/route.ts +++ b/src/app/api/attendance/route.ts @@ -1,3 +1,4 @@ +export const dynamic = "force-dynamic"; import { NextResponse } from "next/server"; import prisma from "@/lib/prisma"; import { getUserIdFromRequest } from "@/lib/auth"; diff --git a/src/app/api/posts/[id]/recommend/route.ts b/src/app/api/posts/[id]/recommend/route.ts index fb362bf..7157e01 100644 --- a/src/app/api/posts/[id]/recommend/route.ts +++ b/src/app/api/posts/[id]/recommend/route.ts @@ -1,18 +1,56 @@ import { NextResponse } from "next/server"; import prisma from "@/lib/prisma"; -import { z } from "zod"; - -const schema = z.object({ userId: z.string().optional(), clientHash: z.string().optional() }).refine( - (d) => !!d.userId || !!d.clientHash, - { message: "Provide userId or clientHash" } -); +import { getUserIdFromRequest } from "@/lib/auth"; +import crypto from "crypto"; export async function POST(req: Request, context: { params: Promise<{ id: string }> }) { const { id } = await context.params; - const body = await req.json().catch(() => ({})); - const parsed = schema.safeParse(body); - if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); - const { userId, clientHash } = parsed.data; + // 1) 사용자 식별 시도: 쿠키/헤더에서 userId 우선 + let userId = getUserIdFromRequest(req); + + // 2) 바디에서 clientHash 수용(클라이언트가 보낼 수 있음) + let clientHash: string | null = null; + // JSON 우선 + const jsonBody = await req + .json() + .catch(() => null); + if (jsonBody && typeof jsonBody === "object") { + if (!userId && typeof jsonBody.userId === "string" && jsonBody.userId.length > 0) { + userId = jsonBody.userId; + } + if (typeof jsonBody.clientHash === "string" && jsonBody.clientHash.length > 0) { + clientHash = jsonBody.clientHash; + } + } else { + // form 제출도 허용 (빈 폼일 수 있음) + const form = await req + .formData() + .catch(() => null); + if (form) { + const formUserId = form.get("userId"); + const formClientHash = form.get("clientHash"); + if (!userId && typeof formUserId === "string" && formUserId.length > 0) { + userId = formUserId; + } + if (typeof formClientHash === "string" && formClientHash.length > 0) { + clientHash = formClientHash; + } + } + } + + // 3) 둘 다 없으면 서버에서 익명 고유 clientHash 생성(IP + UA 기반) + if (!userId && !clientHash) { + const ua = req.headers.get("user-agent") || ""; + // x-forwarded-for 첫번째가 원 IP 가정 + const ip = + (req.headers.get("x-forwarded-for") || "") + .split(",") + .map((s) => s.trim())[0] || + req.headers.get("x-real-ip") || + ""; + const raw = `${ip}::${ua}`; + clientHash = crypto.createHash("sha256").update(raw).digest("hex").slice(0, 32); + } const existing = await prisma.reaction.findFirst({ where: { postId: id, type: "RECOMMEND", userId: userId ?? null, clientHash: clientHash ?? null }, @@ -27,6 +65,19 @@ export async function POST(req: Request, context: { params: Promise<{ id: string }); } + // 폼 제출의 경우, JSON 대신 원래 페이지로 리다이렉트 + // jsonBody가 없고(formData 경로였거나 파싱 실패), 브라우저에서 온 요청으로 가정 + if (!jsonBody) { + const referer = req.headers.get("referer"); + if (referer) { + return NextResponse.redirect(referer); + } + const baseUrl = new URL(req.url); + baseUrl.pathname = `/posts/${id}`; + baseUrl.search = ""; + return NextResponse.redirect(baseUrl.toString()); + } + return NextResponse.json({ ok: true }); } diff --git a/src/app/api/posts/[id]/route.ts b/src/app/api/posts/[id]/route.ts index 3b4a743..58ebc08 100644 --- a/src/app/api/posts/[id]/route.ts +++ b/src/app/api/posts/[id]/route.ts @@ -11,6 +11,7 @@ export async function GET(_: Request, context: { params: Promise<{ id: string }> include: { author: { select: { userId: true, nickname: true } }, board: { select: { id: true, name: true, slug: true } }, + stat: { select: { views: true, recommendCount: true, commentsCount: true } }, }, }); if (!post) return NextResponse.json({ error: "Not found" }, { status: 404 }); diff --git a/src/app/api/posts/[id]/view/route.ts b/src/app/api/posts/[id]/view/route.ts index 793fe15..fa7e70e 100644 --- a/src/app/api/posts/[id]/view/route.ts +++ b/src/app/api/posts/[id]/view/route.ts @@ -5,11 +5,62 @@ import { getUserIdFromRequest } from "@/lib/auth"; export async function POST(req: Request, context: { params: Promise<{ id: string }> }) { const { id } = await context.params; const userId = getUserIdFromRequest(req); - const ip = req.headers.get("x-forwarded-for") || undefined; + // x-forwarded-for는 다중 IP가 올 수 있음 -> 첫 번째(실제 클라이언트)만 사용 + const forwardedFor = req.headers.get("x-forwarded-for") || undefined; + const ip = forwardedFor ? forwardedFor.split(",")[0]?.trim() || undefined : undefined; const userAgent = req.headers.get("user-agent") || undefined; - await prisma.postViewLog.create({ data: { postId: id, userId: userId ?? null, ip, userAgent } }); - await prisma.postStat.upsert({ where: { postId: id }, update: { views: { increment: 1 } }, create: { postId: id, views: 1 } }); - return NextResponse.json({ ok: true }); + + // 중복 방지: 로그인 사용자는 userId로, 비로그인은 (ip + userAgent) 조합으로 1회만 카운트 + const orConditions: any[] = []; + if (userId) { + orConditions.push({ userId }); + } + // 비로그인 식별: 가능한 한 많은 신호를 사용 (ip+UA 우선, 단일 ip 또는 단일 UA로도 보수적으로 차단) + if (!userId) { + if (ip && userAgent) { + orConditions.push({ userId: null, ip, userAgent }); + } else if (ip) { + orConditions.push({ userId: null, ip, userAgent: null }); + } else if (userAgent) { + orConditions.push({ userId: null, ip: null, userAgent }); + } + } + + let counted = false; + await prisma.$transaction(async (tx) => { + if (orConditions.length > 0) { + const exists = await tx.postViewLog.findFirst({ + where: { + postId: id, + OR: orConditions, + }, + select: { id: true }, + }); + if (exists) { + return; + } + } else { + // 식별 정보가 전혀 없을 때는 안전을 위해 카운트하지 않음 + return; + } + + await tx.postViewLog.create({ + data: { + postId: id, + userId: userId ?? null, + ip: ip ?? null, + userAgent: userAgent ?? null, + }, + }); + await tx.postStat.upsert({ + where: { postId: id }, + update: { views: { increment: 1 } }, + create: { postId: id, views: 1 }, + }); + counted = true; + }); + + return NextResponse.json({ ok: true, counted }); } diff --git a/src/app/components/AttendanceCalendar.tsx b/src/app/components/AttendanceCalendar.tsx index 657c889..cf206d5 100644 --- a/src/app/components/AttendanceCalendar.tsx +++ b/src/app/components/AttendanceCalendar.tsx @@ -1,7 +1,8 @@ "use client"; import React from "react"; -import useSWR from "swr"; +import useSWR, { useSWRConfig } from "swr"; +import { useToast } from "@/app/components/ui/ToastProvider"; function getMonthRange(date: Date) { const y = date.getFullYear(); @@ -16,6 +17,9 @@ export default function AttendanceCalendar({ isLoggedIn = true }: { isLoggedIn?: const [days, setDays] = React.useState([]); const [todayChecked, setTodayChecked] = React.useState(null); const [loading, setLoading] = React.useState(false); + const [effectiveLoggedIn, setEffectiveLoggedIn] = React.useState(!!isLoggedIn); + const { show } = useToast(); + const { mutate } = useSWRConfig(); const { y, m } = getMonthRange(current); const ymKey = `${y}-${String(m + 1).padStart(2, "0")}`; @@ -34,11 +38,14 @@ export default function AttendanceCalendar({ isLoggedIn = true }: { isLoggedIn?: } setLoading(true); try { - const res = await fetch(`/api/attendance?year=${y}&month=${m + 1}`); + const res = await fetch(`/api/attendance?year=${y}&month=${m + 1}`, { cache: "no-store" }); const data = await res.json().catch(() => ({})); if (!cancelled) { setDays(Array.isArray(data?.days) ? data.days : []); - setTodayChecked(!!data?.today); + // today === null 이면 비로그인으로 판정 + const isAuthed = data?.today !== null && data?.today !== undefined; + setEffectiveLoggedIn(isAuthed && !!isLoggedIn); + setTodayChecked(isAuthed ? !!data?.today : null); } } finally { if (!cancelled) setLoading(false); @@ -51,19 +58,23 @@ export default function AttendanceCalendar({ isLoggedIn = true }: { isLoggedIn?: const goNext = () => setCurrent(new Date(current.getFullYear(), current.getMonth() + 1, 1)); const handleCheckIn = async () => { - if (!isLoggedIn) return; + if (!effectiveLoggedIn) return; try { const res = await fetch("/api/attendance", { method: "POST" }); const data = await res.json().catch(() => ({})); if (res.ok && !data?.duplicated) { - // 오늘 날짜 문자열 const d = new Date(); const s = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; - setDays((prev) => prev.includes(s) ? prev : [...prev, s]); + setDays((prev) => (prev.includes(s) ? prev : [...prev, s])); setTodayChecked(true); - } else { + show("출석 완료"); + } else if (res.ok) { setTodayChecked(true); + show("출석 완료"); } + // 출석 통계/랭킹 리셋(재검증) + mutate("/api/attendance/me-stats"); + mutate("/api/attendance/rankings"); } catch { // ignore } @@ -147,20 +158,20 @@ export default function AttendanceCalendar({ isLoggedIn = true }: { isLoggedIn?:
- {!isLoggedIn && ( + {!effectiveLoggedIn && (
로그인이 필요합니다
)} {/* 내 출석 통계 */} - {isLoggedIn && } + {effectiveLoggedIn && } ); @@ -169,7 +180,7 @@ export default function AttendanceCalendar({ isLoggedIn = true }: { isLoggedIn?: function MyAttendanceStats() { const { data } = useSWR<{ total: number; currentStreak: number; maxStreak: number }>( "/api/attendance/me-stats", - (u) => fetch(u).then((r) => r.json()) + (u) => fetch(u, { cache: "no-store" }).then((r) => r.json()) ); const total = data?.total ?? 0; const current = data?.currentStreak ?? 0; @@ -193,7 +204,10 @@ function MyAttendanceStats() { } function Rankings() { - const { data } = useSWR<{ overall: any[]; streak: any[] }>("/api/attendance/rankings", (u) => fetch(u).then((r) => r.json())); + const { data } = useSWR<{ overall: any[]; streak: any[] }>( + "/api/attendance/rankings", + (u) => fetch(u, { cache: "no-store" }).then((r) => r.json()) + ); const overall = data?.overall ?? []; const streak = data?.streak ?? []; return ( diff --git a/src/app/components/BoardPanelClient.tsx b/src/app/components/BoardPanelClient.tsx index 8b4a55c..022e18c 100644 --- a/src/app/components/BoardPanelClient.tsx +++ b/src/app/components/BoardPanelClient.tsx @@ -132,17 +132,19 @@ export function BoardPanelClient({ {selectedBoardData.specialRankUsers.map((user, idx) => { const rank = idx + 1; return ( - -
- -
- + +
+
+ +
+
+
@@ -315,7 +317,7 @@ export function BoardPanelClient({ {selectedBoardData.textPosts.map((p) => (
  • - + {isNewWithin1Hour(p.createdAt) && (
    diff --git a/src/app/components/Editor.tsx b/src/app/components/Editor.tsx index 711d6e4..b77968c 100644 --- a/src/app/components/Editor.tsx +++ b/src/app/components/Editor.tsx @@ -235,10 +235,6 @@ export function Editor({ value, onChange, placeholder, withToolbar = true }: Pro - - - - - - - - - - -
    ); }, [exec, withToolbar, wrapSelectionWithHtml]); diff --git a/src/app/components/PostList.tsx b/src/app/components/PostList.tsx index 23889b7..adba2ce 100644 --- a/src/app/components/PostList.tsx +++ b/src/app/components/PostList.tsx @@ -3,7 +3,7 @@ import useSWRInfinite from "swr/infinite"; import useSWR from "swr"; import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; -import { useSearchParams } from "next/navigation"; +import { useSearchParams, useRouter } from "next/navigation"; import ViewsIcon from "@/app/svgs/ViewsIcon"; import LikeIcon from "@/app/svgs/LikeIcon"; import CommentIcon from "@/app/svgs/CommentIcon"; @@ -41,6 +41,7 @@ import { UserNameMenu } from "./UserNameMenu"; export function PostList({ boardId, sort = "recent", q, tag, author, authorId, start, end, variant = "default", newPostHref, titleHoverOrange, pageSizeOverride, compact }: { boardId?: string; sort?: "recent" | "popular" | "views" | "likes" | "comments"; q?: string; tag?: string; author?: string; authorId?: string; start?: string; end?: string; variant?: "default" | "board"; newPostHref?: string; titleHoverOrange?: boolean; pageSizeOverride?: number; compact?: boolean }) { const sp = useSearchParams(); + const router = useRouter(); const listContainerRef = useRef(null); const [lockedMinHeight, setLockedMinHeight] = useState(null); @@ -228,13 +229,19 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
      {items.map((p) => ( -
    • +
    • {/* bullet/공지 아이콘 자리 */} -
      {p.isPinned ? "★" : "•"}
      +
      - +
      router.push(`/posts/${p.id}`)} + onKeyDown={(e) => { if (e.key === "Enter") router.push(`/posts/${p.id}`); }} + > {p.isPinned && 공지} {/* 게시판 정보 배지: 보드 지정 리스트가 아닐 때만 표시 */} {!boardId && p.board && ( @@ -249,14 +256,14 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s 0 && ( [{p.stat?.commentsCount}] )} - +
      {!!p.postTags?.length && (
      {p.postTags?.map((pt) => ( @@ -282,7 +289,7 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
      -
      +
      {p.stat?.views ?? 0} {p.stat?.recommendCount ?? 0} {p.stat?.commentsCount ?? 0} diff --git a/src/app/components/SendMessageButton.tsx b/src/app/components/SendMessageButton.tsx new file mode 100644 index 0000000..c6d6805 --- /dev/null +++ b/src/app/components/SendMessageButton.tsx @@ -0,0 +1,30 @@ +"use client"; + +import React from "react"; +import { useMessageModal } from "@/app/components/ui/MessageModalProvider"; + +type Props = { + receiverId: string; + receiverNickname?: string | null; + className?: string; + size?: "sm" | "md"; +}; + +export function SendMessageButton({ receiverId, receiverNickname, className, size = "sm" }: Props) { + const { openMessageModal } = useMessageModal(); + const padding = size === "md" ? "px-3 py-1.5" : "px-2 py-1"; + const text = size === "md" ? "text-sm" : "text-xs"; + + return ( + + ); +} + + diff --git a/src/app/components/SendMessageForm.tsx b/src/app/components/SendMessageForm.tsx index 4fc21cc..2777148 100644 --- a/src/app/components/SendMessageForm.tsx +++ b/src/app/components/SendMessageForm.tsx @@ -3,7 +3,7 @@ import React from "react"; import { useToast } from "@/app/components/ui/ToastProvider"; -export function SendMessageForm({ receiverId, receiverNickname }: { receiverId: string; receiverNickname?: string | null }) { +export function SendMessageForm({ receiverId, receiverNickname, onSent }: { receiverId: string; receiverNickname?: string | null; onSent?: () => void }) { const { show } = useToast(); const [body, setBody] = React.useState(""); const [sending, setSending] = React.useState(false); @@ -32,6 +32,7 @@ export function SendMessageForm({ receiverId, receiverNickname }: { receiverId: } setBody(""); show("쪽지를 보냈습니다"); + onSent?.(); } catch (e: any) { show(e?.message || "전송 실패"); } finally { diff --git a/src/app/components/UnreadMessagesModal.tsx b/src/app/components/UnreadMessagesModal.tsx new file mode 100644 index 0000000..7c06d27 --- /dev/null +++ b/src/app/components/UnreadMessagesModal.tsx @@ -0,0 +1,45 @@ +"use client"; + +import React from "react"; +import { Modal } from "@/app/components/ui/Modal"; +import Link from "next/link"; + +type Props = { + count: number; +}; + +export default function UnreadMessagesModal({ count }: Props) { + const [open, setOpen] = React.useState(true); + const onClose = () => setOpen(false); + if (!open || count <= 0) return null; + return ( + +
      +
      +

      알림

      +
      +
      +

      + 안읽은 쪽지가 있습니다{count > 0 ? ` (${count}개)` : ""}. +

      +
      +
      + + + 바로 확인 + +
      +
      +
      + ); +} + + diff --git a/src/app/components/ui/MessageModalProvider.tsx b/src/app/components/ui/MessageModalProvider.tsx index fb7f7da..2fae44b 100644 --- a/src/app/components/ui/MessageModalProvider.tsx +++ b/src/app/components/ui/MessageModalProvider.tsx @@ -59,7 +59,7 @@ export function MessageModalProvider({ children }: { children: React.ReactNode }
      - +
      `; - setForm((f) => ({ ...f, content: `${f.content}\n${figure}` })); - }} + onUploaded={(url) => setImages((prev) => [url, ...prev])} {...(boardSlug ? require("@/lib/photoPresets").getPhotoPresetBySlug(boardSlug) : {})} />
      + {images.length > 0 ? ( +
      +
      +
      업로드된 이미지
      +
      + {images.map((url) => ( +
      + +
      + + +
      +
      + ))} +
      +
      +
      + ) : null} +