@@ -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);
|
||||
|
||||
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 prisma from "@/lib/prisma";
|
||||
import { getUserIdFromRequest } from "@/lib/auth";
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export const dynamic = "force-dynamic";
|
||||
import { NextResponse } from "next/server";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { getUserIdFromRequest } from "@/lib/auth";
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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="코드"></></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]);
|
||||
|
||||
@@ -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>
|
||||
|
||||
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 { 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 {
|
||||
|
||||
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>
|
||||
</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
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user