@@ -313,6 +313,7 @@ async function upsertBoards(admin, categoryMap) {
|
|||||||
const created = [];
|
const created = [];
|
||||||
// 텍스트/특수랭킹/특수출석 뷰 타입 ID 조회 (사전에 upsertViewTypes로 생성됨)
|
// 텍스트/특수랭킹/특수출석 뷰 타입 ID 조회 (사전에 upsertViewTypes로 생성됨)
|
||||||
const mainText = await prisma.boardViewType.findUnique({ where: { key: "main_text" } });
|
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 listText = await prisma.boardViewType.findUnique({ where: { key: "list_text" } });
|
||||||
const mainSpecial = await prisma.boardViewType.findUnique({ where: { key: "main_special_rank" } });
|
const mainSpecial = await prisma.boardViewType.findUnique({ where: { key: "main_special_rank" } });
|
||||||
const listSpecial = await prisma.boardViewType.findUnique({ where: { key: "list_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" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}),
|
||||||
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
|
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
|
||||||
...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}),
|
...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}),
|
||||||
|
...(b.slug === "test" && mainPreview ? { mainPageViewTypeId: mainPreview.id } : {}),
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
name: b.name,
|
name: b.name,
|
||||||
@@ -371,6 +373,7 @@ async function upsertBoards(admin, categoryMap) {
|
|||||||
...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}),
|
...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}),
|
||||||
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
|
...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}),
|
||||||
...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}),
|
...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}),
|
||||||
|
...(b.slug === "test" && mainPreview ? { mainPageViewTypeId: mainPreview.id } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
created.push(board);
|
created.push(board);
|
||||||
|
|||||||
BIN
public/uploads/1762701326416-gknp8r0e4af.webp
Normal file
BIN
public/uploads/1762701326416-gknp8r0e4af.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
BIN
public/uploads/1762702099453-si2e8ubylu9.webp
Normal file
BIN
public/uploads/1762702099453-si2e8ubylu9.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
public/uploads/1762703335687-i85lpr0bgo.webp
Normal file
BIN
public/uploads/1762703335687-i85lpr0bgo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/uploads/1762704770941-j2nzhl8ww1.webp
Normal file
BIN
public/uploads/1762704770941-j2nzhl8ww1.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
@@ -1,3 +1,4 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { getUserIdFromRequest } from "@/lib/auth";
|
import { getUserIdFromRequest } from "@/lib/auth";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
@@ -12,8 +13,8 @@ export async function GET() {
|
|||||||
// 전체 출석 누적 상위 (Top 10)
|
// 전체 출석 누적 상위 (Top 10)
|
||||||
const overallGroups = await prisma.attendance.groupBy({
|
const overallGroups = await prisma.attendance.groupBy({
|
||||||
by: ["userId"],
|
by: ["userId"],
|
||||||
_count: { _all: true },
|
_count: { userId: true },
|
||||||
orderBy: { _count: { _all: "desc" } },
|
orderBy: { _count: { userId: "desc" } },
|
||||||
take: 20,
|
take: 20,
|
||||||
});
|
});
|
||||||
const overallUserIds = overallGroups.map((g) => g.userId);
|
const overallUserIds = overallGroups.map((g) => g.userId);
|
||||||
@@ -25,7 +26,7 @@ export async function GET() {
|
|||||||
const overall = overallGroups.map((g) => ({
|
const overall = overallGroups.map((g) => ({
|
||||||
userId: g.userId,
|
userId: g.userId,
|
||||||
nickname: userMeta.get(g.userId)?.nickname ?? "회원",
|
nickname: userMeta.get(g.userId)?.nickname ?? "회원",
|
||||||
count: g._count._all,
|
count: g._count.userId ?? 0,
|
||||||
profileImage: userMeta.get(g.userId)?.profileImage ?? null,
|
profileImage: userMeta.get(g.userId)?.profileImage ?? null,
|
||||||
grade: userMeta.get(g.userId)?.grade ?? 0,
|
grade: userMeta.get(g.userId)?.grade ?? 0,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { getUserIdFromRequest } from "@/lib/auth";
|
import { getUserIdFromRequest } from "@/lib/auth";
|
||||||
|
|||||||
@@ -1,18 +1,56 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { z } from "zod";
|
import { getUserIdFromRequest } from "@/lib/auth";
|
||||||
|
import crypto from "crypto";
|
||||||
const schema = z.object({ userId: z.string().optional(), clientHash: z.string().optional() }).refine(
|
|
||||||
(d) => !!d.userId || !!d.clientHash,
|
|
||||||
{ message: "Provide userId or clientHash" }
|
|
||||||
);
|
|
||||||
|
|
||||||
export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
|
export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
const body = await req.json().catch(() => ({}));
|
// 1) 사용자 식별 시도: 쿠키/헤더에서 userId 우선
|
||||||
const parsed = schema.safeParse(body);
|
let userId = getUserIdFromRequest(req);
|
||||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
|
||||||
const { userId, clientHash } = parsed.data;
|
// 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({
|
const existing = await prisma.reaction.findFirst({
|
||||||
where: { postId: id, type: "RECOMMEND", userId: userId ?? null, clientHash: clientHash ?? null },
|
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 });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export async function GET(_: Request, context: { params: Promise<{ id: string }>
|
|||||||
include: {
|
include: {
|
||||||
author: { select: { userId: true, nickname: true } },
|
author: { select: { userId: true, nickname: true } },
|
||||||
board: { select: { id: true, name: true, slug: 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 });
|
if (!post) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|||||||
@@ -5,11 +5,62 @@ import { getUserIdFromRequest } from "@/lib/auth";
|
|||||||
export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
|
export async function POST(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await context.params;
|
const { id } = await context.params;
|
||||||
const userId = getUserIdFromRequest(req);
|
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;
|
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 } });
|
// 중복 방지: 로그인 사용자는 userId로, 비로그인은 (ip + userAgent) 조합으로 1회만 카운트
|
||||||
return NextResponse.json({ ok: true });
|
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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import useSWR from "swr";
|
import useSWR, { useSWRConfig } from "swr";
|
||||||
|
import { useToast } from "@/app/components/ui/ToastProvider";
|
||||||
|
|
||||||
function getMonthRange(date: Date) {
|
function getMonthRange(date: Date) {
|
||||||
const y = date.getFullYear();
|
const y = date.getFullYear();
|
||||||
@@ -16,6 +17,9 @@ export default function AttendanceCalendar({ isLoggedIn = true }: { isLoggedIn?:
|
|||||||
const [days, setDays] = React.useState<string[]>([]);
|
const [days, setDays] = React.useState<string[]>([]);
|
||||||
const [todayChecked, setTodayChecked] = React.useState<boolean | null>(null);
|
const [todayChecked, setTodayChecked] = React.useState<boolean | null>(null);
|
||||||
const [loading, setLoading] = React.useState(false);
|
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 { y, m } = getMonthRange(current);
|
||||||
const ymKey = `${y}-${String(m + 1).padStart(2, "0")}`;
|
const ymKey = `${y}-${String(m + 1).padStart(2, "0")}`;
|
||||||
@@ -34,11 +38,14 @@ export default function AttendanceCalendar({ isLoggedIn = true }: { isLoggedIn?:
|
|||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setDays(Array.isArray(data?.days) ? data.days : []);
|
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 {
|
} finally {
|
||||||
if (!cancelled) setLoading(false);
|
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 goNext = () => setCurrent(new Date(current.getFullYear(), current.getMonth() + 1, 1));
|
||||||
|
|
||||||
const handleCheckIn = async () => {
|
const handleCheckIn = async () => {
|
||||||
if (!isLoggedIn) return;
|
if (!effectiveLoggedIn) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/attendance", { method: "POST" });
|
const res = await fetch("/api/attendance", { method: "POST" });
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (res.ok && !data?.duplicated) {
|
if (res.ok && !data?.duplicated) {
|
||||||
// 오늘 날짜 문자열
|
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
const s = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
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);
|
setTodayChecked(true);
|
||||||
} else {
|
show("출석 완료");
|
||||||
|
} else if (res.ok) {
|
||||||
setTodayChecked(true);
|
setTodayChecked(true);
|
||||||
|
show("출석 완료");
|
||||||
}
|
}
|
||||||
|
// 출석 통계/랭킹 리셋(재검증)
|
||||||
|
mutate("/api/attendance/me-stats");
|
||||||
|
mutate("/api/attendance/rankings");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -147,20 +158,20 @@ export default function AttendanceCalendar({ isLoggedIn = true }: { isLoggedIn?:
|
|||||||
<div className="mt-4 flex items-center justify-center">
|
<div className="mt-4 flex items-center justify-center">
|
||||||
<button
|
<button
|
||||||
onClick={handleCheckIn}
|
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"
|
className="h-9 px-4 rounded-md bg-neutral-900 text-white text-sm hover:bg-neutral-800 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{todayChecked ? "오늘 출석 완료" : "오늘 출석하기"}
|
{todayChecked ? "오늘 출석 완료" : "오늘 출석하기"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{!isLoggedIn && (
|
{!effectiveLoggedIn && (
|
||||||
<div className="absolute inset-0 bg-white/70 backdrop-blur-[1px] rounded-lg flex items-center justify-center">
|
<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 className="text-sm font-semibold text-neutral-700">로그인이 필요합니다</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* 내 출석 통계 */}
|
{/* 내 출석 통계 */}
|
||||||
{isLoggedIn && <MyAttendanceStats />}
|
{effectiveLoggedIn && <MyAttendanceStats />}
|
||||||
<Rankings />
|
<Rankings />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -169,7 +180,7 @@ export default function AttendanceCalendar({ isLoggedIn = true }: { isLoggedIn?:
|
|||||||
function MyAttendanceStats() {
|
function MyAttendanceStats() {
|
||||||
const { data } = useSWR<{ total: number; currentStreak: number; maxStreak: number }>(
|
const { data } = useSWR<{ total: number; currentStreak: number; maxStreak: number }>(
|
||||||
"/api/attendance/me-stats",
|
"/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 total = data?.total ?? 0;
|
||||||
const current = data?.currentStreak ?? 0;
|
const current = data?.currentStreak ?? 0;
|
||||||
@@ -193,7 +204,10 @@ function MyAttendanceStats() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Rankings() {
|
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 overall = data?.overall ?? [];
|
||||||
const streak = data?.streak ?? [];
|
const streak = data?.streak ?? [];
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -132,17 +132,19 @@ export function BoardPanelClient({
|
|||||||
{selectedBoardData.specialRankUsers.map((user, idx) => {
|
{selectedBoardData.specialRankUsers.map((user, idx) => {
|
||||||
const rank = idx + 1;
|
const rank = idx + 1;
|
||||||
return (
|
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))]">
|
<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="h-[72px] w-[90px] relative shrink-0 bg-[#d5d5d5] overflow-hidden">
|
<div className="relative shrink-0">
|
||||||
<UserAvatar
|
<div className="h-[56px] w-[56px] bg-[#d5d5d5] overflow-hidden rounded-full">
|
||||||
src={user.profileImage}
|
<UserAvatar
|
||||||
alt={user.nickname || "프로필"}
|
src={user.profileImage}
|
||||||
width={90}
|
alt={user.nickname || "프로필"}
|
||||||
height={72}
|
width={56}
|
||||||
className="w-full h-full object-cover rounded-none"
|
height={56}
|
||||||
/>
|
className="w-full h-full object-cover rounded-full"
|
||||||
<div className="absolute top-0 right-0 w-[20px] h-[20px] flex items-center justify-center">
|
/>
|
||||||
<GradeIcon grade={user.grade} width={20} height={20} />
|
</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>
|
</div>
|
||||||
<div className="flex-1 flex items-center gap-[6px] px-[12px] md:px-[12px] py-[8px] min-w-0">
|
<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) => (
|
{selectedBoardData.textPosts.map((p) => (
|
||||||
<li key={p.id} className="border-b border-[#ededed] h-[28px] pl-0 pr-[4px] pt-0 pb-0">
|
<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">
|
<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) && (
|
{isNewWithin1Hour(p.createdAt) && (
|
||||||
<div className="relative w-[16px] h-[16px] shrink-0">
|
<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">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
|||||||
@@ -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", "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>
|
<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" />
|
<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="코드"></></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>
|
<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" />
|
<span className="mx-2 h-4 w-px bg-neutral-300" />
|
||||||
<button
|
<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 className="block h-0.5 bg-current" style={{ width: "100%" }} />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}, [exec, withToolbar, wrapSelectionWithHtml]);
|
}, [exec, withToolbar, wrapSelectionWithHtml]);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import useSWRInfinite from "swr/infinite";
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import ViewsIcon from "@/app/svgs/ViewsIcon";
|
import ViewsIcon from "@/app/svgs/ViewsIcon";
|
||||||
import LikeIcon from "@/app/svgs/LikeIcon";
|
import LikeIcon from "@/app/svgs/LikeIcon";
|
||||||
import CommentIcon from "@/app/svgs/CommentIcon";
|
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 }) {
|
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 sp = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
const listContainerRef = useRef<HTMLDivElement | null>(null);
|
const listContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [lockedMinHeight, setLockedMinHeight] = useState<number | 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 }}>
|
<div ref={listContainerRef} style={{ minHeight: lockedMinHeight ? `${lockedMinHeight}px` : undefined }}>
|
||||||
<ul className="divide-y divide-[#ececec]">
|
<ul className="divide-y divide-[#ececec]">
|
||||||
{items.map((p) => (
|
{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">
|
<div className="grid grid-cols-1 md:grid-cols-[20px_1fr_120px_120px_80px] items-center gap-2">
|
||||||
{/* bullet/공지 아이콘 자리 */}
|
{/* 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">
|
<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>}
|
{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 && (
|
{!boardId && p.board && (
|
||||||
@@ -249,14 +256,14 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
|
|||||||
<span
|
<span
|
||||||
className={`${
|
className={`${
|
||||||
variant === "board" && titleHoverOrange
|
variant === "board" && titleHoverOrange
|
||||||
? "text-[24px] leading-[24px] font-[400] text-[#161616]"
|
? "text-[16px] leading-[16px] font-[400] text-[#161616]"
|
||||||
: compact
|
: compact
|
||||||
? "text-[14px] leading-[20px]"
|
? "text-[14px] leading-[20px]"
|
||||||
: "text-[15px] md:text-base"
|
: "text-[15px] md:text-base"
|
||||||
} ${
|
} ${
|
||||||
titleHoverOrange
|
titleHoverOrange
|
||||||
? variant === "board"
|
? 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]"
|
: "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 && (
|
{(p.stat?.commentsCount ?? 0) > 0 && (
|
||||||
<span className="ml-1 text-[12px] md:text-[12px] text-[#f94b37] align-middle">[{p.stat?.commentsCount}]</span>
|
<span className="ml-1 text-[12px] md:text-[12px] text-[#f94b37] align-middle">[{p.stat?.commentsCount}]</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</div>
|
||||||
{!!p.postTags?.length && (
|
{!!p.postTags?.length && (
|
||||||
<div className="mt-1 hidden md:flex flex-wrap gap-1 text-[11px] text-neutral-500">
|
<div className="mt-1 hidden md:flex flex-wrap gap-1 text-[11px] text-neutral-500">
|
||||||
{p.postTags?.map((pt) => (
|
{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} />
|
<UserNameMenu userId={(p as any).author?.userId} nickname={p.author?.nickname} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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"><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"><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>
|
<span className="inline-flex items-center gap-1"><CommentIcon width={16} height={16} />{p.stat?.commentsCount ?? 0}</span>
|
||||||
|
|||||||
30
src/app/components/SendMessageButton.tsx
Normal file
30
src/app/components/SendMessageButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useToast } from "@/app/components/ui/ToastProvider";
|
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 { show } = useToast();
|
||||||
const [body, setBody] = React.useState("");
|
const [body, setBody] = React.useState("");
|
||||||
const [sending, setSending] = React.useState(false);
|
const [sending, setSending] = React.useState(false);
|
||||||
@@ -32,6 +32,7 @@ export function SendMessageForm({ receiverId, receiverNickname }: { receiverId:
|
|||||||
}
|
}
|
||||||
setBody("");
|
setBody("");
|
||||||
show("쪽지를 보냈습니다");
|
show("쪽지를 보냈습니다");
|
||||||
|
onSent?.();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
show(e?.message || "전송 실패");
|
show(e?.message || "전송 실패");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
45
src/app/components/UnreadMessagesModal.tsx
Normal file
45
src/app/components/UnreadMessagesModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ export function MessageModalProvider({ children }: { children: React.ReactNode }
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<SendMessageForm receiverId={target.receiverId} receiverNickname={target.receiverNickname} />
|
<SendMessageForm receiverId={target.receiverId} receiverNickname={target.receiverNickname} onSent={close} />
|
||||||
</div>
|
</div>
|
||||||
<div className="px-5 pb-4 flex items-center justify-end">
|
<div className="px-5 pb-4 flex items-center justify-end">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -276,6 +276,11 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
|
|||||||
})()}
|
})()}
|
||||||
{activeTab === "messages-received" && (async () => {
|
{activeTab === "messages-received" && (async () => {
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
|
// 받은 쪽지함 진입 시 미읽은 메시지를 읽음 처리
|
||||||
|
await prisma.message.updateMany({
|
||||||
|
where: { receiverId: currentUser.userId, readAt: null },
|
||||||
|
data: { readAt: new Date() },
|
||||||
|
});
|
||||||
const messages = await prisma.message.findMany({
|
const messages = await prisma.message.findMany({
|
||||||
where: { receiverId: currentUser.userId },
|
where: { receiverId: currentUser.userId },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { GradeIcon, getGradeName } from "@/app/components/GradeIcon";
|
|||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { InlineLoginForm } from "@/app/components/InlineLoginForm";
|
import { InlineLoginForm } from "@/app/components/InlineLoginForm";
|
||||||
|
import UnreadMessagesModal from "@/app/components/UnreadMessagesModal";
|
||||||
|
|
||||||
export default async function Home({ searchParams }: { searchParams: Promise<{ sort?: "recent" | "popular" } | undefined> }) {
|
export default async function Home({ searchParams }: { searchParams: Promise<{ sort?: "recent" | "popular" } | undefined> }) {
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
@@ -56,9 +57,13 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
|||||||
// 내가 쓴 게시글/댓글 수
|
// 내가 쓴 게시글/댓글 수
|
||||||
let myPostsCount = 0;
|
let myPostsCount = 0;
|
||||||
let myCommentsCount = 0;
|
let myCommentsCount = 0;
|
||||||
|
let unreadMessagesCount = 0;
|
||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
myPostsCount = await prisma.post.count({ where: { authorId: currentUser.userId, status: "published" } });
|
myPostsCount = await prisma.post.count({ where: { authorId: currentUser.userId, status: "published" } });
|
||||||
myCommentsCount = await prisma.comment.count({ where: { authorId: currentUser.userId } });
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
{currentUser && unreadMessagesCount > 0 && (
|
||||||
|
<UnreadMessagesModal count={unreadMessagesCount} />
|
||||||
|
)}
|
||||||
{/* 히어로 섹션: 상단 대형 비주얼 영역 (설정 온오프) */}
|
{/* 히어로 섹션: 상단 대형 비주얼 영역 (설정 온오프) */}
|
||||||
{showBanner && (
|
{showBanner && (
|
||||||
<section>
|
<section>
|
||||||
@@ -264,12 +272,13 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</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 w-full pl-[88px]">
|
||||||
<span className="flex items-center gap-[8px]">
|
<span className="flex items-center gap-[8px]">
|
||||||
<SearchIcon width={16} height={16} />
|
<SearchIcon width={16} height={16} />
|
||||||
<span>포인트 히스토리</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>
|
</span>
|
||||||
</Link>
|
</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]">
|
<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]">
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { headers } from "next/headers";
|
|||||||
import { HeroBanner } from "@/app/components/HeroBanner";
|
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||||
import { RelatedPosts } from "./RelatedPosts";
|
import { RelatedPosts } from "./RelatedPosts";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
|
import Link from "next/link";
|
||||||
import { CommentSection } from "@/app/components/CommentSection";
|
import { CommentSection } from "@/app/components/CommentSection";
|
||||||
import { UserNameMenu } from "@/app/components/UserNameMenu";
|
import { UserNameMenu } from "@/app/components/UserNameMenu";
|
||||||
import { ViewTracker } from "./ViewTracker";
|
import { ViewTracker } from "./ViewTracker";
|
||||||
|
import ViewsIcon from "@/app/svgs/ViewsIcon";
|
||||||
|
import LikeIcon from "@/app/svgs/LikeIcon";
|
||||||
|
|
||||||
// 서버 전용 페이지: params가 Promise일 수 있어 안전 언랩 후 절대 URL로 fetch합니다.
|
// 서버 전용 페이지: params가 Promise일 수 있어 안전 언랩 후 절대 URL로 fetch합니다.
|
||||||
export default async function PostDetail({ params }: { params: any }) {
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<ViewTracker postId={id} />
|
<ViewTracker postId={id} />
|
||||||
@@ -67,6 +72,16 @@ export default async function PostDetail({ params }: { params: any }) {
|
|||||||
</section>
|
</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">
|
<section className="rounded-xl overflow-hidden bg-white">
|
||||||
<header className="px-4 py-3 border-b border-neutral-200">
|
<header className="px-4 py-3 border-b border-neutral-200">
|
||||||
@@ -82,6 +97,20 @@ export default async function PostDetail({ params }: { params: any }) {
|
|||||||
</span>
|
</span>
|
||||||
{createdAt && <span aria-hidden>•</span>}
|
{createdAt && <span aria-hidden>•</span>}
|
||||||
{createdAt && <span>{createdAt.toLocaleString()}</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="p-4 md:p-6">
|
<div className="p-4 md:p-6">
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default function NewPostPage() {
|
|||||||
const initialBoardId = sp.get("boardId") ?? "";
|
const initialBoardId = sp.get("boardId") ?? "";
|
||||||
const boardSlug = sp.get("boardSlug") ?? undefined;
|
const boardSlug = sp.get("boardSlug") ?? undefined;
|
||||||
const [form, setForm] = useState({ boardId: initialBoardId, title: "", content: "" });
|
const [form, setForm] = useState({ boardId: initialBoardId, title: "", content: "" });
|
||||||
|
const [images, setImages] = useState<string[]>([]);
|
||||||
const [isSecret, setIsSecret] = useState(false);
|
const [isSecret, setIsSecret] = useState(false);
|
||||||
const [loading, setLoading] = 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()));
|
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>
|
<span aria-hidden>🙂</span>
|
||||||
<UploadButton
|
<UploadButton
|
||||||
multiple
|
multiple
|
||||||
onUploaded={(url) => {
|
onUploaded={(url) => setImages((prev) => [url, ...prev])}
|
||||||
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}` }));
|
|
||||||
}}
|
|
||||||
{...(boardSlug ? require("@/lib/photoPresets").getPhotoPresetBySlug(boardSlug) : {})}
|
{...(boardSlug ? require("@/lib/photoPresets").getPhotoPresetBySlug(boardSlug) : {})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div className="mt-4">
|
||||||
<button
|
<button
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
import { UserNameMenu } from "@/app/components/UserNameMenu";
|
||||||
|
|
||||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
@@ -19,7 +20,10 @@ export default function RankingPage({ searchParams }: { searchParams?: { period?
|
|||||||
<ol>
|
<ol>
|
||||||
{(data?.items ?? []).map((i) => (
|
{(data?.items ?? []).map((i) => (
|
||||||
<li key={i.userId}>
|
<li key={i.userId}>
|
||||||
<strong>{i.nickname}</strong> — {i.points}점
|
<strong>
|
||||||
|
<UserNameMenu userId={i.userId} nickname={i.nickname} underlineOnHover={false} />
|
||||||
|
</strong>{" "}
|
||||||
|
— {i.points}점
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { PostList } from "@/app/components/PostList";
|
import { PostList } from "@/app/components/PostList";
|
||||||
import { headers } from "next/headers";
|
|
||||||
import { HeroBanner } from "@/app/components/HeroBanner";
|
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||||
import { UserAvatar } from "@/app/components/UserAvatar";
|
import { UserAvatar } from "@/app/components/UserAvatar";
|
||||||
import { GradeIcon, getGradeName } from "@/app/components/GradeIcon";
|
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 }> }) {
|
export default async function UserPublicProfile({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const p = await params;
|
const p = await params;
|
||||||
const userId = p.id;
|
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({
|
const user = await prisma.user.findUnique({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
select: {
|
select: {
|
||||||
@@ -52,6 +64,9 @@ export default async function UserPublicProfile({ params }: { params: Promise<{
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="text-xl font-bold text-neutral-900 truncate">{user.nickname}</h1>
|
<h1 className="text-xl font-bold text-neutral-900 truncate">{user.nickname}</h1>
|
||||||
<GradeIcon grade={user.grade} width={20} height={20} />
|
<GradeIcon grade={user.grade} width={20} height={20} />
|
||||||
|
{currentUserId && currentUserId !== user.userId && (
|
||||||
|
<SendMessageButton receiverId={user.userId} receiverNickname={user.nickname} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-neutral-600 mt-1 flex items-center gap-3">
|
<div className="text-sm text-neutral-600 mt-1 flex items-center gap-3">
|
||||||
<span>Lv. {user.level}</span>
|
<span>Lv. {user.level}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user