This commit is contained in:
37
.cursor/.prompt/new.md
Normal file
37
.cursor/.prompt/new.md
Normal 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.이미지 사이즈 미리불러오기
|
||||
@@ -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())
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
92
src/app/api/posts/popular/route.ts
Normal file
92
src/app/api/posts/popular/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user