diff --git a/prisma/seed.js b/prisma/seed.js
index eba5007..f89f2af 100644
--- a/prisma/seed.js
+++ b/prisma/seed.js
@@ -168,7 +168,12 @@ async function upsertRoles() {
async function upsertAdmin() {
const admin = await prisma.user.upsert({
where: { nickname: "admin" },
- update: { passwordHash: hashPassword("1234") },
+ update: {
+ passwordHash: hashPassword("1234"),
+ grade: 7,
+ points: 1650000,
+ level: 200,
+ },
create: {
nickname: "admin",
name: "Administrator",
@@ -177,6 +182,9 @@ async function upsertAdmin() {
passwordHash: hashPassword("1234"),
agreementTermsAt: new Date(),
authLevel: "ADMIN",
+ grade: 7,
+ points: 1650000,
+ level: 200,
},
});
diff --git a/public/svgs/01_bronze.svg b/public/svgs/01_bronze.svg
new file mode 100644
index 0000000..35258e7
--- /dev/null
+++ b/public/svgs/01_bronze.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/svgs/02_silver.svg.svg b/public/svgs/02_silver.svg.svg
new file mode 100644
index 0000000..1c8cf00
--- /dev/null
+++ b/public/svgs/02_silver.svg.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/svgs/03_gold.svg b/public/svgs/03_gold.svg
new file mode 100644
index 0000000..b22c300
--- /dev/null
+++ b/public/svgs/03_gold.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/svgs/04_platinum.svg b/public/svgs/04_platinum.svg
new file mode 100644
index 0000000..9f5a33c
--- /dev/null
+++ b/public/svgs/04_platinum.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/svgs/05_diamond.svg b/public/svgs/05_diamond.svg
new file mode 100644
index 0000000..2ed7671
--- /dev/null
+++ b/public/svgs/05_diamond.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/svgs/06_master.svg b/public/svgs/06_master.svg
new file mode 100644
index 0000000..c74b612
--- /dev/null
+++ b/public/svgs/06_master.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/svgs/07_grandmaster.svg b/public/svgs/07_grandmaster.svg
new file mode 100644
index 0000000..6e58412
--- /dev/null
+++ b/public/svgs/07_grandmaster.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/svgs/08_god.svg b/public/svgs/08_god.svg
new file mode 100644
index 0000000..ec4544b
--- /dev/null
+++ b/public/svgs/08_god.svg
@@ -0,0 +1,9 @@
+
diff --git a/public/uploads/1762052938422-has0h33j4x6.webp b/public/uploads/1762052938422-has0h33j4x6.webp
new file mode 100644
index 0000000..a02680e
Binary files /dev/null and b/public/uploads/1762052938422-has0h33j4x6.webp differ
diff --git a/public/uploads/1762053004600-0ewlk5af03i.webp b/public/uploads/1762053004600-0ewlk5af03i.webp
new file mode 100644
index 0000000..5eb8c1c
Binary files /dev/null and b/public/uploads/1762053004600-0ewlk5af03i.webp differ
diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts
index 139d97a..b223126 100644
--- a/src/app/api/posts/route.ts
+++ b/src/app/api/posts/route.ts
@@ -48,6 +48,7 @@ const listQuerySchema = z.object({
sort: z.enum(["recent", "popular"]).default("recent").optional(),
tag: z.string().optional(), // Tag.slug
author: z.string().optional(), // User.nickname contains
+ authorId: z.string().optional(), // User.userId exact match
start: z.coerce.date().optional(), // createdAt >= start
end: z.coerce.date().optional(), // createdAt <= end
});
@@ -58,7 +59,7 @@ export async function GET(req: Request) {
if (!parsed.success) {
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 = {
NOT: { status: "deleted" as const },
...(boardId ? { boardId } : {}),
@@ -75,7 +76,11 @@ export async function GET(req: Request) {
postTags: { some: { tag: { slug: tag } } },
}
: {}),
- ...(author
+ ...(authorId
+ ? {
+ authorId,
+ }
+ : author
? {
author: { nickname: { contains: author } },
}
diff --git a/src/app/boards/[id]/page.tsx b/src/app/boards/[id]/page.tsx
index 0605df8..50aa719 100644
--- a/src/app/boards/[id]/page.tsx
+++ b/src/app/boards/[id]/page.tsx
@@ -3,6 +3,11 @@ import { HeroBanner } from "@/app/components/HeroBanner";
import { BoardToolbar } from "@/app/components/BoardToolbar";
import { headers } from "next/headers";
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가 될 수 있어 안전 언랩 처리합니다.
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";
- let rankingItems: { userId: string; nickname: string; points: number }[] = [];
+ let rankingItems: { userId: string; nickname: string; points: number; profileImage: string | null; grade: number }[] = [];
if (isSpecialRanking) {
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" },
orderBy: { points: "desc" },
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 (
@@ -54,24 +59,46 @@ export default async function BoardDetail({ params, searchParams }: { params: an
{!isSpecialRanking &&
}
{isSpecialRanking ? (
-
-
-
포인트 랭킹
-
-
- {rankingItems.map((i, idx) => (
- -
-
-
{idx + 1}
-
{i.nickname || "회원"}
+
+
+ {rankingItems.map((i, idx) => {
+ const rank = idx + 1;
+ return (
+
+
+
+ {(rank === 1 || rank === 2 || rank === 3) && (
+
+ {rank === 1 && }
+ {rank === 2 && }
+ {rank === 3 && }
+
+ )}
+
+ {rank}위
+
+
+
+
+ {i.nickname || "회원"}
+
+
+
+
+
+
+
{i.points.toLocaleString()}
+
+
-
{i.points}점
-
- ))}
- {rankingItems.length === 0 && (
-
- 랭킹 데이터가 없습니다.
- )}
-
+ );
+ })}
+
+ {rankingItems.length === 0 && (
+
랭킹 데이터가 없습니다.
+ )}
) : (
{
+ // 쿠키에 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;
+}
+
diff --git a/src/app/components/BoardPanelClient.tsx b/src/app/components/BoardPanelClient.tsx
index a9bad6c..5bebc63 100644
--- a/src/app/components/BoardPanelClient.tsx
+++ b/src/app/components/BoardPanelClient.tsx
@@ -8,6 +8,7 @@ import { RankIcon3rd } from "./RankIcon3rd";
import { UserAvatar } from "./UserAvatar";
import { ImagePlaceholderIcon } from "./ImagePlaceholderIcon";
import { PostList } from "./PostList";
+import { GradeIcon } from "./GradeIcon";
type BoardMeta = {
id: string;
@@ -21,12 +22,14 @@ type UserData = {
nickname: string | null;
points: number;
profileImage: string | null;
+ grade: number;
};
type PostData = {
id: string;
title: string;
createdAt: Date;
+ content?: string | null;
attachments?: { url: string }[];
stat?: { recommendCount: number | null };
};
@@ -61,6 +64,27 @@ export function BoardPanelClient({
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(/
]+src=["']([^"']+)["'][^>]*>/i);
+ if (imgMatch && imgMatch[1]) {
+ return imgMatch[1];
+ }
+ // figure 안의 img 태그도 확인
+ const figureMatch = content.match(/]*>.*?
]+src=["']([^"']+)["'][^>]*>/is);
+ if (figureMatch && figureMatch[1]) {
+ return figureMatch[1];
+ }
+ return null;
+ }
+
const isTextMain = board.mainTypeKey === "main_text";
const isSpecialRank = board.mainTypeKey === "main_special_rank";
const isPreview = board.mainTypeKey === "main_preview";
@@ -103,6 +127,9 @@ export function BoardPanelClient({
height={150}
className="w-full h-full object-cover rounded-none"
/>
+
+
+
@@ -165,14 +192,15 @@ export function BoardPanelClient({
{selectedBoardData.previewPosts.map((post) => {
- const firstImage = post.attachments?.[0]?.url;
+ // attachments에서 이미지를 먼저 찾고, 없으면 content에서 추출
+ const firstImage = post.attachments?.[0]?.url || extractImageFromContent(post.content);
return (
{firstImage ? (

) : (
@@ -191,7 +219,7 @@ export function BoardPanelClient({
n
-
{post.title}
+
{stripHtml(post.title)}
@@ -244,14 +272,14 @@ export function BoardPanelClient({
{selectedBoardData.textPosts.map((p) => (
-
-
+
-
{p.title}
+
{stripHtml(p.title)}
+{p.stat?.recommendCount ?? 0}
-
+
{formatDateYmd(p.createdAt)}
diff --git a/src/app/components/GradeIcon.tsx b/src/app/components/GradeIcon.tsx
new file mode 100644
index 0000000..6e2c975
--- /dev/null
+++ b/src/app/components/GradeIcon.tsx
@@ -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 (
+
+ );
+}
+
+// 등급 숫자를 등급 이름으로 변환하는 함수
+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];
+}
+
diff --git a/src/app/components/PostList.tsx b/src/app/components/PostList.tsx
index 42c055f..274d67c 100644
--- a/src/app/components/PostList.tsx
+++ b/src/app/components/PostList.tsx
@@ -29,20 +29,37 @@ type Resp = {
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 }) {
- const pageSize = 10;
+function stripHtml(html: string | null | undefined): string {
+ 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 listContainerRef = useRef(null);
const [lockedMinHeight, setLockedMinHeight] = useState(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) => {
if (prev && prev.items.length === 0) return null;
const page = index + 1;
+ // 무한 스크롤은 board variant가 아닐 때 사용되므로 pageSize 사용
const sp = new URLSearchParams({ page: String(page), pageSize: String(pageSize), sort });
if (boardId) sp.set("boardId", boardId);
if (q) sp.set("q", q);
if (tag) sp.set("tag", tag);
if (author) sp.set("author", author);
+ if (authorId) sp.set("authorId", authorId);
if (start) sp.set("start", start);
if (end) sp.set("end", end);
return `/api/posts?${sp.toString()}`;
@@ -50,29 +67,39 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
// default(무한 스크롤 형태)
const { data, size, setSize, isLoading } = useSWRInfinite(getKey, fetcher, { revalidateFirstPage: false });
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;
-
- // board 변형: 번호 페이지네이션
- const initialPage = useMemo(() => Math.max(1, parseInt(sp.get("page") || "1", 10)), [sp]);
- const [page, setPage] = useState(initialPage);
+
useEffect(() => { setPage(initialPage); }, [initialPage]);
+ useEffect(() => { setCurrentPageSize(pageSize); }, [pageSize]);
+
const singleKey = useMemo(() => {
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 (q) usp.set("q", q);
if (tag) usp.set("tag", tag);
if (author) usp.set("author", author);
+ if (authorId) usp.set("authorId", authorId);
if (start) usp.set("start", start);
if (end) usp.set("end", end);
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(singleKey, fetcher);
- const itemsSingle = singlePageResp?.items ?? [];
- const totalSingle = singlePageResp?.total ?? 0;
- const totalPages = Math.max(1, Math.ceil(totalSingle / pageSize));
- const isEmptySingle = !isLoadingSingle && itemsSingle.length === 0;
+
+ // 이전 데이터를 유지하여 깜빡임 방지
+ const [stableData, setStableData] = useState(null);
+ 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 isEmpty = variant === "board" ? isEmptySingle : isEmptyInfinite;
@@ -149,7 +176,7 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
{p.isPinned &&
공지}
- {p.title}
+ {stripHtml(p.title)}
{!!p.postTags?.length && (
@@ -254,6 +281,30 @@ export function PostList({ boardId, sort = "recent", q, tag, author, start, end,
Next
+
+ 표시 개수
+
+
{newPostHref && (
diff --git a/src/app/components/RankIcon1st.tsx b/src/app/components/RankIcon1st.tsx
index 507c642..7840e07 100644
--- a/src/app/components/RankIcon1st.tsx
+++ b/src/app/components/RankIcon1st.tsx
@@ -1,6 +1,6 @@
-export function RankIcon1st() {
+export function RankIcon1st({ width = 20, height = 21 }: { width?: number; height?: number }) {
return (
-