인기글 추가
All checks were successful
deploy-on-main / deploy (push) Successful in 28s

This commit is contained in:
koreacomp5
2025-11-28 06:12:58 +09:00
parent c85450ce37
commit afc714022f
7 changed files with 294 additions and 14 deletions

37
.cursor/.prompt/new.md Normal file
View File

@@ -0,0 +1,37 @@
7.게시판 열 구분선 잘보이게 요청 / 모바일 최적화 요청 및 구분선,높이 조절 요청
8.글삭제기능 어드민추가
9-1.시크로드 참고 - 제휴업체 리스트, 지역별 / 제휴업체 정보 생성 요청 (제휴업체 프로필,출근부)
9-2.제휴업체 및 프로필 등록은, 권한을부여한 제휴업체가 직접 등록하도록 해야합니다
9-3.제휴업체 등록 주인이하면안됨, 시크로드 - 제휴문의 - 제휴문의글쓰기 참고요청
제휴문의 게시판 ( 관리자 승인후) → 이동
제휴업소 리스트 게시판 (노출됨)"
10.배너관리 이미지 사이즈 규격 및 제휴업체 등록시 이미지 사이즈 규격
11,게시판, 주간인기글 , 일간인기글 추가 요청
12.https://search.google.com/search-console/welcome 등록요청
13.메인페이지 제휴업체 배너 이미지 규격작게 수정요청, 한번에 4개이상 정도 보이는 정도
14.메인페이지 제휴업체 클릭시 제휴업체 카테고리 하이퍼연결 추가 요청
15.레벨별 아이콘 페이지 / https://seekrod.co.kr/bbs/page.php?hid=point 참고
16. 게시글 상단고정 기능
17. 메인화면 큰 카테고리 빼기
18. google SEO: header, meta, description 동적설정
2. 회색 배경 영역(비밀글, 글자수)이 글 작성 영역 안에 있어야 함
로그인 프로세스 수정: 로그인시 로그인 팝업 모달
로그인 로그오프 시, 메인 프로필 디자인 누락되서 추가(지금상태로 가도 무상관)
1. 게시글에서 목록돌아가는 버튼 디자인과 다름
2. 게시글에서 제목, 내용, 댓글 디자인 다름
3. 게시글에서 최하단에 게시글리스트 부분 디자인 다
4. 게시글 리스트에서 리스트버튼 하단 divider 색상이 너무 연함
5. 표시개수 필요없을 거 같은데, 그냥 고정해버리죠?
9. 게시글 이미지 배치사이즈
10. 외부접속 가입없어도 가능
11. 글뷰에서 게시글 리스트로
12. 게시글 리스트 디자인
13. 포인트 규칙
14. 게시판권한 확인
15.메인뷰 3열 사이즈 변경
16. 로그인 안됐을때 카드 휑함
17.이미지 사이즈 미리불러오기

View File

