컴잇
Some checks failed
deploy-on-main / deploy (push) Failing after 23s

This commit is contained in:
koreacomp5
2025-11-10 01:39:44 +09:00
parent 4337a8f69a
commit b579b32138
25 changed files with 361 additions and 70 deletions

View File

@@ -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);

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

View File

@@ -1,3 +1,4 @@
export const dynamic = "force-dynamic";
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { getUserIdFromRequest } from "@/lib/auth";

View File

@@ -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,
}));

View File

@@ -1,3 +1,4 @@
export const dynamic = "force-dynamic";
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { getUserIdFromRequest } from "@/lib/auth";

View File

@@ -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 });
}

View File

@@ -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 });

View File

@@ -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 });
}

View File

@@ -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<string[]>([]);
const [todayChecked, setTodayChecked] = React.useState<boolean | null>(null);
const [loading, setLoading] = React.useState(false);
const [effectiveLoggedIn, setEffectiveLoggedIn] = React.useState<boolean>(!!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?:
<div className="mt-4 flex items-center justify-center">
<button
onClick={handleCheckIn}
disabled={todayChecked === true || loading || !isLoggedIn}
disabled={todayChecked === true || loading || !effectiveLoggedIn}
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800 disabled:opacity-50"
>
{todayChecked ? "오늘 출석 완료" : "오늘 출석하기"}
</button>
</div>
{!isLoggedIn && (
{!effectiveLoggedIn && (
<div className="absolute inset-0 bg-white/70 backdrop-blur-[1px] rounded-lg flex items-center justify-center">
<div className="text-sm font-semibold text-neutral-700"> </div>
</div>
)}
</div>
{/* 내 출석 통계 */}
{isLoggedIn && <MyAttendanceStats />}
{effectiveLoggedIn && <MyAttendanceStats />}
<Rankings />
</div>
);
@@ -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 (

View File

@@ -132,17 +132,19 @@ export function BoardPanelClient({
{selectedBoardData.specialRankUsers.map((user, idx) => {
const rank = idx + 1;
return (
<Link href="/boards/ranking" key={user.userId} className=" mx-[4px] flex h-[72px] items-center rounded-[10px] overflow-hidden bg-white hover:bg-neutral-50 transition-shadow hover:[box-shadow:0_0_2px_0_var(--color-alpha-shadow2,_rgba(0,0,0,0.08)),_0_var(--shadow-y-3,_8px)_var(--shadow-blur-3,_16px)_0_var(--color-alpha-shadow3,_rgba(0,0,0,0.12))]">
<div className="h-[72px] w-[90px] relative shrink-0 bg-[#d5d5d5] overflow-hidden">
<UserAvatar
src={user.profileImage}
alt={user.nickname || "프로필"}
width={90}
height={72}
className="w-full h-full object-cover rounded-none"
/>
<div className="absolute top-0 right-0 w-[20px] h-[20px] flex items-center justify-center">
<GradeIcon grade={user.grade} width={20} height={20} />
<Link href="/boards/ranking" key={user.userId} className=" mx-[4px] flex h-[76px] items-center rounded-[10px] overflow-hidden bg-white hover:bg-neutral-50 transition-shadow hover:[box-shadow:0_0_2px_0_var(--color-alpha-shadow2,_rgba(0,0,0,0.08)),_0_var(--shadow-y-3,_8px)_var(--shadow-blur-3,_16px)_0_var(--color-alpha-shadow3,_rgba(0,0,0,0.12))] pl-[12px]">
<div className="relative shrink-0">
<div className="h-[56px] w-[56px] bg-[#d5d5d5] overflow-hidden rounded-full">
<UserAvatar
src={user.profileImage}
alt={user.nickname || "프로필"}
width={56}
height={56}
className="w-full h-full object-cover rounded-full"
/>
</div>
<div className="absolute -right-1 -bottom-1 w-[22px] h-[22px] flex items-center justify-center z-10">
<GradeIcon grade={user.grade} width={22} height={22} />
</div>
</div>
<div className="flex-1 flex items-center gap-[6px] px-[12px] md:px-[12px] py-[8px] min-w-0">
@@ -315,7 +317,7 @@ export function BoardPanelClient({
{selectedBoardData.textPosts.map((p) => (
<li key={p.id} className="border-b border-[#ededed] h-[28px] pl-0 pr-[4px] pt-0 pb-0">
<div className="flex items-center justify-between w-full">
<Link href={`/posts/${p.id}`} className="group flex items-center gap-[4px] h-[32px] overflow-hidden flex-1 min-w-0">
<Link href={`/posts/${p.id}`} className="group flex items-center gap-[4px] h-[24px] overflow-hidden flex-1 min-w-0 cursor-pointer">
{isNewWithin1Hour(p.createdAt) && (
<div className="relative w-[16px] h-[16px] shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">

View File

@@ -235,10 +235,6 @@ export function Editor({ value, onChange, placeholder, withToolbar = true }: Pro
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("formatBlock", "H2")} aria-label="H2">H2</button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("formatBlock", "H3")} aria-label="H3">H3</button>
<span className="mx-2 h-4 w-px bg-neutral-300" />
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("insertOrderedList")} aria-label="번호 목록">1.</button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("insertUnorderedList")} aria-label="글머리 목록"></button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => wrapSelectionWithHtml("<code>", "</code>")} aria-label="코드">&lt;/&gt;</button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("formatBlock", "BLOCKQUOTE")} aria-label="인용구"></button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("insertHorizontalRule")} aria-label="구분선"></button>
<span className="mx-2 h-4 w-px bg-neutral-300" />
<button
@@ -293,13 +289,6 @@ export function Editor({ value, onChange, placeholder, withToolbar = true }: Pro
<span className="block h-0.5 bg-current" style={{ width: "100%" }} />
</span>
</button>
<span className="mx-2 h-4 w-px bg-neutral-300" />
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("indent")} aria-label="들여쓰기"></button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("outdent")} aria-label="내어쓰기"></button>
<span className="mx-2 h-4 w-px bg-neutral-300" />
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("removeFormat")} aria-label="서식 제거">clear</button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("undo")} aria-label="되돌리기"></button>
<button type="button" className="px-2 py-1 text-sm rounded border border-neutral-300 hover:bg-neutral-100" onClick={() => exec("redo")} aria-label="다시하기"></button>
</div>
);
}, [exec, withToolbar, wrapSelectionWithHtml]);

View File

@@ -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<HTMLDivElement | null>(null);
const [lockedMinHeight, setLockedMinHeight] = useState<number | null>(null);
@@ -228,13 +229,19 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
<div ref={listContainerRef} style={{ minHeight: lockedMinHeight ? `${lockedMinHeight}px` : undefined }}>
<ul className="divide-y divide-[#ececec]">
{items.map((p) => (
<li key={p.id} className={`px-4 ${variant === "board" ? "" : "py-3 md:py-3"} hover:bg-neutral-50 transition-colors`}>
<li key={p.id} className={`px-4 ${variant === "board" ? "" : "py-4 md:py-4"} hover:bg-neutral-50 transition-colors`}>
<div className="grid grid-cols-1 md:grid-cols-[20px_1fr_120px_120px_80px] items-center gap-2">
{/* bullet/공지 아이콘 자리 */}
<div className="hidden md:flex items-center justify-center text-[#f94b37]">{p.isPinned ? "★" : "•"}</div>
<div className="hidden md:flex items-center justify-center text-[#f94b37]"></div>
<div className="min-w-0">
<Link href={`/posts/${p.id}`} className={`group block truncate text-neutral-900 ${variant === "board" ? "py-[36px]" : ""}`}>
<div
role="link"
tabIndex={0}
className={`group block truncate text-neutral-900 cursor-pointer ${variant === "board" ? "py-[8px]" : ""}`}
onClick={() => router.push(`/posts/${p.id}`)}
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/posts/${p.id}`); }}
>
{p.isPinned && <span className="mr-2 inline-flex items-center rounded bg-orange-100 text-orange-700 px-1.5 py-0.5 text-[11px]"></span>}
{/* 게시판 정보 배지: 보드 지정 리스트가 아닐 때만 표시 */}
{!boardId && p.board && (
@@ -249,14 +256,14 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
<span
className={`${
variant === "board" && titleHoverOrange
? "text-[24px] leading-[24px] font-[400] text-[#161616]"
? "text-[16px] leading-[16px] font-[400] text-[#161616]"
: compact
? "text-[14px] leading-[20px]"
: "text-[15px] md:text-base"
} ${
titleHoverOrange
? variant === "board"
? "group-hover:text-[var(--red-50,#F94B37)] group-hover:font-[700] group-hover:text-[24px] group-hover:leading-[24px]"
? "group-hover:text-[var(--red-50,#F94B37)] group-hover:font-[700] group-hover:text-[16px] group-hover:leading-[16px]"
: "group-hover:text-[var(--red-50,#F94B37)] group-hover:font-[700]"
: ""
}`}
@@ -267,7 +274,7 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
{(p.stat?.commentsCount ?? 0) > 0 && (
<span className="ml-1 text-[12px] md:text-[12px] text-[#f94b37] align-middle">[{p.stat?.commentsCount}]</span>
)}
</Link>
</div>
{!!p.postTags?.length && (
<div className="mt-1 hidden md:flex flex-wrap gap-1 text-[11px] text-neutral-500">
{p.postTags?.map((pt) => (
@@ -282,7 +289,7 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
<UserNameMenu userId={(p as any).author?.userId} nickname={p.author?.nickname} />
</span>
</div>
<div className="md:w-[120px] text-[11px] text-[#8c8c8c] text-center flex items-center justify-center gap-3">
<div className="md:w-[120px] text-xs text-[#8c8c8c] text-center flex items-center justify-center gap-3">
<span className="inline-flex items-center gap-1"><ViewsIcon width={16} height={16} />{p.stat?.views ?? 0}</span>
<span className="inline-flex items-center gap-1"><LikeIcon width={16} height={16} />{p.stat?.recommendCount ?? 0}</span>
<span className="inline-flex items-center gap-1"><CommentIcon width={16} height={16} />{p.stat?.commentsCount ?? 0}</span>

View File

@@ -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 (
<button
type="button"
onClick={() => openMessageModal({ receiverId, receiverNickname: receiverNickname ?? null })}
className={`inline-flex items-center rounded-md border border-neutral-300 bg-white ${padding} ${text} text-neutral-800 hover:bg-neutral-100 cursor-pointer ${className || ""}`}
aria-label="쪽지 보내기"
>
</button>
);
}

View File

@@ -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 {

View File

@@ -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 (
<Modal open={open} onClose={onClose}>
<div className="w-[420px] max-w-[92vw] rounded-lg border border-neutral-200 shadow-xl">
<div className="px-5 py-4 border-b border-neutral-200">
<h3 className="text-base font-semibold text-neutral-900"></h3>
</div>
<div className="p-5">
<p className="text-sm text-neutral-800">
{count > 0 ? ` (${count}개)` : ""}.
</p>
</div>
<div className="px-5 pb-5 flex items-center justify-end gap-2">
<button
onClick={onClose}
className="h-9 px-4 rounded-md border border-neutral-300 bg-white text-sm hover:bg-neutral-100"
>
</button>
<Link
href="/my-page?tab=messages-received"
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800 inline-flex items-center justify-center text-center"
>
</Link>
</div>
</div>
</Modal>
);
}

View File

@@ -59,7 +59,7 @@ export function MessageModalProvider({ children }: { children: React.ReactNode }
</button>
</div>
<div className="p-5">
<SendMessageForm receiverId={target.receiverId} receiverNickname={target.receiverNickname} />
<SendMessageForm receiverId={target.receiverId} receiverNickname={target.receiverNickname} onSent={close} />
</div>
<div className="px-5 pb-4 flex items-center justify-end">
<button

View File

@@ -276,6 +276,11 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
})()}
{activeTab === "messages-received" && (async () => {
const pageSize = 20;
// 받은 쪽지함 진입 시 미읽은 메시지를 읽음 처리
await prisma.message.updateMany({
where: { receiverId: currentUser.userId, readAt: null },
data: { readAt: new Date() },
});
const messages = await prisma.message.findMany({
where: { receiverId: currentUser.userId },
orderBy: { createdAt: "desc" },

View File

@@ -16,6 +16,7 @@ import { GradeIcon, getGradeName } from "@/app/components/GradeIcon";
import prisma from "@/lib/prisma";
import { headers } from "next/headers";
import { InlineLoginForm } from "@/app/components/InlineLoginForm";
import UnreadMessagesModal from "@/app/components/UnreadMessagesModal";
export default async function Home({ searchParams }: { searchParams: Promise<{ sort?: "recent" | "popular" } | undefined> }) {
const sp = await searchParams;
@@ -56,9 +57,13 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
// 내가 쓴 게시글/댓글 수
let myPostsCount = 0;
let myCommentsCount = 0;
let unreadMessagesCount = 0;
if (currentUser) {
myPostsCount = await prisma.post.count({ where: { authorId: currentUser.userId, status: "published" } });
myCommentsCount = await prisma.comment.count({ where: { authorId: currentUser.userId } });
unreadMessagesCount = await prisma.message.count({
where: { receiverId: currentUser.userId, readAt: null },
});
}
// 메인페이지 설정 불러오기
@@ -193,6 +198,9 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
return (
<div className="space-y-8">
{currentUser && unreadMessagesCount > 0 && (
<UnreadMessagesModal count={unreadMessagesCount} />
)}
{/* 히어로 섹션: 상단 대형 비주얼 영역 (설정 온오프) */}
{showBanner && (
<section>
@@ -264,12 +272,13 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
</span>
</span>
</Link>
<Link href="/my-page?tab=points" className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
<Link href={`/my-page?tab=messages-received`} className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">
<span className="flex items-center w-full pl-[88px]">
<span className="flex items-center gap-[8px]">
<SearchIcon width={16} height={16} />
<span> </span>
<span> </span>
</span>
<span className="ml-auto inline-flex items-center justify-center h-[20px] px-[8px] rounded-full bg-white text-[#5c5c5c] text-[12px] leading-[20px] shrink-0">{unreadMessagesCount.toLocaleString()}</span>
</span>
</Link>
<Link href={`/my-page?tab=posts`} className="w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#5c5c5c] text-white text-[12px] font-[700] flex items-center px-[12px]">

View File

@@ -3,9 +3,12 @@ import { headers } from "next/headers";
import { HeroBanner } from "@/app/components/HeroBanner";
import { RelatedPosts } from "./RelatedPosts";
import prisma from "@/lib/prisma";
import Link from "next/link";
import { CommentSection } from "@/app/components/CommentSection";
import { UserNameMenu } from "@/app/components/UserNameMenu";
import { ViewTracker } from "./ViewTracker";
import ViewsIcon from "@/app/svgs/ViewsIcon";
import LikeIcon from "@/app/svgs/LikeIcon";
// 서버 전용 페이지: params가 Promise일 수 있어 안전 언랩 후 절대 URL로 fetch합니다.
export default async function PostDetail({ params }: { params: any }) {
@@ -53,6 +56,8 @@ export default async function PostDetail({ params }: { params: any }) {
}
}
const backHref = post?.board?.slug ? `/boards/${post.board.slug}` : "/";
return (
<div className="space-y-6">
<ViewTracker postId={id} />
@@ -67,6 +72,16 @@ export default async function PostDetail({ params }: { params: any }) {
</section>
)}
{/* 목록으로 버튼 */}
<div className="px-1">
<Link
href={backHref}
className="inline-flex items-center gap-1 h-9 px-3 rounded-md border border-neutral-300 bg-white text-sm text-neutral-900 hover:bg-neutral-100"
>
</Link>
</div>
{/* 본문 카드 */}
<section className="rounded-xl overflow-hidden bg-white">
<header className="px-4 py-3 border-b border-neutral-200">
@@ -82,6 +97,20 @@ export default async function PostDetail({ params }: { params: any }) {
</span>
{createdAt && <span aria-hidden></span>}
{createdAt && <span>{createdAt.toLocaleString()}</span>}
{/* 지표: 조회수 / 좋아요수 / 댓글수 */}
{(() => {
const views = post?.stat?.views ?? 0;
const likes = post?.stat?.recommendCount ?? 0;
const comments = post?.stat?.commentsCount ?? 0;
return (
<>
<span aria-hidden></span>
<span className="inline-flex items-center gap-1"><ViewsIcon width={14} height={14} />{views}</span>
<span className="inline-flex items-center gap-1"><LikeIcon width={14} height={14} />{likes}</span>
<span className="inline-flex items-center gap-1"> {comments}</span>
</>
);
})()}
</div>
</header>
<div className="p-4 md:p-6">

View File

@@ -16,6 +16,7 @@ export default function NewPostPage() {
const initialBoardId = sp.get("boardId") ?? "";
const boardSlug = sp.get("boardSlug") ?? undefined;
const [form, setForm] = useState({ boardId: initialBoardId, title: "", content: "" });
const [images, setImages] = useState<string[]>([]);
const [isSecret, setIsSecret] = useState(false);
const [loading, setLoading] = useState(false);
const { data: meData } = useSWR<{ user: { userId: string } | null }>("/api/me", (u: string) => fetch(u).then((r) => r.json()));
@@ -144,16 +145,47 @@ export default function NewPostPage() {
<span aria-hidden>🙂</span>
<UploadButton
multiple
onUploaded={(url) => {
const figure = `<figure style="margin:8px 0; position: relative; display: inline-block;" data-resizable="true"><img src="${url}" alt="" style="max-width: 100%; height: auto; display: block; cursor: pointer;" /><div class="resize-handle" style="position: absolute; bottom: 0; right: 0; width: 16px; height: 16px; background: #f94b37; cursor: se-resize; border-radius: 50% 0 0 0; opacity: 0.8;"></div><button class="delete-image-btn" style="position: absolute; top: 4px; right: 4px; width: 24px; height: 24px; background: rgba(0, 0, 0, 0.6); color: white; border: none; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; line-height: 1; opacity: 0.8;" title="이미지 삭제">×</button></figure>`;
setForm((f) => ({ ...f, content: `${f.content}\n${figure}` }));
}}
onUploaded={(url) => setImages((prev) => [url, ...prev])}
{...(boardSlug ? require("@/lib/photoPresets").getPhotoPresetBySlug(boardSlug) : {})}
/>
</div>
</div>
</div>
{images.length > 0 ? (
<div className="mt-3">
<div className="px-4 py-3 bg-neutral-50 border border-neutral-200 rounded-2xl">
<div className="mb-2 text-sm text-neutral-700"> </div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{images.map((url) => (
<div key={url} className="relative group border border-neutral-200 rounded-xl overflow-hidden bg-white">
<img src={url} alt="" className="w-full h-36 object-cover" />
<div className="absolute inset-x-0 bottom-0 p-2 flex gap-2 justify-end opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
className="px-2.5 py-1 text-xs rounded-md border border-neutral-300 bg-white hover:bg-neutral-100"
onClick={() => {
const imgHtml = `<p><img src="${url}" alt="" style="max-width: 100%; height: auto; display: block; margin: 8px 0;" /></p>`;
setForm((f) => ({ ...f, content: `${f.content}\n${imgHtml}` }));
}}
>
</button>
<button
type="button"
className="px-2.5 py-1 text-xs rounded-md border border-neutral-300 bg-white hover:bg-neutral-100"
onClick={() => setImages((prev) => prev.filter((u) => u !== url))}
>
</button>
</div>
</div>
))}
</div>
</div>
</div>
) : null}
<div className="mt-4">
<button
disabled={loading}

View File

@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import useSWR from "swr";
import { UserNameMenu } from "@/app/components/UserNameMenu";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
@@ -19,7 +20,10 @@ export default function RankingPage({ searchParams }: { searchParams?: { period?
<ol>
{(data?.items ?? []).map((i) => (
<li key={i.userId}>
<strong>{i.nickname}</strong> {i.points}
<strong>
<UserNameMenu userId={i.userId} nickname={i.nickname} underlineOnHover={false} />
</strong>{" "}
{i.points}
</li>
))}
</ol>

View File

@@ -1,15 +1,27 @@
import prisma from "@/lib/prisma";
import { notFound } from "next/navigation";
import { PostList } from "@/app/components/PostList";
import { headers } from "next/headers";
import { HeroBanner } from "@/app/components/HeroBanner";
import { UserAvatar } from "@/app/components/UserAvatar";
import { GradeIcon, getGradeName } from "@/app/components/GradeIcon";
import { SendMessageButton } from "@/app/components/SendMessageButton";
import { headers } from "next/headers";
export default async function UserPublicProfile({ params }: { params: Promise<{ id: string }> }) {
const p = await params;
const userId = p.id;
// 현재 로그인한 사용자 식별 (uid 쿠키 기반)
const headerList = await headers();
const cookieHeader = headerList.get("cookie") || "";
const currentUid =
cookieHeader
.split(";")
.map((s) => s.trim())
.find((pair) => pair.startsWith("uid="))
?.split("=")[1] || "";
const currentUserId = currentUid ? decodeURIComponent(currentUid) : null;
const user = await prisma.user.findUnique({
where: { userId },
select: {
@@ -52,6 +64,9 @@ export default async function UserPublicProfile({ params }: { params: Promise<{
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold text-neutral-900 truncate">{user.nickname}</h1>
<GradeIcon grade={user.grade} width={20} height={20} />
{currentUserId && currentUserId !== user.userId && (
<SendMessageButton receiverId={user.userId} receiverNickname={user.nickname} />
)}
</div>
<div className="text-sm text-neutral-600 mt-1 flex items-center gap-3">
<span>Lv. {user.level}</span>