From a007ac11ce6bca357d165e1464118beab4f95dd6 Mon Sep 17 00:00:00 2001 From: koreacomp5 Date: Sun, 9 Nov 2025 22:05:22 +0900 Subject: [PATCH] test --- middleware.ts | 3 + prisma/schema.prisma | 19 +- prisma/seed.js | 73 +++++-- src/app/admin/boards/page.tsx | 34 ++-- src/app/admin/layout.tsx | 23 ++- src/app/api/attendance/me-stats/route.ts | 66 +++++++ src/app/api/attendance/rankings/route.ts | 83 ++++++++ src/app/api/attendance/route.ts | 40 +++- src/app/api/auth/register/route.ts | 6 +- src/app/api/me/profile-image/route.ts | 31 +++ src/app/boards/[id]/page.tsx | 18 +- src/app/components/AppHeader.tsx | 48 +++-- src/app/components/AttendanceCalendar.tsx | 231 ++++++++++++++++++++++ src/app/components/InlineLoginForm.tsx | 63 ++++++ src/app/components/PartnerScroller.tsx | 36 ++++ src/app/components/ProfileImageEditor.tsx | 63 ++++++ src/app/my-page/page.tsx | 55 ++++-- src/app/page.tsx | 13 +- src/app/register/page.tsx | 36 ++-- src/app/svgs/editicon.svg | 9 + src/lib/validation/auth.ts | 7 - 21 files changed, 845 insertions(+), 112 deletions(-) create mode 100644 middleware.ts create mode 100644 src/app/api/attendance/me-stats/route.ts create mode 100644 src/app/api/attendance/rankings/route.ts create mode 100644 src/app/api/me/profile-image/route.ts create mode 100644 src/app/components/AttendanceCalendar.tsx create mode 100644 src/app/components/InlineLoginForm.tsx create mode 100644 src/app/components/ProfileImageEditor.tsx create mode 100644 src/app/svgs/editicon.svg diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..1b71465 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,3 @@ +export { middleware, config } from "./src/middleware"; + + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2cfdceb..c5713eb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -229,8 +229,8 @@ model User { nickname String @unique passwordHash String? name String - birth DateTime - phone String @unique + birth DateTime? + phone String? @unique rank Int @default(0) // 누적 포인트, 레벨, 등급(0~10) points Int @default(0) @@ -259,6 +259,7 @@ model User { blocksInitiated Block[] @relation("Blocker") blocksReceived Block[] @relation("Blocked") pointTxns PointTransaction[] + attendances Attendance[] sanctions Sanction[] nicknameChanges NicknameChange[] passwordResetTokens PasswordResetToken[] @relation("PasswordResetUser") @@ -506,6 +507,20 @@ model PointTransaction { @@map("point_transactions") } +// 출석부 기록 (사용자별 일자 단위 출석) +model Attendance { + id String @id @default(cuid()) + userId String + date DateTime // 자정 기준 날짜만 사용 + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [userId], onDelete: Cascade) + + @@unique([userId, date]) + @@index([userId, date]) + @@map("attendance") +} + // 레벨 임계치(선택) model LevelThreshold { id String @id @default(cuid()) diff --git a/prisma/seed.js b/prisma/seed.js index 5ed6f48..188c230 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -270,6 +270,25 @@ async function upsertAdmin() { return admin; } +async function seedAdminAttendance(admin) { + try { + const now = new Date(); + const year = now.getUTCFullYear(); + const days = [1, 2, 5, 6]; // 11월 1,2,5,6일 + for (const d of days) { + const date = new Date(Date.UTC(year, 10, d, 0, 0, 0, 0)); // 10 = November (0-based) + // @@unique([userId, date]) 기준으로 업서트 + await prisma.attendance.upsert({ + where: { userId_date: { userId: admin.userId, date } }, + update: {}, + create: { userId: admin.userId, date }, + }); + } + } catch (e) { + console.warn("seedAdminAttendance failed:", e); + } +} + async function upsertBoards(admin, categoryMap) { const boards = [ // 일반 @@ -292,11 +311,12 @@ async function upsertBoards(admin, categoryMap) { ]; const created = []; - // 특수 랭킹/텍스트/미리보기 뷰 타입 ID 조회 (사전에 upsertViewTypes로 생성됨) + // 텍스트/특수랭킹/특수출석 뷰 타입 ID 조회 (사전에 upsertViewTypes로 생성됨) + const mainText = await prisma.boardViewType.findUnique({ where: { key: "main_text" } }); + const listText = await prisma.boardViewType.findUnique({ where: { key: "list_text" } }); const mainSpecial = await prisma.boardViewType.findUnique({ where: { key: "main_special_rank" } }); const listSpecial = await prisma.boardViewType.findUnique({ where: { key: "list_special_rank" } }); - const mainText = await prisma.boardViewType.findUnique({ where: { key: "main_text" } }); - const mainPreview = await prisma.boardViewType.findUnique({ where: { key: "main_preview" } }); + const listSpecialAttendance = await prisma.boardViewType.findUnique({ where: { key: "list_special_attendance" } }); for (const b of boards) { // 카테고리 매핑 규칙 (트리 기준 상위 카테고리) @@ -330,13 +350,12 @@ async function upsertBoards(admin, categoryMap) { allowAnonymousPost: !!b.allowAnonymousPost, readLevel: b.readLevel || undefined, categoryId: category ? category.id : undefined, - // 메인뷰: 기본이 아니라 텍스트(main_text)로 설정, 랭킹 보드는 특수 랭킹 유지, TEST는 미리보기 - ...(b.slug === "ranking" - ? (mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}) - : b.slug === "test" - ? (mainPreview ? { mainPageViewTypeId: mainPreview.id } : {}) - : (mainText ? { mainPageViewTypeId: mainText.id } : {})), + // 기본은 텍스트, 'ranking'은 특수랭킹, 'attendance'는 특수출석으로 오버라이드 + ...(mainText ? { mainPageViewTypeId: mainText.id } : {}), + ...(listText ? { listViewTypeId: listText.id } : {}), + ...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}), ...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}), + ...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}), }, create: { name: b.name, @@ -346,13 +365,12 @@ async function upsertBoards(admin, categoryMap) { allowAnonymousPost: !!b.allowAnonymousPost, readLevel: b.readLevel || undefined, categoryId: category ? category.id : undefined, - // 메인뷰: 기본이 아니라 텍스트(main_text)로 설정, 랭킹 보드는 특수 랭킹 유지, TEST는 미리보기 - ...(b.slug === "ranking" - ? (mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}) - : b.slug === "test" - ? (mainPreview ? { mainPageViewTypeId: mainPreview.id } : {}) - : (mainText ? { mainPageViewTypeId: mainText.id } : {})), + // 기본은 텍스트, 'ranking'은 특수랭킹, 'attendance'는 특수출석으로 오버라이드 + ...(mainText ? { mainPageViewTypeId: mainText.id } : {}), + ...(listText ? { listViewTypeId: listText.id } : {}), + ...(b.slug === "ranking" && mainSpecial ? { mainPageViewTypeId: mainSpecial.id } : {}), ...(b.slug === "ranking" && listSpecial ? { listViewTypeId: listSpecial.id } : {}), + ...(b.slug === "attendance" && listSpecialAttendance ? { listViewTypeId: listSpecialAttendance.id } : {}), }, }); created.push(board); @@ -373,16 +391,15 @@ async function upsertBoards(admin, categoryMap) { async function upsertViewTypes() { const viewTypes = [ - // main scope - { key: "main_default", name: "기본", scope: "main" }, + // main scope (기본/없음 제거, 텍스트 중심) { key: "main_text", name: "텍스트", scope: "main" }, { key: "main_preview", name: "미리보기", scope: "main" }, { key: "main_special_rank", name: "특수랭킹", scope: "main" }, - // list scope - { key: "list_default", name: "기본", scope: "list" }, + // list scope (기본/없음 제거, 텍스트 중심) { key: "list_text", name: "텍스트", scope: "list" }, { key: "list_preview", name: "미리보기", scope: "list" }, { key: "list_special_rank", name: "특수랭킹", scope: "list" }, + { key: "list_special_attendance", name: "특수출석", scope: "list" }, ]; for (const vt of viewTypes) { await prisma.boardViewType.upsert({ @@ -404,6 +421,8 @@ async function createPostsForAllBoards(boards, countPerBoard = 100, admin) { const users = await prisma.user.findMany({ select: { userId: true } }); const userIds = users.map((u) => u.userId); for (const board of boards) { + // 회원랭킹 보드는 특수랭킹용이라 게시글을 시드하지 않습니다. + if (board.slug === "ranking") continue; const data = []; for (let i = 0; i < countPerBoard; i++) { const authorId = ["notice", "bug-report"].includes(board.slug) @@ -530,6 +549,21 @@ async function main() { ); await prisma.$executeRawUnsafe("CREATE INDEX IF NOT EXISTS idx_partner_categories_sortOrder ON partner_categories(sortOrder)"); } + // Attendance 테이블 보장 (마이그레이션 미실행 환경 대응) + const att = await prisma.$queryRaw`SELECT name FROM sqlite_master WHERE type='table' AND name='attendance'`; + if (!Array.isArray(att) || att.length === 0) { + console.log("Creating missing table: attendance"); + await prisma.$executeRawUnsafe( + "CREATE TABLE IF NOT EXISTS attendance (\n" + + "id TEXT PRIMARY KEY,\n" + + "userId TEXT NOT NULL,\n" + + "date DATETIME NOT NULL,\n" + + "createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP\n" + + ")" + ); + await prisma.$executeRawUnsafe("CREATE UNIQUE INDEX IF NOT EXISTS idx_attendance_user_date ON attendance(userId, date)"); + await prisma.$executeRawUnsafe("CREATE INDEX IF NOT EXISTS idx_attendance_date ON attendance(date)"); + } const cols = await prisma.$queryRaw`PRAGMA table_info('partners')`; const hasCategoryId = Array.isArray(cols) && cols.some((c) => (c.name || c.COLUMN_NAME) === 'categoryId'); if (!hasCategoryId) { @@ -566,6 +600,7 @@ async function main() { await createRandomUsers(100); await removeNonPrimaryBoards(); const boards = await upsertBoards(admin, categoryMap); + await seedAdminAttendance(admin); await seedMainpageVisibleBoards(boards); await createPostsForAllBoards(boards, 100, admin); await seedPartnerShops(); diff --git a/src/app/admin/boards/page.tsx b/src/app/admin/boards/page.tsx index f404a38..8ac48b2 100644 --- a/src/app/admin/boards/page.tsx +++ b/src/app/admin/boards/page.tsx @@ -33,6 +33,8 @@ export default function AdminBoardsPage() { const listTypes = useMemo(() => viewTypes.filter((v: any) => v.scope === 'list'), [viewTypes]); const defaultMainTypeId = useMemo(() => (mainTypes.find((t: any) => t.key === 'main_default' || t.key === 'default')?.id ?? null), [mainTypes]); const defaultListTypeId = useMemo(() => (listTypes.find((t: any) => t.key === 'list_default' || t.key === 'default')?.id ?? null), [listTypes]); + const textMainTypeId = useMemo(() => (mainTypes.find((t: any) => t.key === 'main_text')?.id ?? null), [mainTypes]); + const textListTypeId = useMemo(() => (listTypes.find((t: any) => t.key === 'list_text')?.id ?? null), [listTypes]); const categories = useMemo(() => { const merged = rawCategories.map((c: any) => ({ ...c, ...(dirtyCats[c.id] ?? {}) })); return merged.sort((a: any, b: any) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)); @@ -237,7 +239,7 @@ export default function AdminBoardsPage() { return; } const sortOrder = (currentItems?.length ?? 0) + 1; - await fetch(`/api/admin/boards`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, slug, categoryId: catId === "uncat" ? null : catId, sortOrder, status: "active", mainPageViewTypeId: defaultMainTypeId, listViewTypeId: defaultListTypeId }) }); + await fetch(`/api/admin/boards`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, slug, categoryId: catId === "uncat" ? null : catId, sortOrder, status: "active", mainPageViewTypeId: textMainTypeId, listViewTypeId: textListTypeId }) }); await mutateBoards(); } @@ -436,12 +438,20 @@ export default function AdminBoardsPage() { function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, mainTypes, listTypes, onAddType, defaultMainTypeId, defaultListTypeId }: { b: any; onDirty: (id: string, draft: any) => void; onDelete: () => void; allowMove?: boolean; categories?: any[]; onMove?: (toId: string) => void; mainTypes?: any[]; listTypes?: any[]; onAddType?: (scope: 'main'|'list') => Promise; defaultMainTypeId?: string | null; defaultListTypeId?: string | null }) { const [edit, setEdit] = useState(b); - const effectiveMainTypeId = edit.mainPageViewTypeId ?? defaultMainTypeId ?? ''; - const effectiveListTypeId = edit.listViewTypeId ?? defaultListTypeId ?? ''; + // 선택 가능 옵션에서 '기본' 타입은 제외 + const selectableMainTypes = (mainTypes ?? []).filter((t: any) => t.id !== defaultMainTypeId); + const selectableListTypes = (listTypes ?? []).filter((t: any) => t.id !== defaultListTypeId); + // 표시 값: 현재 값이 선택 가능 목록에 없으면 첫 번째 항목을 사용 + const effectiveMainTypeId = selectableMainTypes.some((t: any) => t.id === edit.mainPageViewTypeId) + ? edit.mainPageViewTypeId + : (selectableMainTypes[0]?.id ?? ''); + const effectiveListTypeId = selectableListTypes.some((t: any) => t.id === edit.listViewTypeId) + ? edit.listViewTypeId + : (selectableListTypes[0]?.id ?? ''); return ( <> - { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(b.id, v); }} /> - { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(b.id, v); }} /> + { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(b.id, v); }} /> + { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(b.id, v); }} /> @@ -473,12 +482,11 @@ function BoardRowCells({ b, onDirty, onDelete, allowMove, categories, onMove, ma e.currentTarget.value = id ?? ''; return; } - const v = { ...edit, listViewTypeId: e.target.value || null }; + const v = { ...edit, listViewTypeId: e.target.value }; setEdit(v); onDirty(b.id, v); }} > - - {(listTypes ?? []).map((t: any) => ())} + {(selectableListTypes ?? []).map((t: any) => ())} @@ -551,8 +559,8 @@ function CategoryHeaderContent({ g, onDirty }: { g: any; onDirty: (payload: any) return ( <>
- { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(v); }} /> - { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(v); }} /> + { const v = { ...edit, name: e.target.value }; setEdit(v); onDirty(v); }} /> + { const v = { ...edit, slug: e.target.value }; setEdit(v); onDirty(v); }} />
); diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index c9d88d8..5c7d04c 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -1,11 +1,32 @@ import type { Metadata } from "next"; import AdminSidebar from "@/app/admin/AdminSidebar"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; export const metadata: Metadata = { title: "Admin | ASSM", }; -export default function AdminLayout({ children }: { children: React.ReactNode }) { +export default async function AdminLayout({ children }: { children: React.ReactNode }) { + // 서버에서 쿠키 기반 접근 제어 (미들웨어 보조) + const h = await headers(); + const cookieHeader = h.get("cookie") || ""; + const uid = cookieHeader + .split(";") + .map((s) => s.trim()) + .find((pair) => pair.startsWith("uid=")) + ?.split("=")[1]; + const isAdmin = cookieHeader + .split(";") + .map((s) => s.trim()) + .find((pair) => pair.startsWith("isAdmin=")) + ?.split("=")[1]; + if (!uid) { + redirect("/login"); + } + if (isAdmin !== "1") { + redirect("/"); + } return (
diff --git a/src/app/api/attendance/me-stats/route.ts b/src/app/api/attendance/me-stats/route.ts new file mode 100644 index 0000000..6d12113 --- /dev/null +++ b/src/app/api/attendance/me-stats/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { getUserIdFromRequest } from "@/lib/auth"; + +function toYmdUTC(d: Date): string { + const yy = d.getUTCFullYear(); + const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(d.getUTCDate()).padStart(2, "0"); + return `${yy}-${mm}-${dd}`; +} + +export async function GET(req: Request) { + const userId = getUserIdFromRequest(req); + if (!userId) return NextResponse.json({ total: 0, currentStreak: 0, maxStreak: 0 }); + + // 총 출석일 + const total = await prisma.attendance.count({ where: { userId } }); + + // 모든 출석일(UTC 자정 기준) 가져와서 streak 계산 + const rows = await prisma.attendance.findMany({ + where: { userId }, + select: { date: true }, + orderBy: { date: "asc" }, + }); + const days = Array.from(new Set(rows.map((r) => toYmdUTC(new Date(r.date))))); // unique, asc + + // 현재 연속 출석 + let currentStreak = 0; + if (days.length > 0) { + const set = new Set(days); + const now = new Date(); + let cursor = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0)); + while (true) { + const ymd = toYmdUTC(cursor); + if (set.has(ymd)) { + currentStreak += 1; + cursor.setUTCDate(cursor.getUTCDate() - 1); + } else { + break; + } + } + } + + // 최대 연속 출석 + let maxStreak = 0; + if (days.length > 0) { + // scan through sorted list + let localMax = 1; + for (let i = 1; i < days.length; i++) { + const prev = new Date(days[i - 1] + "T00:00:00.000Z"); + const cur = new Date(days[i] + "T00:00:00.000Z"); + const diff = (cur.getTime() - prev.getTime()) / (24 * 60 * 60 * 1000); + if (diff === 1) { + localMax += 1; + } else if (diff > 1) { + if (localMax > maxStreak) maxStreak = localMax; + localMax = 1; + } + } + if (localMax > maxStreak) maxStreak = localMax; + } + + return NextResponse.json({ total, currentStreak, maxStreak }); +} + + diff --git a/src/app/api/attendance/rankings/route.ts b/src/app/api/attendance/rankings/route.ts new file mode 100644 index 0000000..508fbfc --- /dev/null +++ b/src/app/api/attendance/rankings/route.ts @@ -0,0 +1,83 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; + +function toYmdUTC(d: Date): string { + const yy = d.getUTCFullYear(); + const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(d.getUTCDate()).padStart(2, "0"); + return `${yy}-${mm}-${dd}`; +} + +export async function GET() { + // 전체 출석 누적 상위 (Top 10) + const overallGroups = await prisma.attendance.groupBy({ + by: ["userId"], + _count: { _all: true }, + orderBy: { _count: { _all: "desc" } }, + take: 20, + }); + const overallUserIds = overallGroups.map((g) => g.userId); + const overallUsers = await prisma.user.findMany({ + where: { userId: { in: overallUserIds } }, + select: { userId: true, nickname: true, profileImage: true, grade: true }, + }); + const userMeta = new Map(overallUsers.map((u) => [u.userId, u])); + const overall = overallGroups.map((g) => ({ + userId: g.userId, + nickname: userMeta.get(g.userId)?.nickname ?? "회원", + count: g._count._all, + profileImage: userMeta.get(g.userId)?.profileImage ?? null, + grade: userMeta.get(g.userId)?.grade ?? 0, + })); + + // 연속 출석 상위 (Top 10, 현재 연속 기준) + const now = new Date(); + const todayUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0, 0)); + const past = new Date(todayUTC); + past.setUTCDate(past.getUTCDate() - 60); // 최근 60일 데이터만으로 streak 계산 + const recent = await prisma.attendance.findMany({ + where: { date: { gte: past } }, + select: { userId: true, date: true }, + orderBy: { userId: "asc" }, + }); + const map = new Map>(); + for (const r of recent) { + const set = map.get(r.userId) ?? new Set(); + set.add(toYmdUTC(new Date(r.date))); + map.set(r.userId, set); + } + const streakArr: { userId: string; streak: number }[] = []; + for (const [userId, set] of map.entries()) { + let streak = 0; + let cursor = new Date(todayUTC); + while (streak < 60) { + const ymd = toYmdUTC(cursor); + if (set.has(ymd)) { + streak += 1; + cursor.setUTCDate(cursor.getUTCDate() - 1); + } else { + break; + } + } + if (streak > 0) streakArr.push({ userId, streak }); + } + streakArr.sort((a, b) => b.streak - a.streak); + const topStreak = streakArr.slice(0, 20); + const streakUserIds = topStreak.map((s) => s.userId); + const streakUsers = await prisma.user.findMany({ + where: { userId: { in: streakUserIds } }, + select: { userId: true, nickname: true, profileImage: true, grade: true }, + }); + const streakMeta = new Map(streakUsers.map((u) => [u.userId, u])); + const streak = topStreak.map((s) => ({ + userId: s.userId, + nickname: streakMeta.get(s.userId)?.nickname ?? "회원", + streak: s.streak, + profileImage: streakMeta.get(s.userId)?.profileImage ?? null, + grade: streakMeta.get(s.userId)?.grade ?? 0, + })); + + return NextResponse.json({ overall, streak }); +} + + diff --git a/src/app/api/attendance/route.ts b/src/app/api/attendance/route.ts index 8353c25..e2be37f 100644 --- a/src/app/api/attendance/route.ts +++ b/src/app/api/attendance/route.ts @@ -4,13 +4,38 @@ import { getUserIdFromRequest } from "@/lib/auth"; export async function GET(req: Request) { const userId = getUserIdFromRequest(req); - if (!userId) return NextResponse.json({ today: null, count: 0 }); + if (!userId) return NextResponse.json({ today: null, count: 0, days: [] }); + const url = new URL(req.url); + const year = url.searchParams.get("year"); + const month = url.searchParams.get("month"); // 1-12 const start = new Date(); start.setHours(0,0,0,0); const end = new Date(); end.setHours(23,59,59,999); - const today = await prisma.pointTransaction.findFirst({ - where: { userId, reason: "attendance", createdAt: { gte: start, lte: end } }, + const today = await prisma.attendance.findFirst({ + where: { userId, date: { gte: start, lte: end } }, }); - const count = await prisma.pointTransaction.count({ where: { userId, reason: "attendance" } }); + const count = await prisma.attendance.count({ where: { userId } }); + // 월별 출석 일자 목록 + if (year && month) { + const y = parseInt(year, 10); + const m = parseInt(month, 10); + if (!isNaN(y) && !isNaN(m) && m >= 1 && m <= 12) { + const firstDay = new Date(Date.UTC(y, m - 1, 1, 0, 0, 0, 0)); + const lastDay = new Date(Date.UTC(y, m, 0, 23, 59, 59, 999)); + const records = await prisma.attendance.findMany({ + where: { userId, date: { gte: firstDay, lte: lastDay } }, + select: { date: true }, + orderBy: { date: "asc" }, + }); + const days = records.map(r => { + const d = new Date(r.date); + const yy = d.getUTCFullYear(); + const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(d.getUTCDate()).padStart(2, "0"); + return `${yy}-${mm}-${dd}`; + }); + return NextResponse.json({ today: !!today, count, days }); + } + } return NextResponse.json({ today: !!today, count }); } @@ -19,9 +44,12 @@ export async function POST(req: Request) { if (!userId) return NextResponse.json({ error: "login required" }, { status: 401 }); const start = new Date(); start.setHours(0,0,0,0); const end = new Date(); end.setHours(23,59,59,999); - const exists = await prisma.pointTransaction.findFirst({ where: { userId, reason: "attendance", createdAt: { gte: start, lte: end } } }); + const exists = await prisma.attendance.findFirst({ where: { userId, date: { gte: start, lte: end } } }); if (exists) return NextResponse.json({ ok: true, duplicated: true }); - await prisma.pointTransaction.create({ data: { userId, amount: 10, reason: "attendance" } }); + // normalize to UTC midnight + const now = new Date(); + const normalized = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0)); + await prisma.attendance.create({ data: { userId, date: normalized } }); return NextResponse.json({ ok: true }); } diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index f35ab16..2c3c376 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -8,15 +8,13 @@ export async function POST(req: Request) { const parsed = registerSchema.safeParse(body); if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); - const { nickname, name, phone, birth, password } = parsed.data; - const exists = await prisma.user.findFirst({ where: { OR: [{ nickname }, { phone }] } }); + const { nickname, name, password } = parsed.data; + const exists = await prisma.user.findFirst({ where: { nickname } }); if (exists) return NextResponse.json({ error: "이미 존재하는 사용자" }, { status: 409 }); const user = await prisma.user.create({ data: { nickname, name, - phone, - birth: new Date(birth), passwordHash: hashPassword(password), agreementTermsAt: new Date(), }, diff --git a/src/app/api/me/profile-image/route.ts b/src/app/api/me/profile-image/route.ts new file mode 100644 index 0000000..e6ed67a --- /dev/null +++ b/src/app/api/me/profile-image/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { getUserIdOrAdmin } from "@/lib/auth"; + +export async function PUT(req: Request) { + const userId = await getUserIdOrAdmin(req); + if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + try { + const body = await req.json(); + const url: string | null = body?.url ?? null; + if (url !== null && typeof url !== "string") { + return NextResponse.json({ error: "invalid url" }, { status: 400 }); + } + // 간단 검증: 내부 업로드 경로 또는 http(s) 허용 + if (url) { + const ok = url.startsWith("/uploads/") || url.startsWith("http://") || url.startsWith("https://"); + if (!ok) return NextResponse.json({ error: "invalid url" }, { status: 400 }); + if (url.length > 1000) return NextResponse.json({ error: "url too long" }, { status: 400 }); + } + const user = await prisma.user.update({ + where: { userId }, + data: { profileImage: url || null }, + select: { userId: true, profileImage: true }, + }); + return NextResponse.json({ ok: true, user }); + } catch { + return NextResponse.json({ error: "Bad Request" }, { status: 400 }); + } +} + + diff --git a/src/app/boards/[id]/page.tsx b/src/app/boards/[id]/page.tsx index cc3eda6..d044ed7 100644 --- a/src/app/boards/[id]/page.tsx +++ b/src/app/boards/[id]/page.tsx @@ -9,6 +9,7 @@ import { RankIcon1st } from "@/app/components/RankIcon1st"; import { RankIcon2nd } from "@/app/components/RankIcon2nd"; import { RankIcon3rd } from "@/app/components/RankIcon3rd"; import { GradeIcon } from "@/app/components/GradeIcon"; +import AttendanceCalendar from "@/app/components/AttendanceCalendar"; // Next 15: params/searchParams가 Promise가 될 수 있어 안전 언랩 처리합니다. export default async function BoardDetail({ params, searchParams }: { params: any; searchParams: any }) { @@ -21,6 +22,14 @@ export default async function BoardDetail({ params, searchParams }: { params: an const host = h.get("host") ?? "localhost:3000"; const proto = h.get("x-forwarded-proto") ?? "http"; const base = process.env.NEXT_PUBLIC_BASE_URL || `${proto}://${host}`; + // 로그인 여부 파악 + const cookieHeader = h.get("cookie") || ""; + const uid = cookieHeader + .split(";") + .map((s) => s.trim()) + .find((pair) => pair.startsWith("uid=")) + ?.split("=")[1]; + const isLoggedIn = !!uid; const res = await fetch(new URL("/api/boards", base).toString(), { cache: "no-store" }); const { boards } = await res.json(); const board = (boards || []).find((b: any) => b.slug === idOrSlug || b.id === idOrSlug); @@ -33,12 +42,13 @@ export default async function BoardDetail({ params, searchParams }: { params: an const parsed = settingRow ? JSON.parse(settingRow.value as string) : {}; const showBanner: boolean = parsed.showBanner ?? true; - // 리스트 뷰 타입 확인 (특수랭킹일 경우 게시글 대신 랭킹 노출) + // 리스트 뷰 타입 확인 (특수랭킹/출석부 등) const boardView = await prisma.board.findUnique({ where: { id }, select: { listViewType: { select: { key: true } } }, }); const isSpecialRanking = boardView?.listViewType?.key === "list_special_rank"; + const isAttendance = boardView?.listViewType?.key === "list_special_attendance"; let rankingItems: { userId: string; nickname: string; points: number; profileImage: string | null; grade: number }[] = []; if (isSpecialRanking) { @@ -84,7 +94,7 @@ export default async function BoardDetail({ params, searchParams }: { params: an {/* 검색/필터 툴바 + 리스트 */}
- {!isSpecialRanking && } + {!isSpecialRanking && !isAttendance && }
{isSpecialRanking ? (
@@ -128,6 +138,10 @@ export default async function BoardDetail({ params, searchParams }: { params: an
랭킹 데이터가 없습니다.
)}
+ ) : isAttendance ? ( +
+ +
) : (
-
+
- {authData?.user && ( - - )} - {!authData?.user && ( + {authData?.user ? ( + <> + {/* 인사 + 로그아웃을 하나의 배지로 묶어 자연스럽게 표시 */} +
+ + {authData.user.nickname}님 안녕하세요 + + + +
+ + ) : ( 로그인 diff --git a/src/app/components/AttendanceCalendar.tsx b/src/app/components/AttendanceCalendar.tsx new file mode 100644 index 0000000..657c889 --- /dev/null +++ b/src/app/components/AttendanceCalendar.tsx @@ -0,0 +1,231 @@ +"use client"; + +import React from "react"; +import useSWR from "swr"; + +function getMonthRange(date: Date) { + const y = date.getFullYear(); + const m = date.getMonth(); // 0-based + const first = new Date(y, m, 1); + const last = new Date(y, m + 1, 0); + return { y, m, first, last }; +} + +export default function AttendanceCalendar({ isLoggedIn = true }: { isLoggedIn?: boolean }) { + const [current, setCurrent] = React.useState(new Date()); + const [days, setDays] = React.useState([]); + const [todayChecked, setTodayChecked] = React.useState(null); + const [loading, setLoading] = React.useState(false); + + const { y, m } = getMonthRange(current); + const ymKey = `${y}-${String(m + 1).padStart(2, "0")}`; + const today = React.useMemo(() => { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + }, []); + + React.useEffect(() => { + let cancelled = false; + (async () => { + if (!isLoggedIn) { + setDays([]); + setTodayChecked(null); + return; + } + setLoading(true); + try { + const res = await fetch(`/api/attendance?year=${y}&month=${m + 1}`); + const data = await res.json().catch(() => ({})); + if (!cancelled) { + setDays(Array.isArray(data?.days) ? data.days : []); + setTodayChecked(!!data?.today); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [y, m, ymKey, isLoggedIn]); + + const goPrev = () => setCurrent(new Date(current.getFullYear(), current.getMonth() - 1, 1)); + const goNext = () => setCurrent(new Date(current.getFullYear(), current.getMonth() + 1, 1)); + + const handleCheckIn = async () => { + if (!isLoggedIn) return; + try { + const res = await fetch("/api/attendance", { method: "POST" }); + const data = await res.json().catch(() => ({})); + if (res.ok && !data?.duplicated) { + // 오늘 날짜 문자열 + const d = new Date(); + const s = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + setDays((prev) => prev.includes(s) ? prev : [...prev, s]); + setTodayChecked(true); + } else { + setTodayChecked(true); + } + } catch { + // ignore + } + }; + + // 캘린더 데이터 생성 + const firstOfMonth = new Date(y, m, 1); + const startWeekday = firstOfMonth.getDay(); // 0:Sun + const lastOfMonth = new Date(y, m + 1, 0).getDate(); + const weeks: Array> = []; + let day = 1; + for (let w = 0; w < 6; w++) { + const row: any[] = []; + for (let wd = 0; wd < 7; wd++) { + if (w === 0 && wd < startWeekday) { + row.push({ d: null }); + } else if (day > lastOfMonth) { + row.push({ d: null }); + } else { + const dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + row.push({ d: day, dateStr, checked: days.includes(dateStr) }); + day++; + } + } + weeks.push(row); + if (day > lastOfMonth) break; + } + + return ( +
+
+
+ +
+ {y}년 {m + 1}월 +
+ +
+ + + + {["일","월","화","수","목","금","토"].map((h) => ())} + + + + {weeks.map((row, i) => ( + + {row.map((cell, j) => ( + + ))} + + ))} + +
{h}
+ {cell.d ? ( + (() => { + const isToday = cell.dateStr === today; + const base = "mx-auto w-8 h-8 leading-8 rounded-full"; + const color = cell.checked ? " bg-[#F94B37] text-white" : " text-neutral-700"; + const ring = isToday ? (cell.checked ? " ring-2 ring-offset-2 ring-[#F94B37]" : " ring-2 ring-[#F94B37]") : ""; + return ( +
+ {cell.d} +
+ ); + })() + ) : ( +
+ )} +
+
+ + + 출석일 + + + + 오늘 + +
+
+ +
+ {!isLoggedIn && ( +
+
로그인이 필요합니다
+
+ )} +
+ {/* 내 출석 통계 */} + {isLoggedIn && } + +
+ ); +} + +function MyAttendanceStats() { + const { data } = useSWR<{ total: number; currentStreak: number; maxStreak: number }>( + "/api/attendance/me-stats", + (u) => fetch(u).then((r) => r.json()) + ); + const total = data?.total ?? 0; + const current = data?.currentStreak ?? 0; + const max = data?.maxStreak ?? 0; + return ( +
+
+
내 출석일수
+
{total}
+
+
+
연속출석
+
{current}일
+
+
+
최대 연속
+
{max}일
+
+
+ ); +} + +function Rankings() { + const { data } = useSWR<{ overall: any[]; streak: any[] }>("/api/attendance/rankings", (u) => fetch(u).then((r) => r.json())); + const overall = data?.overall ?? []; + const streak = data?.streak ?? []; + return ( +
+
+
연속출석 순위
+
    + {streak.length === 0 &&
  1. 데이터가 없습니다.
  2. } + {streak.map((u, idx) => ( +
  3. + {idx + 1} + {u.nickname} + {u.streak}일 +
  4. + ))} +
+
+
+
전체 출석 순위
+
    + {overall.length === 0 &&
  1. 데이터가 없습니다.
  2. } + {overall.map((u, idx) => ( +
  3. + {idx + 1} + {u.nickname} + {u.count}회 +
  4. + ))} +
+
+
+ ); +} + + diff --git a/src/app/components/InlineLoginForm.tsx b/src/app/components/InlineLoginForm.tsx new file mode 100644 index 0000000..0bcc3c4 --- /dev/null +++ b/src/app/components/InlineLoginForm.tsx @@ -0,0 +1,63 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; + +export function InlineLoginForm({ next = "/" }: { next?: string }) { + const [nickname, setNickname] = React.useState(""); + const [password, setPassword] = React.useState(""); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + try { + const res = await fetch("/api/auth/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nickname, password }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error || "로그인 실패"); + // 성공 시 다음 경로로 이동 (기본은 홈) + window.location.href = next || "/"; + } catch (err: any) { + setError(err?.message || "로그인 실패"); + } finally { + setLoading(false); + } + }; + + return ( +
+ setNickname(e.target.value)} + className="h-10 rounded-md border border-neutral-300 px-3 text-sm" + /> + setPassword(e.target.value)} + className="h-10 rounded-md border border-neutral-300 px-3 text-sm" + /> + {error &&
{error}
} + +
+ 회원가입 +
+
+ ); +} + + diff --git a/src/app/components/PartnerScroller.tsx b/src/app/components/PartnerScroller.tsx index 4d468c7..af27296 100644 --- a/src/app/components/PartnerScroller.tsx +++ b/src/app/components/PartnerScroller.tsx @@ -56,6 +56,42 @@ export default function PartnerScroller() { ? partners.map((p: any) => ({ id: p.id, region: p.address ? String(p.address).split(" ")[0] : p.category, name: p.name, address: p.address || "", image: p.imageUrl || "/sample.jpg" })) : fallbackItems; + const isLoading = !partnersData && (!reqData || partners.length === 0); + + if (isLoading) { + // 스켈레톤: 실제 카드와 동일 사이즈(384x308)로 5~6개 표시 + const skeletons = Array.from({ length: 6 }).map((_, i) => i); + return ( +
+
+
+ {skeletons.map((i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+
+
+ ); + } + if (items.length === 0) return null; return ; } diff --git a/src/app/components/ProfileImageEditor.tsx b/src/app/components/ProfileImageEditor.tsx new file mode 100644 index 0000000..2b0e991 --- /dev/null +++ b/src/app/components/ProfileImageEditor.tsx @@ -0,0 +1,63 @@ +"use client"; +import React from "react"; +import { UploadButton } from "@/app/components/UploadButton"; +import { useToast } from "@/app/components/ui/ToastProvider"; + +type Props = { + initialUrl?: string | null; +}; + +export function ProfileImageEditor({ initialUrl }: Props) { + const { show } = useToast(); + const [url, setUrl] = React.useState(initialUrl || ""); + const [saving, setSaving] = React.useState(false); + + async function save(newUrl: string | null) { + try { + setSaving(true); + const res = await fetch("/api/me/profile-image", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: newUrl }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error || "저장 실패"); + show("프로필 이미지가 저장되었습니다"); + setUrl(newUrl || ""); + } catch (e: any) { + show(e.message || "저장 실패"); + } finally { + setSaving(false); + } + } + + return ( +
+
+ { + // 정사각 권장 + save(u); + }} + aspectRatio={1} + maxWidth={512} + maxHeight={512} + quality={0.9} + /> + {url ? ( + + ) : null} +
+ {url ? {url} : null} +
+ ); +} + + diff --git a/src/app/my-page/page.tsx b/src/app/my-page/page.tsx index b567600..fcafd0f 100644 --- a/src/app/my-page/page.tsx +++ b/src/app/my-page/page.tsx @@ -1,4 +1,5 @@ import { headers } from "next/headers"; +import { redirect } from "next/navigation"; import prisma from "@/lib/prisma"; import Link from "next/link"; import { UserAvatar } from "@/app/components/UserAvatar"; @@ -6,6 +7,7 @@ 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"; +import { ProfileImageEditor } from "@/app/components/ProfileImageEditor"; 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"; @@ -26,35 +28,38 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{ try { const h = await headers(); const cookieHeader = h.get("cookie") || ""; + let isAdmin = false; const uid = cookieHeader .split(";") .map((s) => s.trim()) .find((pair) => pair.startsWith("uid=")) ?.split("=")[1]; + const isAdminVal = cookieHeader + .split(";") + .map((s) => s.trim()) + .find((pair) => pair.startsWith("isAdmin=")) + ?.split("=")[1]; + isAdmin = isAdminVal === "1"; + if (!uid) { + redirect(`/login?next=/my-page`); + } 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; + if (user) { + currentUser = { ...user, isAdmin } as any; + } else { + redirect(`/login?next=/my-page`); + } } } 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
로그인이 필요합니다.
; - } + if (!currentUser) redirect(`/login?next=/my-page`); // 통계 정보 가져오기 const [postsCount, commentsCount, receivedMessagesCount, sentMessagesCount] = await Promise.all([ @@ -84,7 +89,12 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
{/* 히어로 배너 */}
- +
{/* 프로필 섹션 (모바일 대응) */} @@ -114,12 +124,25 @@ export default async function MyPage({ searchParams }: { searchParams: Promise<{
{currentUser.nickname || "사용자"}
+ {/* 관리자 설정 버튼 */} + {"isAdmin" in (currentUser as any) && (currentUser as any).isAdmin && ( +
+ + 관리자설정 + +
+ )}
+ {/* 프로필 이미지 편집 */} + {/* 레벨/등급/포인트 정보 - 가로 배치 */}
diff --git a/src/app/page.tsx b/src/app/page.tsx index 3eddf26..6d4bde7 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -15,6 +15,7 @@ import { BoardPanelClient } from "@/app/components/BoardPanelClient"; import { GradeIcon, getGradeName } from "@/app/components/GradeIcon"; import prisma from "@/lib/prisma"; import { headers } from "next/headers"; +import { InlineLoginForm } from "@/app/components/InlineLoginForm"; export default async function Home({ searchParams }: { searchParams: Promise<{ sort?: "recent" | "popular" } | undefined> }) { const sp = await searchParams; @@ -292,15 +293,9 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
) : ( -
-
-
-
로그인이 필요합니다
-
내 정보와 등급/포인트를 확인하려면 로그인하세요.
-
- - 로그인 하러 가기 - +
+
로그인이 필요합니다
+
)}
diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index c9c6847..70ebf4c 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -8,8 +8,6 @@ export default function RegisterPage() { const [form, setForm] = React.useState({ nickname: "", name: "", - phone: "", - birth: "", password: "", confirmPassword: "", agreeTerms: false, @@ -49,22 +47,14 @@ export default function RegisterPage() {

회원가입

- + {errors.nickname?.length ? ( {errors.nickname[0]} ) : null} - + {errors.name?.length ? ( {errors.name[0]} ) : null} - - {errors.phone?.length ? ( - {errors.phone[0]} - ) : null} - - {errors.birth?.length ? ( - {errors.birth[0]} - ) : null} {errors.password?.length ? ( {errors.password[0]} @@ -73,9 +63,25 @@ export default function RegisterPage() { {errors.confirmPassword?.length ? ( {errors.confirmPassword[0]} ) : null} - +
+