@@ -217,6 +217,7 @@ model Post {
reports Report[]
stat PostStat?
viewLogs PostViewLog[]
dailyViews DailyPostView[]
@@index([boardId, status, createdAt])
@@index([boardId, isPinned, pinnedOrder])
@@ -438,6 +439,21 @@ model PostStat {
@@map("post_stats")
}
// 일일 게시글 조회수 (날짜별 집계)
model DailyPostView {
id String @id @default(cuid())
postId String
date DateTime // 날짜만 사용 (시간은 00:00:00)
viewCount Int @default(0)
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
@@unique([postId, date])
@@index([date, viewCount])
@@index([postId, date])
@@map("daily_post_views")
}
// 신고(게시글/댓글 대상)
model Report {
id String @id @default(cuid())

View File

@@ -5,13 +5,33 @@ import { getUserIdFromRequest } from "@/lib/auth";
export async function GET(req: Request) {
const userId = getUserIdFromRequest(req);
if (!userId) return NextResponse.json({ permissions: [] });
const roles = await prisma.userRole.findMany({ where: { userId }, select: { roleId: true } });
if (roles.length === 0) return NextResponse.json({ permissions: [] });
const roleIds = roles.map((r) => r.roleId);
const permissions = await prisma.rolePermission.findMany({
where: { roleId: { in: roleIds }, allowed: true },
select: { resource: true, action: true },
const user = await prisma.user.findUnique({
where: { userId },
select: {
authLevel: true,
userRoles: { select: { roleId: true } },
},
});
if (!user) return NextResponse.json({ permissions: [] });
const roleIds = user.userRoles.map((r) => r.roleId);
const rolePermissions =
roleIds.length > 0
? await prisma.rolePermission.findMany({
where: { roleId: { in: roleIds }, allowed: true },
select: { resource: true, action: true },
})
: [];
const hasAdminPerm = rolePermissions.some(
(perm) => perm.resource === "ADMIN" && perm.action === "ADMINISTER"
);
const permissions =
user.authLevel === "ADMIN" && !hasAdminPerm
? [{ resource: "ADMIN" as const, action: "ADMINISTER" as const }, ...rolePermissions]
: rolePermissions;
return NextResponse.json({ permissions });
}

View File

@@ -57,6 +57,25 @@ export async function POST(req: Request, context: { params: Promise<{ id: string
update: { views: { increment: 1 } },
create: { postId: id, views: 1 },
});
// 일일 조회수 업데이트 (오늘 날짜)
const today = new Date();
today.setHours(0, 0, 0, 0);
await tx.dailyPostView.upsert({
where: {
postId_date: {
postId: id,
date: today,
},
},
update: { viewCount: { increment: 1 } },
create: {
postId: id,
date: today,
viewCount: 1,
},
});
counted = true;
});

View File

@@ -0,0 +1,92 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const boardId = searchParams.get("boardId");
const period = searchParams.get("period") || "daily"; // daily | weekly
// 날짜 범위 계산
const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
const startOfWeek = new Date(now);
startOfWeek.setDate(now.getDate() - 7);
startOfWeek.setHours(0, 0, 0, 0);
const dateFilter = period === "daily"
? {
date: {
gte: startOfToday,
lte: endOfToday,
}
}
: { date: { gte: startOfWeek } };
// 일일 조회수 테이블에서 조회수 합계 계산
const dailyViews = await prisma.dailyPostView.groupBy({
by: ["postId"],
where: dateFilter,
_sum: {
viewCount: true,
},
orderBy: {
_sum: {
viewCount: "desc",
},
},
take: 20, // 조회수 상위 20개만 가져와서 게시글 정보 조회
});
if (dailyViews.length === 0) {
return NextResponse.json({ items: [], period });
}
const postIds = dailyViews.map((dv) => dv.postId);
// 게시글 정보 조회
const posts = await prisma.post.findMany({
where: {
id: { in: postIds },
status: "published",
...(boardId ? { boardId } : {}),
},
select: {
id: true,
title: true,
createdAt: true,
boardId: true,
board: { select: { id: true, name: true, slug: true } },
isPinned: true,
status: true,
author: { select: { userId: true, nickname: true } },
stat: { select: { recommendCount: true, views: true, commentsCount: true } },
postTags: { select: { tag: { select: { name: true, slug: true } } } },
},
});
// 조회수와 게시글 매핑
const viewCountMap = new Map(
dailyViews.map((dv) => [dv.postId, dv._sum.viewCount ?? 0])
);
// 조회수 순으로 정렬 (조회수가 0보다 큰 것만)
const postsWithViews = posts
.map((post) => ({
...post,
viewCount: viewCountMap.get(post.id) ?? 0,
}))
.filter((post) => post.viewCount > 0) // 조회수가 0보다 큰 것만
.sort((a, b) => {
// 고정글 우선
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
// 조회수 순
if (b.viewCount !== a.viewCount) return b.viewCount - a.viewCount;
// 최신순
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
})
.slice(0, 5); // 상위 5개만
return NextResponse.json({ items: postsWithViews, period });
}

View File

@@ -19,6 +19,7 @@ type Item = {
stat?: { recommendCount: number; views: number; commentsCount: number } | null;
postTags?: { tag: { name: string; slug: string } }[];
author?: { nickname: string } | null;
viewCount?: number; // 일일/주간 조회수
};
type Resp = {
@@ -45,6 +46,12 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
const listContainerRef = useRef<HTMLDivElement | null>(null);
const [lockedMinHeight, setLockedMinHeight] = useState<number | null>(null);
// 인기글 데이터 가져오기 (board variant일 때만)
const popularDailyKey = variant === "board" ? `/api/posts/popular?${boardId ? `boardId=${boardId}&` : ""}period=daily` : null;
const popularWeeklyKey = variant === "board" ? `/api/posts/popular?${boardId ? `boardId=${boardId}&` : ""}period=weekly` : null;
const { data: dailyData } = useSWR<{ items: Item[] }>(popularDailyKey, fetcher);
const { data: weeklyData } = useSWR<{ items: Item[] }>(popularWeeklyKey, fetcher);
// board 변형에서는 URL에서 pageSize를 읽고, 기본값은 20
const defaultPageSize = variant === "board" ? 20 : 10;
const pageSizeParam = sp.get("pageSize");
@@ -184,6 +191,83 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
return (
<div className="w-full">
{/* 인기글 섹션 (board variant일 때만 표시) */}
{variant === "board" && (dailyData?.items.length || weeklyData?.items.length) && (
<div className="mb-6 space-y-4">
{/* 일간 인기글 */}
{dailyData?.items && dailyData.items.length > 0 && (
<div className="border border-[#e6e6e6] rounded-xl overflow-hidden">
<div className="bg-[#f6f4f4] px-4 py-2 border-b border-[#e6e6e6]">
<h3 className="text-[14px] font-semibold text-[#161616]">🔥 </h3>
</div>
<ul className="divide-y divide-[#e6e6e6]">
{dailyData.items.map((p) => (
<li key={p.id} className="px-4 py-3 hover:bg-neutral-50 transition-colors">
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<div
role="link"
tabIndex={0}
className="group block truncate text-neutral-900 cursor-pointer"
onClick={() => router.push(`/posts/${p.id}`)}
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/posts/${p.id}`); }}
>
<span className="text-[15px] group-hover:text-[var(--red-50,#F94B37)] group-hover:font-[700]">
{stripHtml(p.title)}
</span>
{(p.stat?.commentsCount ?? 0) > 0 && (
<span className="ml-1 text-[12px] text-[#f94b37] align-middle">[{p.stat?.commentsCount}]</span>
)}
</div>
</div>
<div className="flex items-center gap-3 text-xs text-[#8c8c8c] shrink-0">
<span className="inline-flex items-center gap-1"><ViewsIcon width={14} height={14} />{p.viewCount ?? 0}</span>
</div>
</div>
</li>
))}
</ul>
</div>
)}
{/* 주간 인기글 */}
{weeklyData?.items && weeklyData.items.length > 0 && (
<div className="border border-[#e6e6e6] rounded-xl overflow-hidden">
<div className="bg-[#f6f4f4] px-4 py-2 border-b border-[#e6e6e6]">
<h3 className="text-[14px] font-semibold text-[#161616]"> </h3>
</div>
<ul className="divide-y divide-[#e6e6e6]">
{weeklyData.items.map((p) => (
<li key={p.id} className="px-4 py-3 hover:bg-neutral-50 transition-colors">
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<div
role="link"
tabIndex={0}
className="group block truncate text-neutral-900 cursor-pointer"
onClick={() => router.push(`/posts/${p.id}`)}
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/posts/${p.id}`); }}
>
<span className="text-[15px] group-hover:text-[var(--red-50,#F94B37)] group-hover:font-[700]">
{stripHtml(p.title)}
</span>
{(p.stat?.commentsCount ?? 0) > 0 && (
<span className="ml-1 text-[12px] text-[#f94b37] align-middle">[{p.stat?.commentsCount}]</span>
)}
</div>
</div>
<div className="flex items-center gap-3 text-xs text-[#8c8c8c] shrink-0">
<span className="inline-flex items-center gap-1"><ViewsIcon width={14} height={14} />{p.viewCount ?? 0}</span>
</div>
</div>
</li>
))}
</ul>
</div>
)}
</div>
)}
{/* 정렬 스위치 (board 변형에서는 상단 툴바를 숨김) */}
{variant !== "board" && (
<div className="px-4 py-2 border-b border-neutral-200 mb-2 flex items-center gap-2">
@@ -209,7 +293,7 @@ export function PostList({ boardId, sort = "recent", q, tag, author, authorId, s
{/* 리스트 테이블 헤더 (board 변형에서는 숨김) */}
{variant !== "board" && (
<div className="hidden md:grid grid-cols-[20px_1fr_120px_120px_80px] items-center px-4 py-2 text-[12px] text-[#8c8c8c] bg-[#f6f4f4] border-b border-[#e6e6e6] rounded-t-xl">
<div className="hidden md:grid grid-cols-[20px_1fr_120px_120px_80px] items-center px-4 py-2 text-[12px] text-[#8c8c8c] bg-[#f6f4f4] border-b border-[#e6e6e6] rounded-t-xl md:divide-x md:divide-[#e6e6e6]">
<div />
<div></div>
<div className="text-center"></div>
@@ -227,10 +311,10 @@ 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]">
<ul className="divide-y divide-[#bbbbbb]">
{items.map((p) => (
<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">
<li key={p.id} className={`px-4 ${variant === "board" ? "py-3 md:py-4" : "py-4 md:py-4"} hover:bg-neutral-50 transition-colors min-h-[52px] md:min-h-[56px]`}>
<div className="grid grid-cols-1 md:grid-cols-[20px_1fr_120px_120px_80px] items-center gap-2 md:divide-x md:divide-[#eaeaea]">
{/* bullet/공지 아이콘 자리 */}
<div className="hidden md:flex items-center justify-center text-[#f94b37]"></div>

View File

@@ -9,12 +9,23 @@ export async function checkPermission(options: {
const { userId, resource, action } = options;
if (!userId) return false;
const userRoles = await prisma.userRole.findMany({
const user = await prisma.user.findUnique({
where: { userId },
select: { roleId: true },
select: {
authLevel: true,
userRoles: { select: { roleId: true } },
},
});
if (userRoles.length === 0) return false;
const roleIds = userRoles.map((r) => r.roleId);
if (!user) return false;
// 사용자 레코드가 ADMIN 권한이면 모든 리소스/액션 허용
if (user.authLevel === "ADMIN") {
return true;
}
const roleIds = user.userRoles.map((r) => r.roleId);
if (roleIds.length === 0) return false;
const has = await prisma.rolePermission.findFirst({
where: {
roleId: { in: roleIds },
@@ -25,6 +36,7 @@ export async function checkPermission(options: {
select: { id: true },
});
if (has) return true;
// ADMIN.ADMINISTER 이면 모든 리소스/액션 허용
const isAdmin = await prisma.rolePermission.findFirst({
where: {