123
@@ -168,7 +168,12 @@ async function upsertRoles() {
|
|||||||
async function upsertAdmin() {
|
async function upsertAdmin() {
|
||||||
const admin = await prisma.user.upsert({
|
const admin = await prisma.user.upsert({
|
||||||
where: { nickname: "admin" },
|
where: { nickname: "admin" },
|
||||||
update: { passwordHash: hashPassword("1234") },
|
update: {
|
||||||
|
passwordHash: hashPassword("1234"),
|
||||||
|
grade: 7,
|
||||||
|
points: 1650000,
|
||||||
|
level: 200,
|
||||||
|
},
|
||||||
create: {
|
create: {
|
||||||
nickname: "admin",
|
nickname: "admin",
|
||||||
name: "Administrator",
|
name: "Administrator",
|
||||||
@@ -177,6 +182,9 @@ async function upsertAdmin() {
|
|||||||
passwordHash: hashPassword("1234"),
|
passwordHash: hashPassword("1234"),
|
||||||
agreementTermsAt: new Date(),
|
agreementTermsAt: new Date(),
|
||||||
authLevel: "ADMIN",
|
authLevel: "ADMIN",
|
||||||
|
grade: 7,
|
||||||
|
points: 1650000,
|
||||||
|
level: 200,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
9
public/svgs/01_bronze.svg
Normal file
|
After Width: | Height: | Size: 99 KiB |
9
public/svgs/02_silver.svg.svg
Normal file
|
After Width: | Height: | Size: 69 KiB |
9
public/svgs/03_gold.svg
Normal file
|
After Width: | Height: | Size: 70 KiB |
9
public/svgs/04_platinum.svg
Normal file
|
After Width: | Height: | Size: 84 KiB |
9
public/svgs/05_diamond.svg
Normal file
|
After Width: | Height: | Size: 92 KiB |
9
public/svgs/06_master.svg
Normal file
|
After Width: | Height: | Size: 96 KiB |
9
public/svgs/07_grandmaster.svg
Normal file
|
After Width: | Height: | Size: 95 KiB |
9
public/svgs/08_god.svg
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
public/uploads/1762052938422-has0h33j4x6.webp
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/uploads/1762053004600-0ewlk5af03i.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
@@ -48,6 +48,7 @@ const listQuerySchema = z.object({
|
|||||||
sort: z.enum(["recent", "popular"]).default("recent").optional(),
|
sort: z.enum(["recent", "popular"]).default("recent").optional(),
|
||||||
tag: z.string().optional(), // Tag.slug
|
tag: z.string().optional(), // Tag.slug
|
||||||
author: z.string().optional(), // User.nickname contains
|
author: z.string().optional(), // User.nickname contains
|
||||||
|
authorId: z.string().optional(), // User.userId exact match
|
||||||
start: z.coerce.date().optional(), // createdAt >= start
|
start: z.coerce.date().optional(), // createdAt >= start
|
||||||
end: z.coerce.date().optional(), // createdAt <= end
|
end: z.coerce.date().optional(), // createdAt <= end
|
||||||
});
|
});
|
||||||
@@ -58,7 +59,7 @@ export async function GET(req: Request) {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||||
}
|
}
|
||||||
const { page, pageSize, boardId, q, sort = "recent", tag, author, start, end } = parsed.data;
|
const { page, pageSize, boardId, q, sort = "recent", tag, author, authorId, start, end } = parsed.data;
|
||||||
const where = {
|
const where = {
|
||||||
NOT: { status: "deleted" as const },
|
NOT: { status: "deleted" as const },
|
||||||
...(boardId ? { boardId } : {}),
|
...(boardId ? { boardId } : {}),
|
||||||
@@ -75,7 +76,11 @@ export async function GET(req: Request) {
|
|||||||
postTags: { some: { tag: { slug: tag } } },
|
postTags: { some: { tag: { slug: tag } } },
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(author
|
...(authorId
|
||||||
|
? {
|
||||||
|
authorId,
|
||||||
|
}
|
||||||
|
: author
|
||||||
? {
|
? {
|
||||||
author: { nickname: { contains: author } },
|
author: { nickname: { contains: author } },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { HeroBanner } from "@/app/components/HeroBanner";
|
|||||||
import { BoardToolbar } from "@/app/components/BoardToolbar";
|
import { BoardToolbar } from "@/app/components/BoardToolbar";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
|
import { UserAvatar } from "@/app/components/UserAvatar";
|
||||||
|
import { RankIcon1st } from "@/app/components/RankIcon1st";
|
||||||
|
import { RankIcon2nd } from "@/app/components/RankIcon2nd";
|
||||||
|
import { RankIcon3rd } from "@/app/components/RankIcon3rd";
|
||||||
|
import { GradeIcon } from "@/app/components/GradeIcon";
|
||||||
|
|
||||||
// Next 15: params/searchParams가 Promise가 될 수 있어 안전 언랩 처리합니다.
|
// Next 15: params/searchParams가 Promise가 될 수 있어 안전 언랩 처리합니다.
|
||||||
export default async function BoardDetail({ params, searchParams }: { params: any; searchParams: any }) {
|
export default async function BoardDetail({ params, searchParams }: { params: any; searchParams: any }) {
|
||||||
@@ -29,15 +34,15 @@ export default async function BoardDetail({ params, searchParams }: { params: an
|
|||||||
});
|
});
|
||||||
const isSpecialRanking = boardView?.listViewType?.key === "list_special_rank";
|
const isSpecialRanking = boardView?.listViewType?.key === "list_special_rank";
|
||||||
|
|
||||||
let rankingItems: { userId: string; nickname: string; points: number }[] = [];
|
let rankingItems: { userId: string; nickname: string; points: number; profileImage: string | null; grade: number }[] = [];
|
||||||
if (isSpecialRanking) {
|
if (isSpecialRanking) {
|
||||||
const topUsers = await prisma.user.findMany({
|
const topUsers = await prisma.user.findMany({
|
||||||
select: { userId: true, nickname: true, points: true },
|
select: { userId: true, nickname: true, points: true, profileImage: true, grade: true },
|
||||||
where: { status: "active" },
|
where: { status: "active" },
|
||||||
orderBy: { points: "desc" },
|
orderBy: { points: "desc" },
|
||||||
take: 100,
|
take: 100,
|
||||||
});
|
});
|
||||||
rankingItems = topUsers.map((u) => ({ userId: u.userId, nickname: u.nickname, points: u.points }));
|
rankingItems = topUsers.map((u) => ({ userId: u.userId, nickname: u.nickname, points: u.points, profileImage: u.profileImage, grade: u.grade }));
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -54,24 +59,46 @@ export default async function BoardDetail({ params, searchParams }: { params: an
|
|||||||
{!isSpecialRanking && <BoardToolbar boardId={board?.slug} />}
|
{!isSpecialRanking && <BoardToolbar boardId={board?.slug} />}
|
||||||
<div className="p-0">
|
<div className="p-0">
|
||||||
{isSpecialRanking ? (
|
{isSpecialRanking ? (
|
||||||
<div className="rounded-xl border border-neutral-200 overflow-hidden">
|
<div className="w-full">
|
||||||
<div className="px-4 py-3 border-b border-neutral-200 flex items-center justify-between bg-[#f6f4f4]">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-[32px]">
|
||||||
<h2 className="text-sm text-neutral-700">포인트 랭킹</h2>
|
{rankingItems.map((i, idx) => {
|
||||||
</div>
|
const rank = idx + 1;
|
||||||
<ol className="divide-y divide-neutral-200">
|
return (
|
||||||
{rankingItems.map((i, idx) => (
|
<div key={i.userId} className="border-t border-[#d5d5d5]">
|
||||||
<li key={i.userId} className="px-4 py-3 flex items-center justify-between">
|
<div className="flex gap-[16px] items-center p-[16px]">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-[8px] shrink-0">
|
||||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-neutral-900 text-white text-xs">{idx + 1}</span>
|
{(rank === 1 || rank === 2 || rank === 3) && (
|
||||||
<span className="truncate text-neutral-900 font-medium">{i.nickname || "회원"}</span>
|
<div className="relative w-[20px] h-[20px] shrink-0">
|
||||||
|
{rank === 1 && <RankIcon1st />}
|
||||||
|
{rank === 2 && <RankIcon2nd />}
|
||||||
|
{rank === 3 && <RankIcon3rd />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="bg-white border border-[#d5d5d5] px-[16px] py-[5px] rounded-[12px] text-[14px] text-[#5c5c5c] shrink-0">
|
||||||
|
{rank}위
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[10px] shrink-0 pl-0 pr-[15px] py-0">
|
||||||
|
<UserAvatar src={i.profileImage} alt={i.nickname || "프로필"} width={36} height={36} className="rounded-full" />
|
||||||
|
<span className="text-[16px] text-[#5c5c5c] leading-[16px] tracking-[-0.28px] whitespace-nowrap">
|
||||||
|
{i.nickname || "회원"}
|
||||||
|
</span>
|
||||||
|
<GradeIcon grade={i.grade} width={20} height={20} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[13px] shrink-0 ml-auto">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0">
|
||||||
|
<path d="M8.17377 0.0481988C8.1834 0.0326271 8.19596 0.0201252 8.21037 0.0117499C8.22478 0.00337451 8.24062 -0.000628204 8.25656 7.99922e-05C8.2725 0.000788189 8.28806 0.0061865 8.30194 0.0158187C8.31582 0.0254509 8.3276 0.0390338 8.33629 0.0554196L11.3323 5.55973C11.3575 5.60512 11.4038 5.62472 11.4468 5.60718L15.8683 3.80198C15.9441 3.77104 16.0174 3.85666 15.9963 3.95053L14.2584 16H1.74226L0.00344328 3.95156C-0.00125237 3.93019 -0.0011435 3.90765 0.00375838 3.88635C0.00866025 3.86505 0.0181727 3.84576 0.0312889 3.83054C0.0444051 3.81532 0.0606369 3.80472 0.0782661 3.79988C0.0958953 3.79503 0.114266 3.79612 0.131434 3.80302L4.55889 5.61131C4.59846 5.62678 4.64309 5.61131 4.66835 5.57005L8.17461 0.0481988H8.17377Z" fill="#FFB625"/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-[16px] font-semibold text-[#5c5c5c] leading-[22px]">{i.points.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 text-sm text-neutral-700">{i.points}점</div>
|
);
|
||||||
</li>
|
})}
|
||||||
))}
|
</div>
|
||||||
{rankingItems.length === 0 && (
|
{rankingItems.length === 0 && (
|
||||||
<li className="px-4 py-10 text-center text-neutral-500">랭킹 데이터가 없습니다.</li>
|
<div className="px-4 py-10 text-center text-neutral-500">랭킹 데이터가 없습니다.</div>
|
||||||
)}
|
)}
|
||||||
</ol>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<PostList
|
<PostList
|
||||||
|
|||||||
47
src/app/components/AutoLoginAdmin.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export function AutoLoginAdmin() {
|
||||||
|
useEffect(() => {
|
||||||
|
// 쿠키에 uid가 없으면 어드민으로 자동 로그인
|
||||||
|
const checkCookie = () => {
|
||||||
|
const cookies = document.cookie.split(";");
|
||||||
|
const uidCookie = cookies.find((cookie) => cookie.trim().startsWith("uid="));
|
||||||
|
|
||||||
|
if (!uidCookie) {
|
||||||
|
// 어드민 사용자 정보 가져오기
|
||||||
|
fetch("/api/auth/session")
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (!data.ok || !data.user) {
|
||||||
|
// 어드민으로 로그인 시도
|
||||||
|
fetch("/api/auth/session", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ nickname: "admin", password: "1234" }),
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((loginData) => {
|
||||||
|
if (loginData.ok) {
|
||||||
|
// 페이지 새로고침하여 적용
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// 에러 무시
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// 에러 무시
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkCookie();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import { RankIcon3rd } from "./RankIcon3rd";
|
|||||||
import { UserAvatar } from "./UserAvatar";
|
import { UserAvatar } from "./UserAvatar";
|
||||||
import { ImagePlaceholderIcon } from "./ImagePlaceholderIcon";
|
import { ImagePlaceholderIcon } from "./ImagePlaceholderIcon";
|
||||||
import { PostList } from "./PostList";
|
import { PostList } from "./PostList";
|
||||||
|
import { GradeIcon } from "./GradeIcon";
|
||||||
|
|
||||||
type BoardMeta = {
|
type BoardMeta = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -21,12 +22,14 @@ type UserData = {
|
|||||||
nickname: string | null;
|
nickname: string | null;
|
||||||
points: number;
|
points: number;
|
||||||
profileImage: string | null;
|
profileImage: string | null;
|
||||||
|
grade: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PostData = {
|
type PostData = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
content?: string | null;
|
||||||
attachments?: { url: string }[];
|
attachments?: { url: string }[];
|
||||||
stat?: { recommendCount: number | null };
|
stat?: { recommendCount: number | null };
|
||||||
};
|
};
|
||||||
@@ -61,6 +64,27 @@ export function BoardPanelClient({
|
|||||||
return `${yyyy}.${mm}.${dd}`;
|
return `${yyyy}.${mm}.${dd}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripHtml(html: string | null | undefined): string {
|
||||||
|
if (!html) return "";
|
||||||
|
// HTML 태그 제거
|
||||||
|
return html.replace(/<[^>]*>/g, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractImageFromContent(content: string | null | undefined): string | null {
|
||||||
|
if (!content) return null;
|
||||||
|
// img 태그에서 src 속성 추출
|
||||||
|
const imgMatch = content.match(/<img[^>]+src=["']([^"']+)["'][^>]*>/i);
|
||||||
|
if (imgMatch && imgMatch[1]) {
|
||||||
|
return imgMatch[1];
|
||||||
|
}
|
||||||
|
// figure 안의 img 태그도 확인
|
||||||
|
const figureMatch = content.match(/<figure[^>]*>.*?<img[^>]+src=["']([^"']+)["'][^>]*>/is);
|
||||||
|
if (figureMatch && figureMatch[1]) {
|
||||||
|
return figureMatch[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const isTextMain = board.mainTypeKey === "main_text";
|
const isTextMain = board.mainTypeKey === "main_text";
|
||||||
const isSpecialRank = board.mainTypeKey === "main_special_rank";
|
const isSpecialRank = board.mainTypeKey === "main_special_rank";
|
||||||
const isPreview = board.mainTypeKey === "main_preview";
|
const isPreview = board.mainTypeKey === "main_preview";
|
||||||
@@ -103,6 +127,9 @@ export function BoardPanelClient({
|
|||||||
height={150}
|
height={150}
|
||||||
className="w-full h-full object-cover rounded-none"
|
className="w-full h-full object-cover rounded-none"
|
||||||
/>
|
/>
|
||||||
|
<div className="absolute top-0 right-0 w-[40px] h-[40px] flex items-center justify-center">
|
||||||
|
<GradeIcon grade={user.grade} width={40} height={40} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex items-center gap-[10px] px-[30px] py-[24px] min-w-0">
|
<div className="flex-1 flex items-center gap-[10px] px-[30px] py-[24px] min-w-0">
|
||||||
<div className="flex flex-col gap-[12px] min-w-0 flex-1">
|
<div className="flex flex-col gap-[12px] min-w-0 flex-1">
|
||||||
@@ -165,14 +192,15 @@ export function BoardPanelClient({
|
|||||||
<div className="px-[24px] pt-[8px] pb-[16px]">
|
<div className="px-[24px] pt-[8px] pb-[16px]">
|
||||||
<div className="flex flex-col gap-[16px]">
|
<div className="flex flex-col gap-[16px]">
|
||||||
{selectedBoardData.previewPosts.map((post) => {
|
{selectedBoardData.previewPosts.map((post) => {
|
||||||
const firstImage = post.attachments?.[0]?.url;
|
// attachments에서 이미지를 먼저 찾고, 없으면 content에서 추출
|
||||||
|
const firstImage = post.attachments?.[0]?.url || extractImageFromContent(post.content);
|
||||||
return (
|
return (
|
||||||
<Link key={post.id} href={`/posts/${post.id}`} className="flex h-[150px] items-start rounded-[16px] overflow-hidden bg-white">
|
<Link key={post.id} href={`/posts/${post.id}`} className="flex h-[150px] items-start rounded-[16px] overflow-hidden bg-white">
|
||||||
<div className="h-[150px] w-[214px] relative shrink-0 bg-[#ededed] overflow-hidden">
|
<div className="h-[150px] w-[214px] relative shrink-0 bg-[#ededed] overflow-hidden">
|
||||||
{firstImage ? (
|
{firstImage ? (
|
||||||
<img
|
<img
|
||||||
src={firstImage}
|
src={firstImage}
|
||||||
alt={post.title}
|
alt={stripHtml(post.title)}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -191,7 +219,7 @@ export function BoardPanelClient({
|
|||||||
<div className="absolute inset-0 rounded-full bg-[#f45f00] opacity-0" />
|
<div className="absolute inset-0 rounded-full bg-[#f45f00] opacity-0" />
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-extrabold select-none">n</div>
|
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-extrabold select-none">n</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[16px] leading-[22px] text-[#5c5c5c] truncate">{post.title}</span>
|
<span className="text-[16px] leading-[22px] text-[#5c5c5c] truncate">{stripHtml(post.title)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[16px] relative">
|
<div className="h-[16px] relative">
|
||||||
<span className="absolute top-1/2 translate-y-[-50%] text-[12px] leading-[12px] tracking-[-0.24px] text-[#8c8c8c]">
|
<span className="absolute top-1/2 translate-y-[-50%] text-[12px] leading-[12px] tracking-[-0.24px] text-[#8c8c8c]">
|
||||||
@@ -244,14 +272,14 @@ export function BoardPanelClient({
|
|||||||
{selectedBoardData.textPosts.map((p) => (
|
{selectedBoardData.textPosts.map((p) => (
|
||||||
<li key={p.id} className="border-b border-[#ededed] h-[56px] pl-0 pr-[24px] pt-[16px] pb-[16px]">
|
<li key={p.id} className="border-b border-[#ededed] h-[56px] pl-0 pr-[24px] pt-[16px] pb-[16px]">
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
<div className="flex items-center gap-[4px] h-[24px] overflow-hidden">
|
<Link href={`/posts/${p.id}`} className="flex items-center gap-[4px] h-[24px] overflow-hidden flex-1 min-w-0">
|
||||||
<div className="relative w-[16px] h-[16px] shrink-0">
|
<div className="relative w-[16px] h-[16px] shrink-0">
|
||||||
<div className="absolute inset-0 rounded-full bg-[#f45f00] opacity-0" />
|
<div className="absolute inset-0 rounded-full bg-[#f45f00] opacity-0" />
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-extrabold select-none">n</div>
|
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-white font-extrabold select-none">n</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[16px] leading-[22px] text-[#5c5c5c] truncate max-w-[calc(100vw-280px)]">{p.title}</span>
|
<span className="text-[16px] leading-[22px] text-[#5c5c5c] truncate max-w-[calc(100vw-280px)]">{stripHtml(p.title)}</span>
|
||||||
<span className="text-[16px] text-[#f45f00] font-bold">+{p.stat?.recommendCount ?? 0}</span>
|
<span className="text-[16px] text-[#f45f00] font-bold">+{p.stat?.recommendCount ?? 0}</span>
|
||||||
</div>
|
</Link>
|
||||||
<span className="text-[12px] leading-[12px] tracking-[-0.24px] text-[#8c8c8c]">{formatDateYmd(p.createdAt)}</span>
|
<span className="text-[12px] leading-[12px] tracking-[-0.24px] text-[#8c8c8c]">{formatDateYmd(p.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
45
src/app/components/GradeIcon.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
export function GradeIcon({ grade, width = 32, height = 32, className }: { grade: number; width?: number; height?: number; className?: string }) {
|
||||||
|
// grade: 0~7 (bronze, silver, gold, platinum, diamond, master, grandmaster, god)
|
||||||
|
const gradeImages = [
|
||||||
|
"/svgs/01_bronze.svg",
|
||||||
|
"/svgs/02_silver.svg.svg",
|
||||||
|
"/svgs/03_gold.svg",
|
||||||
|
"/svgs/04_platinum.svg",
|
||||||
|
"/svgs/05_diamond.svg",
|
||||||
|
"/svgs/06_master.svg",
|
||||||
|
"/svgs/07_grandmaster.svg",
|
||||||
|
"/svgs/08_god.svg",
|
||||||
|
];
|
||||||
|
|
||||||
|
const gradeIndex = Math.min(7, Math.max(0, grade));
|
||||||
|
const imageSrc = gradeImages[gradeIndex];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={imageSrc}
|
||||||
|
alt={`등급 ${gradeIndex + 1}`}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
className={className}
|
||||||
|
style={{ width, height }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 등급 숫자를 등급 이름으로 변환하는 함수
|
||||||
|
export function getGradeName(grade: number): string {
|
||||||
|
const gradeNames = [
|
||||||
|
"Bronze",
|
||||||
|
"Silver",
|
||||||
|
"Gold",
|
||||||
|
"Platinum",
|
||||||
|
"Diamond",
|
||||||
|
"Master",
|
||||||
|
"Grandmaster",
|
||||||
|
"God",
|
||||||
|
];
|
||||||
|
|
||||||
|
const gradeIndex = Math.min(7, Math.max(0, grade));
|
||||||
|
return gradeNames[gradeIndex];
|
||||||
|
}
|
||||||
|
|
||||||
@@ -29,20 +29,37 @@ type Resp = {
|
|||||||
|
|
||||||
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
||||||
|
|
||||||
export function PostList({ boardId, sort = "recent", q, tag, author, start, end, variant = "default", newPostHref }: { boardId?: string; sort?: "recent" | "popular"; q?: string; tag?: string; author?: string; start?: string; end?: string; variant?: "default" | "board"; newPostHref?: string }) {
|
function stripHtml(html: string | null | undefined): string {
|
||||||
const pageSize = 10;
|
if (!html) return "";
|
||||||
|
// HTML 태그 제거
|
||||||
|
return html.replace(/<[^>]*>/g, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PostList({ boardId, sort = "recent", q, tag, author, authorId, start, end, variant = "default", newPostHref }: { boardId?: string; sort?: "recent" | "popular"; q?: string; tag?: string; author?: string; authorId?: string; start?: string; end?: string; variant?: "default" | "board"; newPostHref?: string }) {
|
||||||
const sp = useSearchParams();
|
const sp = useSearchParams();
|
||||||
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);
|
||||||
|
|
||||||
|
// board 변형에서는 URL에서 pageSize를 읽고, 기본값은 20
|
||||||
|
const defaultPageSize = variant === "board" ? 20 : 10;
|
||||||
|
const pageSizeParam = sp.get("pageSize");
|
||||||
|
const pageSize = pageSizeParam ? Math.min(50, Math.max(10, parseInt(pageSizeParam, 10))) : defaultPageSize;
|
||||||
|
|
||||||
|
// board 변형: 번호 페이지네이션
|
||||||
|
const initialPage = useMemo(() => Math.max(1, parseInt(sp.get("page") || "1", 10)), [sp]);
|
||||||
|
const [page, setPage] = useState(initialPage);
|
||||||
|
const [currentPageSize, setCurrentPageSize] = useState(pageSize);
|
||||||
|
|
||||||
const getKey = (index: number, prev: Resp | null) => {
|
const getKey = (index: number, prev: Resp | null) => {
|
||||||
if (prev && prev.items.length === 0) return null;
|
if (prev && prev.items.length === 0) return null;
|
||||||
const page = index + 1;
|
const page = index + 1;
|
||||||
|
// 무한 스크롤은 board variant가 아닐 때 사용되므로 pageSize 사용
|
||||||
const sp = new URLSearchParams({ page: String(page), pageSize: String(pageSize), sort });
|
const sp = new URLSearchParams({ page: String(page), pageSize: String(pageSize), sort });
|
||||||
if (boardId) sp.set("boardId", boardId);
|
if (boardId) sp.set("boardId", boardId);
|
||||||
if (q) sp.set("q", q);
|
if (q) sp.set("q", q);
|
||||||
if (tag) sp.set("tag", tag);
|
if (tag) sp.set("tag", tag);
|
||||||
if (author) sp.set("author", author);
|
if (author) sp.set("author", author);
|
||||||
|
if (authorId) sp.set("authorId", authorId);
|
||||||
if (start) sp.set("start", start);
|
if (start) sp.set("start", start);
|
||||||
if (end) sp.set("end", end);
|
if (end) sp.set("end", end);
|
||||||
return `/api/posts?${sp.toString()}`;
|
return `/api/posts?${sp.toString()}`;
|
||||||
@@ -50,29 +67,39 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
|
|||||||
// default(무한 스크롤 형태)
|
// default(무한 스크롤 형태)
|
||||||
const { data, size, setSize, isLoading } = useSWRInfinite<Resp>(getKey, fetcher, { revalidateFirstPage: false });
|
const { data, size, setSize, isLoading } = useSWRInfinite<Resp>(getKey, fetcher, { revalidateFirstPage: false });
|
||||||
const itemsInfinite = data?.flatMap((d) => d.items) ?? [];
|
const itemsInfinite = data?.flatMap((d) => d.items) ?? [];
|
||||||
const canLoadMore = (data?.at(-1)?.items.length ?? 0) === pageSize;
|
const effectivePageSize = variant === "board" ? currentPageSize : pageSize;
|
||||||
|
const canLoadMore = (data?.at(-1)?.items.length ?? 0) === effectivePageSize;
|
||||||
const isEmptyInfinite = !isLoading && itemsInfinite.length === 0;
|
const isEmptyInfinite = !isLoading && itemsInfinite.length === 0;
|
||||||
|
|
||||||
// board 변형: 번호 페이지네이션
|
|
||||||
const initialPage = useMemo(() => Math.max(1, parseInt(sp.get("page") || "1", 10)), [sp]);
|
|
||||||
const [page, setPage] = useState(initialPage);
|
|
||||||
useEffect(() => { setPage(initialPage); }, [initialPage]);
|
useEffect(() => { setPage(initialPage); }, [initialPage]);
|
||||||
|
useEffect(() => { setCurrentPageSize(pageSize); }, [pageSize]);
|
||||||
|
|
||||||
const singleKey = useMemo(() => {
|
const singleKey = useMemo(() => {
|
||||||
if (variant !== "board") return null;
|
if (variant !== "board") return null;
|
||||||
const usp = new URLSearchParams({ page: String(page), pageSize: String(pageSize), sort });
|
const usp = new URLSearchParams({ page: String(page), pageSize: String(currentPageSize), sort });
|
||||||
if (boardId) usp.set("boardId", boardId);
|
if (boardId) usp.set("boardId", boardId);
|
||||||
if (q) usp.set("q", q);
|
if (q) usp.set("q", q);
|
||||||
if (tag) usp.set("tag", tag);
|
if (tag) usp.set("tag", tag);
|
||||||
if (author) usp.set("author", author);
|
if (author) usp.set("author", author);
|
||||||
|
if (authorId) usp.set("authorId", authorId);
|
||||||
if (start) usp.set("start", start);
|
if (start) usp.set("start", start);
|
||||||
if (end) usp.set("end", end);
|
if (end) usp.set("end", end);
|
||||||
return `/api/posts?${usp.toString()}`;
|
return `/api/posts?${usp.toString()}`;
|
||||||
}, [variant, page, pageSize, sort, boardId, q, tag, author, start, end]);
|
}, [variant, page, currentPageSize, sort, boardId, q, tag, author, authorId, start, end]);
|
||||||
const { data: singlePageResp, isLoading: isLoadingSingle } = useSWR<Resp>(singleKey, fetcher);
|
const { data: singlePageResp, isLoading: isLoadingSingle } = useSWR<Resp>(singleKey, fetcher);
|
||||||
const itemsSingle = singlePageResp?.items ?? [];
|
|
||||||
const totalSingle = singlePageResp?.total ?? 0;
|
// 이전 데이터를 유지하여 깜빡임 방지
|
||||||
const totalPages = Math.max(1, Math.ceil(totalSingle / pageSize));
|
const [stableData, setStableData] = useState<Resp | null>(null);
|
||||||
const isEmptySingle = !isLoadingSingle && itemsSingle.length === 0;
|
useEffect(() => {
|
||||||
|
if (singlePageResp) {
|
||||||
|
setStableData(singlePageResp);
|
||||||
|
}
|
||||||
|
}, [singlePageResp]);
|
||||||
|
|
||||||
|
const itemsSingle = stableData?.items ?? [];
|
||||||
|
const totalSingle = stableData?.total ?? singlePageResp?.total ?? 0;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalSingle / currentPageSize));
|
||||||
|
const isEmptySingle = !isLoadingSingle && itemsSingle.length === 0 && !stableData;
|
||||||
|
|
||||||
const items = variant === "board" ? itemsSingle : itemsInfinite;
|
const items = variant === "board" ? itemsSingle : itemsInfinite;
|
||||||
const isEmpty = variant === "board" ? isEmptySingle : isEmptyInfinite;
|
const isEmpty = variant === "board" ? isEmptySingle : isEmptyInfinite;
|
||||||
@@ -149,7 +176,7 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<Link href={`/posts/${p.id}`} className="block truncate text-[15px] md:text-base text-neutral-900">
|
<Link href={`/posts/${p.id}`} className="block truncate text-[15px] md:text-base text-neutral-900">
|
||||||
{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>}
|
||||||
{p.title}
|
{stripHtml(p.title)}
|
||||||
</Link>
|
</Link>
|
||||||
{!!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">
|
||||||
@@ -254,6 +281,30 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
|
|||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-neutral-600">표시 개수</span>
|
||||||
|
<select
|
||||||
|
value={currentPageSize}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newSize = parseInt(e.target.value, 10);
|
||||||
|
setCurrentPageSize(newSize);
|
||||||
|
setPage(1);
|
||||||
|
const nextSp = new URLSearchParams(Array.from(sp.entries()));
|
||||||
|
nextSp.set("pageSize", String(newSize));
|
||||||
|
nextSp.set("page", "1");
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.history.replaceState(null, "", `?${nextSp.toString()}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-9 px-2 rounded-md border border-neutral-300 bg-white text-sm"
|
||||||
|
>
|
||||||
|
<option value="10">10개</option>
|
||||||
|
<option value="20">20개</option>
|
||||||
|
<option value="30">30개</option>
|
||||||
|
<option value="40">40개</option>
|
||||||
|
<option value="50">50개</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
{newPostHref && (
|
{newPostHref && (
|
||||||
<Link href={newPostHref} className="shrink-0">
|
<Link href={newPostHref} className="shrink-0">
|
||||||
<button className="h-9 px-4 rounded-[10px] bg-[#f94b37] text-white text-sm border border-[#d73b29] hover:opacity-95">글쓰기</button>
|
<button className="h-9 px-4 rounded-[10px] bg-[#f94b37] text-white text-sm border border-[#d73b29] hover:opacity-95">글쓰기</button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export function RankIcon1st() {
|
export function RankIcon1st({ width = 20, height = 21 }: { width?: number; height?: number }) {
|
||||||
return (
|
return (
|
||||||
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width={width} height={height} viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<g filter="url(#filter0_d_9465_19783)">
|
<g filter="url(#filter0_d_9465_19783)">
|
||||||
<path d="M12.0854 18.801C10.8419 19.733 9.1581 19.733 7.91462 18.801L1.96272 14.3398C0.719241 13.4077 0.198919 11.7556 0.673887 10.2475L2.94731 3.02913C3.42228 1.52105 4.7845 0.5 6.32153 0.5H13.6785C15.2155 0.5 16.5777 1.52105 17.0527 3.02913L19.3261 10.2475C19.8011 11.7556 19.2808 13.4077 18.0373 14.3398L12.0854 18.801Z" fill="url(#paint0_linear_9465_19783)"/>
|
<path d="M12.0854 18.801C10.8419 19.733 9.1581 19.733 7.91462 18.801L1.96272 14.3398C0.719241 13.4077 0.198919 11.7556 0.673887 10.2475L2.94731 3.02913C3.42228 1.52105 4.7845 0.5 6.32153 0.5H13.6785C15.2155 0.5 16.5777 1.52105 17.0527 3.02913L19.3261 10.2475C19.8011 11.7556 19.2808 13.4077 18.0373 14.3398L12.0854 18.801Z" fill="url(#paint0_linear_9465_19783)"/>
|
||||||
<path d="M6.32129 1.5H13.6787C14.7619 1.50011 15.7495 2.22129 16.0986 3.3291L18.3721 10.5479C18.7226 11.6607 18.3334 12.8685 17.4375 13.54L11.4854 18.001C10.5974 18.6663 9.40256 18.6663 8.51465 18.001L2.5625 13.54C1.6666 12.8685 1.27744 11.6607 1.62793 10.5479L3.90137 3.3291C4.25048 2.22129 5.23809 1.50011 6.32129 1.5Z" stroke="white" strokeOpacity="0.4" strokeWidth="2"/>
|
<path d="M6.32129 1.5H13.6787C14.7619 1.50011 15.7495 2.22129 16.0986 3.3291L18.3721 10.5479C18.7226 11.6607 18.3334 12.8685 17.4375 13.54L11.4854 18.001C10.5974 18.6663 9.40256 18.6663 8.51465 18.001L2.5625 13.54C1.6666 12.8685 1.27744 11.6607 1.62793 10.5479L3.90137 3.3291C4.25048 2.22129 5.23809 1.50011 6.32129 1.5Z" stroke="white" strokeOpacity="0.4" strokeWidth="2"/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export function RankIcon2nd() {
|
export function RankIcon2nd({ width = 20, height = 21 }: { width?: number; height?: number }) {
|
||||||
return (
|
return (
|
||||||
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width={width} height={height} viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<g filter="url(#filter0_d_9465_19777)">
|
<g filter="url(#filter0_d_9465_19777)">
|
||||||
<path d="M12.569 18.9359C11.1502 20.3547 8.84982 20.3547 7.43099 18.9359L1.06412 12.569C-0.354706 11.1502 -0.354706 8.84982 1.06412 7.43099L7.43099 1.06412C8.84982 -0.354706 11.1502 -0.354706 12.569 1.06412L18.9359 7.43099C20.3547 8.84982 20.3547 11.1502 18.9359 12.569L12.569 18.9359Z" fill="url(#paint0_linear_9465_19777)"/>
|
<path d="M12.569 18.9359C11.1502 20.3547 8.84982 20.3547 7.43099 18.9359L1.06412 12.569C-0.354706 11.1502 -0.354706 8.84982 1.06412 7.43099L7.43099 1.06412C8.84982 -0.354706 11.1502 -0.354706 12.569 1.06412L18.9359 7.43099C20.3547 8.84982 20.3547 11.1502 18.9359 12.569L12.569 18.9359Z" fill="url(#paint0_linear_9465_19777)"/>
|
||||||
<path d="M8.1377 1.77148C9.16599 0.743186 10.834 0.743186 11.8623 1.77148L18.2285 8.1377C19.2568 9.16599 19.2568 10.834 18.2285 11.8623L11.8623 18.2285C10.834 19.2568 9.16599 19.2568 8.1377 18.2285L1.77148 11.8623C0.743186 10.834 0.743186 9.16599 1.77148 8.1377L8.1377 1.77148Z" stroke="white" strokeOpacity="0.4" strokeWidth="2"/>
|
<path d="M8.1377 1.77148C9.16599 0.743186 10.834 0.743186 11.8623 1.77148L18.2285 8.1377C19.2568 9.16599 19.2568 10.834 18.2285 11.8623L11.8623 18.2285C10.834 19.2568 9.16599 19.2568 8.1377 18.2285L1.77148 11.8623C0.743186 10.834 0.743186 9.16599 1.77148 8.1377L8.1377 1.77148Z" stroke="white" strokeOpacity="0.4" strokeWidth="2"/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export function RankIcon3rd() {
|
export function RankIcon3rd({ width = 20, height = 20 }: { width?: number; height?: number }) {
|
||||||
return (
|
return (
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width={width} height={height} viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<g filter="url(#filter0_d_9465_19771)">
|
<g filter="url(#filter0_d_9465_19771)">
|
||||||
<mask id="path-1-inside-1_9465_19771" fill="white">
|
<mask id="path-1-inside-1_9465_19771" fill="white">
|
||||||
<path d="M1.5 2.9C1.5 1.85066 2.35066 1 3.4 1L16.6 1C17.6493 1 18.5 1.85066 18.5 2.9V10.9C18.5 15.3735 14.5333 19 10 19C5.46667 19 1.5 15.3735 1.5 10.9V2.9Z"/>
|
<path d="M1.5 2.9C1.5 1.85066 2.35066 1 3.4 1L16.6 1C17.6493 1 18.5 1.85066 18.5 2.9V10.9C18.5 15.3735 14.5333 19 10 19C5.46667 19 1.5 15.3735 1.5 10.9V2.9Z"/>
|
||||||
|
|||||||
@@ -71,3 +71,33 @@ html { scrollbar-gutter: stable both-edges; }
|
|||||||
margin-bottom: -6px; /* 스크롤바 높이만큼 아래 마진 조정 */
|
margin-bottom: -6px; /* 스크롤바 높이만큼 아래 마진 조정 */
|
||||||
padding-bottom: 6px; /* 스크롤바 공간 확보 */
|
padding-bottom: 6px; /* 스크롤바 공간 확보 */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 게시글 내용 스타일 */
|
||||||
|
.prose figure {
|
||||||
|
margin: 1rem 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose figure img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose figure .resize-handle,
|
||||||
|
.prose figure .delete-image-btn {
|
||||||
|
display: none; /* 읽기 전용에서는 리사이즈 핸들과 삭제 버튼 숨김 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose div[style*="text-align"] {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose b {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose br {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import QueryProvider from "@/app/QueryProvider";
|
|||||||
import { AppHeader } from "@/app/components/AppHeader";
|
import { AppHeader } from "@/app/components/AppHeader";
|
||||||
import { AppFooter } from "@/app/components/AppFooter";
|
import { AppFooter } from "@/app/components/AppFooter";
|
||||||
import { ToastProvider } from "@/app/components/ui/ToastProvider";
|
import { ToastProvider } from "@/app/components/ui/ToastProvider";
|
||||||
|
import { AutoLoginAdmin } from "@/app/components/AutoLoginAdmin";
|
||||||
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -21,6 +22,7 @@ export default function RootLayout({
|
|||||||
<body className="min-h-screen bg-background text-foreground antialiased">
|
<body className="min-h-screen bg-background text-foreground antialiased">
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
|
<AutoLoginAdmin />
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
<div className="sticky top-0 z-50 bg-white/80 backdrop-blur">
|
<div className="sticky top-0 z-50 bg-white/80 backdrop-blur">
|
||||||
<div className="mx-auto w-full">
|
<div className="mx-auto w-full">
|
||||||
|
|||||||
226
src/app/my-page/page.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { UserAvatar } from "@/app/components/UserAvatar";
|
||||||
|
import { GradeIcon, getGradeName } from "@/app/components/GradeIcon";
|
||||||
|
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||||
|
import { PostList } from "@/app/components/PostList";
|
||||||
|
import ProfileLabelIcon from "@/app/svgs/profilelableicon";
|
||||||
|
export default async function MyPage({ searchParams }: { searchParams: Promise<{ tab?: string; page?: string; sort?: string; q?: string }> }) {
|
||||||
|
const sp = await searchParams;
|
||||||
|
const activeTab = sp?.tab || "posts";
|
||||||
|
const page = parseInt(sp?.page || "1", 10);
|
||||||
|
const sort = sp?.sort || "recent";
|
||||||
|
const q = sp?.q || "";
|
||||||
|
|
||||||
|
// 현재 로그인한 사용자 정보 가져오기
|
||||||
|
let currentUser: {
|
||||||
|
userId: string;
|
||||||
|
nickname: string;
|
||||||
|
profileImage: string | null;
|
||||||
|
points: number;
|
||||||
|
level: number;
|
||||||
|
grade: number;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const h = await headers();
|
||||||
|
const cookieHeader = h.get("cookie") || "";
|
||||||
|
const uid = cookieHeader
|
||||||
|
.split(";")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.find((pair) => pair.startsWith("uid="))
|
||||||
|
?.split("=")[1];
|
||||||
|
|
||||||
|
if (uid) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { userId: decodeURIComponent(uid) },
|
||||||
|
select: { userId: true, nickname: true, profileImage: true, points: true, level: true, grade: true },
|
||||||
|
});
|
||||||
|
if (user) currentUser = user;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 에러 무시
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로그인되지 않은 경우 어드민 사용자 가져오기
|
||||||
|
if (!currentUser) {
|
||||||
|
const admin = await prisma.user.findUnique({
|
||||||
|
where: { nickname: "admin" },
|
||||||
|
select: { userId: true, nickname: true, profileImage: true, points: true, level: true, grade: true },
|
||||||
|
});
|
||||||
|
if (admin) currentUser = admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
return <div className="p-4">로그인이 필요합니다.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 통계 정보 가져오기
|
||||||
|
const [postsCount, commentsCount, receivedMessagesCount, sentMessagesCount] = await Promise.all([
|
||||||
|
prisma.post.count({ where: { authorId: currentUser.userId } }),
|
||||||
|
prisma.comment.count({ where: { authorId: currentUser.userId } }),
|
||||||
|
prisma.message.count({ where: { receiverId: currentUser.userId } }),
|
||||||
|
prisma.message.count({ where: { senderId: currentUser.userId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 등급 업그레이드에 필요한 포인트 계산 (예시: 다음 등급까지)
|
||||||
|
const nextGradePoints = (() => {
|
||||||
|
// 등급별 포인트 기준을 여기에 설정 (임시로 2M)
|
||||||
|
const gradeThresholds = [0, 200000, 400000, 800000, 1600000, 3200000, 6400000, 10000000];
|
||||||
|
const currentGradeIndex = Math.min(7, Math.max(0, currentUser.grade));
|
||||||
|
return gradeThresholds[currentGradeIndex + 1] || 2000000;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: "posts", label: "내가 쓴 게시글", count: postsCount },
|
||||||
|
{ key: "comments", label: "내가 쓴 댓글", count: commentsCount },
|
||||||
|
{ key: "messages-received", label: "받은 쪽지함", count: receivedMessagesCount },
|
||||||
|
{ key: "messages-sent", label: "보낸 쪽지함", count: sentMessagesCount },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 히어로 배너 */}
|
||||||
|
<section>
|
||||||
|
<HeroBanner />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 프로필 섹션 */}
|
||||||
|
<section className="bg-white rounded-[24px] px-[108px] py-[23px]">
|
||||||
|
<div className="flex gap-[64px] items-center justify-center">
|
||||||
|
{/* 좌측: 프로필 이미지, 닉네임, 레벨/등급/포인트 */}
|
||||||
|
<div className="flex flex-col gap-[16px] items-center">
|
||||||
|
<div className="flex flex-col gap-[16px] items-center">
|
||||||
|
{/* 프로필 이미지 */}
|
||||||
|
<div className="relative w-[161px] h-[162px]">
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="80" cy="80" r="79" stroke="#8c8c8c" strokeWidth="2" fill="none" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-[10px] flex items-center justify-center">
|
||||||
|
<UserAvatar
|
||||||
|
src={currentUser.profileImage || null}
|
||||||
|
alt={currentUser.nickname || "프로필"}
|
||||||
|
width={140}
|
||||||
|
height={140}
|
||||||
|
className="rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 닉네임 */}
|
||||||
|
<div className="flex items-center gap-[4px]">
|
||||||
|
<span className="text-[20px] font-bold text-[#5c5c5c] truncate max-w-[200px]">{currentUser.nickname || "사용자"}</span>
|
||||||
|
<button className="w-[20px] h-[20px] shrink-0">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M11.7188 3.125L13.5938 4.99999L5.3125 13.2812L3.4375 11.4062L11.7188 3.125ZM14.0625 1.5625L15.3125 2.8125C15.625 3.12499 15.625 3.62499 15.3125 3.93749L13.4375 5.81249L11.5625 3.93749L13.4375 2.06249C13.75 1.74999 14.25 1.74999 14.5625 2.06249L14.0625 1.5625ZM3.125 14.375L9.0625 12.1875L7.1875 10.3125L3.125 14.375Z" fill="#707070"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 레벨/등급/포인트 정보 - 가로 배치 */}
|
||||||
|
<div className="flex gap-[16px] items-center justify-center">
|
||||||
|
<div className="flex items-center gap-[4px] min-w-0">
|
||||||
|
<ProfileLabelIcon width={14} height={14} className="shrink-0" />
|
||||||
|
<span className="text-[11px] text-[#8c8c8c] font-[700] shrink-0">레벨</span>
|
||||||
|
<span className="text-[14px] text-[#5c5c5c] font-[700] min-w-0 truncate">Lv.{currentUser.level || 1}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[4px] min-w-0">
|
||||||
|
<ProfileLabelIcon width={14} height={14} className="shrink-0" />
|
||||||
|
<span className="text-[11px] text-[#8c8c8c] font-[700] shrink-0">등급</span>
|
||||||
|
<span className="text-[14px] text-[#5c5c5c] font-[700] min-w-0 truncate">{getGradeName(currentUser.grade)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-[4px] min-w-0">
|
||||||
|
<ProfileLabelIcon width={14} height={14} className="shrink-0" />
|
||||||
|
<span className="text-[11px] text-[#8c8c8c] font-[700] shrink-0">포인트</span>
|
||||||
|
<span className="text-[14px] text-[#5c5c5c] font-[700] min-w-0 truncate">{currentUser.points.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="w-[8px] h-[162px] bg-[#d5d5d5] shrink-0"></div>
|
||||||
|
|
||||||
|
{/* 우측: 등급 배지 및 포인트 진행 상황 */}
|
||||||
|
<div className="flex flex-col items-center justify-center w-[161px] shrink-0">
|
||||||
|
<div className="w-[125px] h-[125px] flex items-center justify-center mb-[16px]">
|
||||||
|
<GradeIcon grade={currentUser.grade} width={125} height={125} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-[16px] items-center">
|
||||||
|
<div className="text-[20px] font-bold text-[#5c5c5c]">{getGradeName(currentUser.grade)}</div>
|
||||||
|
<div className="flex items-start gap-[8px] text-[20px] font-bold">
|
||||||
|
<span className="text-[#5c5c5c] truncate">{(currentUser.points / 1000000).toFixed(1)}M</span>
|
||||||
|
<span className="text-[#8c8c8c] shrink-0">/ {(nextGradePoints / 1000000).toFixed(1)}M</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 탭 버튼 */}
|
||||||
|
<section className="h-[67px]">
|
||||||
|
<div className="bg-white rounded-[24px] p-[10px] flex gap-[10px] items-center">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<Link
|
||||||
|
key={tab.key}
|
||||||
|
href={`/my-page?tab=${tab.key}`}
|
||||||
|
className={`flex-1 h-[46px] rounded-[16px] flex items-center justify-center gap-[8px] ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? "bg-[#5c5c5c] text-white"
|
||||||
|
: "bg-transparent text-[#5c5c5c]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`text-[16px] ${activeTab === tab.key ? "font-medium" : "font-normal"}`}>
|
||||||
|
{tab.label}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className={`h-[20px] px-[8px] py-[4px] rounded-[14px] ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? "bg-white"
|
||||||
|
: "border border-[#707070]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-semibold ${
|
||||||
|
activeTab === tab.key ? "text-[#707070]" : "text-[#707070]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.count.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 컨텐츠 영역 */}
|
||||||
|
<section>
|
||||||
|
{activeTab === "posts" && (
|
||||||
|
<PostList
|
||||||
|
authorId={currentUser.userId}
|
||||||
|
sort={sort as "recent" | "popular"}
|
||||||
|
q={q}
|
||||||
|
variant="board"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === "comments" && (
|
||||||
|
<div className="bg-white rounded-[24px] p-4">
|
||||||
|
<div className="text-center text-neutral-500 py-10">댓글 기능은 준비 중입니다.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeTab === "messages-received" && (
|
||||||
|
<div className="bg-white rounded-[24px] p-4">
|
||||||
|
<div className="text-center text-neutral-500 py-10">받은 쪽지함 기능은 준비 중입니다.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeTab === "messages-sent" && (
|
||||||
|
<div className="bg-white rounded-[24px] p-4">
|
||||||
|
<div className="text-center text-neutral-500 py-10">보낸 쪽지함 기능은 준비 중입니다.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -10,12 +10,53 @@ import { RankIcon3rd } from "@/app/components/RankIcon3rd";
|
|||||||
import { UserAvatar } from "@/app/components/UserAvatar";
|
import { UserAvatar } from "@/app/components/UserAvatar";
|
||||||
import { ImagePlaceholderIcon } from "@/app/components/ImagePlaceholderIcon";
|
import { ImagePlaceholderIcon } from "@/app/components/ImagePlaceholderIcon";
|
||||||
import { BoardPanelClient } from "@/app/components/BoardPanelClient";
|
import { BoardPanelClient } from "@/app/components/BoardPanelClient";
|
||||||
|
import { GradeIcon, getGradeName } from "@/app/components/GradeIcon";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
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;
|
||||||
const sort = sp?.sort ?? "recent";
|
const sort = sp?.sort ?? "recent";
|
||||||
|
|
||||||
|
// 로그인된 사용자 정보 가져오기 (기본값: 어드민)
|
||||||
|
let currentUser: {
|
||||||
|
userId: string;
|
||||||
|
nickname: string;
|
||||||
|
profileImage: string | null;
|
||||||
|
points: number;
|
||||||
|
level: number;
|
||||||
|
grade: number;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const h = await headers();
|
||||||
|
const cookieHeader = h.get("cookie") || "";
|
||||||
|
const uid = cookieHeader
|
||||||
|
.split(";")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.find((pair) => pair.startsWith("uid="))
|
||||||
|
?.split("=")[1];
|
||||||
|
|
||||||
|
if (uid) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { userId: decodeURIComponent(uid) },
|
||||||
|
select: { userId: true, nickname: true, profileImage: true, points: true, level: true, grade: true },
|
||||||
|
});
|
||||||
|
if (user) currentUser = user;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 에러 무시
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로그인되지 않은 경우 어드민 사용자 가져오기
|
||||||
|
if (!currentUser) {
|
||||||
|
const admin = await prisma.user.findUnique({
|
||||||
|
where: { nickname: "admin" },
|
||||||
|
select: { userId: true, nickname: true, profileImage: true, points: true, level: true, grade: true },
|
||||||
|
});
|
||||||
|
if (admin) currentUser = admin;
|
||||||
|
}
|
||||||
|
|
||||||
// 메인페이지 설정 불러오기
|
// 메인페이지 설정 불러오기
|
||||||
const SETTINGS_KEY = "mainpage_settings" as const;
|
const SETTINGS_KEY = "mainpage_settings" as const;
|
||||||
const settingRow = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } });
|
const settingRow = await prisma.setting.findUnique({ where: { key: SETTINGS_KEY } });
|
||||||
@@ -94,7 +135,7 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
|||||||
|
|
||||||
if (isSpecialRank) {
|
if (isSpecialRank) {
|
||||||
specialRankUsers = await prisma.user.findMany({
|
specialRankUsers = await prisma.user.findMany({
|
||||||
select: { userId: true, nickname: true, points: true, profileImage: true },
|
select: { userId: true, nickname: true, points: true, profileImage: true, grade: true },
|
||||||
where: { status: "active" },
|
where: { status: "active" },
|
||||||
orderBy: { points: "desc" },
|
orderBy: { points: "desc" },
|
||||||
take: 3,
|
take: 3,
|
||||||
@@ -106,6 +147,7 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
|||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
content: true,
|
||||||
attachments: {
|
attachments: {
|
||||||
where: { type: "image" },
|
where: { type: "image" },
|
||||||
orderBy: { sortOrder: "asc" },
|
orderBy: { sortOrder: "asc" },
|
||||||
@@ -187,50 +229,52 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
|
|||||||
<div className="h-[120px] flex items-center justify-center relative z-10">
|
<div className="h-[120px] flex items-center justify-center relative z-10">
|
||||||
<div className="flex items-center justify-center gap-[8px]">
|
<div className="flex items-center justify-center gap-[8px]">
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
src={null}
|
src={currentUser?.profileImage || null}
|
||||||
alt="프로필"
|
alt={currentUser?.nickname || "프로필"}
|
||||||
width={120}
|
width={120}
|
||||||
height={120}
|
height={120}
|
||||||
className="rounded-full"
|
className="rounded-full"
|
||||||
/>
|
/>
|
||||||
<div className="w-[62px] h-[62px] rounded-full bg-neutral-200 flex items-center justify-center text-[11px] text-neutral-700">
|
{currentUser && (
|
||||||
Lv
|
<div className="w-[62px] h-[62px] flex items-center justify-center">
|
||||||
</div>
|
<GradeIcon grade={currentUser.grade} width={62} height={62} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[120px] flex flex-col items-center relative z-10">
|
<div className="h-[120px] flex flex-col items-center relative z-10">
|
||||||
<div className="text-[18px] text-[#5c5c5c] font-[700] truncate text-center mb-[20px]">홍길동</div>
|
<div className="text-[18px] text-[#5c5c5c] font-[700] truncate text-center mb-[20px]">{currentUser?.nickname || "사용자"}</div>
|
||||||
<div className="w-[300px] pl-[67px] flex flex-col gap-[12px]">
|
<div className="w-[300px] pl-[67px] flex flex-col gap-[12px]">
|
||||||
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
|
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
|
||||||
<div className="w-[64px] flex items-center">
|
<div className="w-[64px] flex items-center">
|
||||||
<ProfileLabelIcon width={16} height={16} />
|
<ProfileLabelIcon width={16} height={16} />
|
||||||
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]">레벨</span>
|
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]">레벨</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[16px] text-[#5c5c5c] font-[700]">Lv. 79</div>
|
<div className="text-[16px] text-[#5c5c5c] font-[700]">Lv. {currentUser?.level || 1}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
|
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
|
||||||
<div className="w-[64px] flex items-center">
|
<div className="w-[64px] flex items-center">
|
||||||
<ProfileLabelIcon width={16} height={16} />
|
<ProfileLabelIcon width={16} height={16} />
|
||||||
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]">등급</span>
|
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]">등급</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[16px] text-[#5c5c5c] font-[700]">Iron</div>
|
<div className="text-[16px] text-[#5c5c5c] font-[700]">{getGradeName(currentUser?.grade || 0)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
|
<div className="grid grid-cols-[64px_auto] gap-x-[24px] items-center h-[16px]">
|
||||||
<div className="w-[64px] flex items-center">
|
<div className="w-[64px] flex items-center">
|
||||||
<ProfileLabelIcon width={16} height={16} />
|
<ProfileLabelIcon width={16} height={16} />
|
||||||
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]">포인트</span>
|
<span className="ml-[8px] text-[12px] text-[#8c8c8c] font-[700]">포인트</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[16px] text-[#5c5c5c] font-[700]">1,600,000</div>
|
<div className="text-[16px] text-[#5c5c5c] font-[700]">{(currentUser?.points || 0).toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-[12px] relative z-10">
|
<div className="flex flex-col gap-[12px] relative z-10">
|
||||||
<button className="relative w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#7a7a7a] text-white text-[12px] font-[700] flex items-center">
|
<Link href="/my-page" className="relative w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#7a7a7a] text-white text-[12px] font-[700] flex items-center justify-center">
|
||||||
<span className="absolute left-[100px] inline-flex items-center">
|
<span className="inline-flex items-center">
|
||||||
<SearchIcon width={16} height={16} />
|
<SearchIcon width={16} height={16} />
|
||||||
<span className="ml-[8px]">내 정보 페이지</span>
|
<span className="ml-[8px]">내 정보 페이지</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</Link>
|
||||||
<button className="relative w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#7a7a7a] text-white text-[12px] font-[700] flex items-center">
|
<button className="relative w-[300px] h-[32px] rounded-full bg-[#8c8c8c] hover:bg-[#7a7a7a] text-white text-[12px] font-[700] flex items-center">
|
||||||
<span className="absolute left-[100px] inline-flex items-center">
|
<span className="absolute left-[100px] inline-flex items-center">
|
||||||
<SearchIcon width={16} height={16} />
|
<SearchIcon width={16} height={16} />
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { HeroBanner } from "@/app/components/HeroBanner";
|
import { HeroBanner } from "@/app/components/HeroBanner";
|
||||||
|
import Link from "next/link";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
function stripHtml(html: string | null | undefined): string {
|
||||||
|
if (!html) return "";
|
||||||
|
return html.replace(/<[^>]*>/g, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
// 서버 전용 페이지: params가 Promise일 수 있어 안전 언랩 후 절대 URL로 fetch합니다.
|
// 서버 전용 페이지: params가 Promise일 수 있어 안전 언랩 후 절대 URL로 fetch합니다.
|
||||||
export default async function PostDetail({ params }: { params: any }) {
|
export default async function PostDetail({ params }: { params: any }) {
|
||||||
@@ -14,6 +21,27 @@ export default async function PostDetail({ params }: { params: any }) {
|
|||||||
if (!res.ok) return notFound();
|
if (!res.ok) return notFound();
|
||||||
const { post } = await res.json();
|
const { post } = await res.json();
|
||||||
const createdAt = post?.createdAt ? new Date(post.createdAt) : null;
|
const createdAt = post?.createdAt ? new Date(post.createdAt) : null;
|
||||||
|
|
||||||
|
// 같은 게시판의 다른 게시글 10개 가져오기 (현재 게시글 제외)
|
||||||
|
const relatedPosts = post?.boardId
|
||||||
|
? await prisma.post.findMany({
|
||||||
|
where: {
|
||||||
|
boardId: post.boardId,
|
||||||
|
id: { not: id },
|
||||||
|
status: { not: "deleted" },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 10,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
createdAt: true,
|
||||||
|
author: { select: { nickname: true } },
|
||||||
|
stat: { select: { views: true, recommendCount: true, commentsCount: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 상단 배너 */}
|
{/* 상단 배너 */}
|
||||||
@@ -30,7 +58,13 @@ export default async function PostDetail({ params }: { params: any }) {
|
|||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
<div className="p-4 md:p-6">
|
<div className="p-4 md:p-6">
|
||||||
<div className="prose max-w-none whitespace-pre-wrap break-words">{post.content}</div>
|
<div
|
||||||
|
className="prose max-w-none break-words"
|
||||||
|
dangerouslySetInnerHTML={{ __html: post.content || "" }}
|
||||||
|
style={{
|
||||||
|
wordBreak: "break-word",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<footer className="px-4 py-3 border-t border-neutral-200 flex gap-2">
|
<footer className="px-4 py-3 border-t border-neutral-200 flex gap-2">
|
||||||
<form action={`/api/posts/${post.id}/recommend`} method="post">
|
<form action={`/api/posts/${post.id}/recommend`} method="post">
|
||||||
@@ -41,6 +75,46 @@ export default async function PostDetail({ params }: { params: any }) {
|
|||||||
</form>
|
</form>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* 같은 게시판 게시글 목록 */}
|
||||||
|
{relatedPosts.length > 0 && (
|
||||||
|
<section className="rounded-xl overflow-hidden bg-white">
|
||||||
|
<header className="px-4 py-3 border-b border-neutral-200">
|
||||||
|
<h2 className="text-lg font-bold text-neutral-900">같은 게시판 게시글</h2>
|
||||||
|
</header>
|
||||||
|
<div>
|
||||||
|
<ul className="divide-y divide-[#ececec]">
|
||||||
|
{relatedPosts.map((p) => {
|
||||||
|
const postDate = new Date(p.createdAt);
|
||||||
|
return (
|
||||||
|
<li key={p.id} className="px-4 py-2.5 hover:bg-neutral-50 transition-colors">
|
||||||
|
<Link href={`/posts/${p.id}`} className="block">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-[1fr_120px_120px_80px] items-center gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-[15px] md:text-base text-neutral-900">
|
||||||
|
{stripHtml(p.title)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="md:w-[120px] text-xs text-neutral-600 text-center hidden md:flex items-center justify-center gap-2">
|
||||||
|
<span className="truncate max-w-[84px]">{p.author?.nickname ?? "익명"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="md:w-[120px] text-[11px] text-[#8c8c8c] text-center flex items-center justify-center gap-3">
|
||||||
|
<span>조회 {p.stat?.views ?? 0}</span>
|
||||||
|
<span>추천 {p.stat?.recommendCount ?? 0}</span>
|
||||||
|
<span>댓글 {p.stat?.commentsCount ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="md:w-[80px] text-[11px] text-neutral-500 text-right hidden md:block">
|
||||||
|
{postDate.toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/app/svgs/01_bronze.svg
Normal file
|
After Width: | Height: | Size: 99 KiB |
9
src/app/svgs/02_silver.svg.svg
Normal file
|
After Width: | Height: | Size: 69 KiB |
9
src/app/svgs/03_gold.svg
Normal file
|
After Width: | Height: | Size: 70 KiB |
9
src/app/svgs/04_platinum.svg
Normal file
|
After Width: | Height: | Size: 84 KiB |
9
src/app/svgs/05_diamond.svg
Normal file
|
After Width: | Height: | Size: 92 KiB |
9
src/app/svgs/06_master.svg
Normal file
|
After Width: | Height: | Size: 96 KiB |
9
src/app/svgs/07_grandmaster.svg
Normal file
|
After Width: | Height: | Size: 95 KiB |
9
src/app/svgs/08_god.svg
Normal file
|
After Width: | Height: | Size: 133 KiB |
@@ -5,17 +5,35 @@ const protectedApi = [
|
|||||||
/^\/api\/posts\/[^/]+\/approve$/,
|
/^\/api\/posts\/[^/]+\/approve$/,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function middleware(req: NextRequest) {
|
export async function middleware(req: NextRequest) {
|
||||||
const { pathname } = req.nextUrl;
|
const { pathname } = req.nextUrl;
|
||||||
const needAuth = protectedApi.some((re) => re.test(pathname));
|
const response = NextResponse.next();
|
||||||
if (!needAuth) return NextResponse.next();
|
|
||||||
|
// 쿠키에 uid가 없으면 어드민으로 자동 로그인 (기본값)
|
||||||
const uid = req.cookies.get("uid")?.value;
|
const uid = req.cookies.get("uid")?.value;
|
||||||
if (!uid) return new NextResponse(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
if (!uid) {
|
||||||
return NextResponse.next();
|
// 어드민 사용자 ID 가져오기 (DB 조회 대신 하드코딩 - 실제 환경에서는 다른 방식 사용 권장)
|
||||||
|
// 어드민 nickname은 "admin"으로 고정되어 있다고 가정
|
||||||
|
// 실제 userId는 DB에서 가져와야 하지만, middleware는 비동기 DB 호출을 제한적으로 지원
|
||||||
|
// 대신 page.tsx에서 처리하도록 함
|
||||||
|
|
||||||
|
// 페이지 요청일 경우만 쿠키 설정 시도
|
||||||
|
// API는 제외하고 페이지만 처리
|
||||||
|
if (!pathname.startsWith("/api")) {
|
||||||
|
// 페이지 레벨에서 처리하도록 함 (쿠키는 클라이언트 측에서 설정 필요)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const needAuth = protectedApi.some((re) => re.test(pathname));
|
||||||
|
if (needAuth && !uid) {
|
||||||
|
return new NextResponse(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/api/:path*"],
|
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||