diff --git "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20401.png" "b/service/app/public/profiles/\355\224\204\353\241\234\355\225\20401.png" deleted file mode 100644 index 79e7cef0..00000000 Binary files "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20401.png" and /dev/null differ diff --git "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20402.png" "b/service/app/public/profiles/\355\224\204\353\241\234\355\225\20402.png" deleted file mode 100644 index eaf888c0..00000000 Binary files "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20402.png" and /dev/null differ diff --git "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20403.png" "b/service/app/public/profiles/\355\224\204\353\241\234\355\225\20403.png" deleted file mode 100644 index 9c2b10ff..00000000 Binary files "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20403.png" and /dev/null differ diff --git "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20404.png" "b/service/app/public/profiles/\355\224\204\353\241\234\355\225\20404.png" deleted file mode 100644 index 26892e49..00000000 Binary files "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20404.png" and /dev/null differ diff --git "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20405.png" "b/service/app/public/profiles/\355\224\204\353\241\234\355\225\20405.png" deleted file mode 100644 index fd71efa6..00000000 Binary files "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20405.png" and /dev/null differ diff --git "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20406.png" "b/service/app/public/profiles/\355\224\204\353\241\234\355\225\20406.png" deleted file mode 100644 index e04287e7..00000000 Binary files "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20406.png" and /dev/null differ diff --git "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20407.png" "b/service/app/public/profiles/\355\224\204\353\241\234\355\225\20407.png" deleted file mode 100644 index 8bcfcedd..00000000 Binary files "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20407.png" and /dev/null differ diff --git "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20408.png" "b/service/app/public/profiles/\355\224\204\353\241\234\355\225\20408.png" deleted file mode 100644 index 21848b9c..00000000 Binary files "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20408.png" and /dev/null differ diff --git "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20409.png" "b/service/app/public/profiles/\355\224\204\353\241\234\355\225\20409.png" deleted file mode 100644 index 24082ea3..00000000 Binary files "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20409.png" and /dev/null differ diff --git "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20410.png" "b/service/app/public/profiles/\355\224\204\353\241\234\355\225\20410.png" deleted file mode 100644 index a4e66b95..00000000 Binary files "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20410.png" and /dev/null differ diff --git "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20411.png" "b/service/app/public/profiles/\355\224\204\353\241\234\355\225\20411.png" deleted file mode 100644 index be4ef295..00000000 Binary files "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20411.png" and /dev/null differ diff --git "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20412.png" "b/service/app/public/profiles/\355\224\204\353\241\234\355\225\20412.png" deleted file mode 100644 index ed43ba45..00000000 Binary files "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20412.png" and /dev/null differ diff --git "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20413.png" "b/service/app/public/profiles/\355\224\204\353\241\234\355\225\20413.png" deleted file mode 100644 index 89872e38..00000000 Binary files "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20413.png" and /dev/null differ diff --git "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20414.png" "b/service/app/public/profiles/\355\224\204\353\241\234\355\225\20414.png" deleted file mode 100644 index 76b969d0..00000000 Binary files "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20414.png" and /dev/null differ diff --git "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20415.png" "b/service/app/public/profiles/\355\224\204\353\241\234\355\225\20415.png" deleted file mode 100644 index 4abc2331..00000000 Binary files "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20415.png" and /dev/null differ diff --git "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20416.png" "b/service/app/public/profiles/\355\224\204\353\241\234\355\225\20416.png" deleted file mode 100644 index a2d44c23..00000000 Binary files "a/service/app/public/profiles/\355\224\204\353\241\234\355\225\20416.png" and /dev/null differ diff --git a/service/app/src/app/admin/_components/adminNavigation.tsx b/service/app/src/app/admin/_components/adminNavigation.tsx new file mode 100644 index 00000000..252807d3 --- /dev/null +++ b/service/app/src/app/admin/_components/adminNavigation.tsx @@ -0,0 +1,65 @@ +import { SecondaryPlainBoxButton } from "@ject-5-fe/design/components/button" +import { Navigation } from "@ject-5-fe/design/components/navigation" +import { cn } from "@ject-5-fe/design/utils/cn" +import Link from "next/link" +import { usePathname } from "next/navigation" + +import { HomeButton } from "@/widgets/components/homeButton" + +export const AdminNavigation = () => { + const pathname = usePathname() + + const isActive = (href: string) => + pathname === href || pathname.startsWith(`${href}/`) + + const getAriaCurrent = (active: boolean) => (active ? "page" : undefined) + + const navItemClass = (active: boolean) => + cn( + "typography-heading-md-semibold", + active + ? "text-text-interactive-secondary" + : "text-text-interactive-tertiary", + ) + return ( + + + + 관리자페이지 + + + } + rightContent={ +
+ + 신고접수 + + + 게임관리 + + + 회원관리 + +
+ } + /> + ) +} diff --git a/service/app/src/app/admin/games/page.tsx b/service/app/src/app/admin/games/page.tsx new file mode 100644 index 00000000..82c7acb3 --- /dev/null +++ b/service/app/src/app/admin/games/page.tsx @@ -0,0 +1,3 @@ +export default function AdminGamesPage() { + return
AdminPage-Games
+} diff --git a/service/app/src/app/admin/layout.tsx b/service/app/src/app/admin/layout.tsx new file mode 100644 index 00000000..31e4e62f --- /dev/null +++ b/service/app/src/app/admin/layout.tsx @@ -0,0 +1,42 @@ +"use client" + +import { useRouter } from "next/navigation" +import { useEffect, useState } from "react" + +import { validateSession } from "@/entities/auth/api/validateSession" + +import { AdminNavigation } from "./_components/adminNavigation" + +export default function AdminLayout({ + children, +}: { + children: React.ReactNode +}) { + const router = useRouter() + const [checked, setChecked] = useState(false) + + useEffect(() => { + validateSession() + .then((user) => { + if (!user || user.data?.role !== "ADMIN") { + router.replace("/") + return + } + setChecked(true) + }) + .catch(() => { + router.replace("/") + }) + }, [router]) + + if (!checked) { + return null + } + + return ( +
+ + {children} +
+ ) +} diff --git a/service/app/src/app/admin/page.tsx b/service/app/src/app/admin/page.tsx new file mode 100644 index 00000000..f02040c3 --- /dev/null +++ b/service/app/src/app/admin/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation" + +export default function AdminPage() { + redirect("/admin/reports") +} diff --git a/service/app/src/app/admin/reports/__tests__/reports.spec.ts b/service/app/src/app/admin/reports/__tests__/reports.spec.ts new file mode 100644 index 00000000..185b104e --- /dev/null +++ b/service/app/src/app/admin/reports/__tests__/reports.spec.ts @@ -0,0 +1,196 @@ +import { expect, test } from "@playwright/test" + +import { ReportsPOM } from "./reportsPOM" + +test.describe("신고접수 페이지", () => { + test.describe("인증 및 권한", () => { + test("비로그인 사용자는 홈으로 리다이렉트 되어야 한다", async ({ + page, + }) => { + await page.goto("/admin/reports") + await page.waitForURL("/") + }) + + // test("로그인 사용자라도 ADMIN이 아닌 경우 홈으로 리다이렉트 되어야 한다", async ({ + // page, + // }) => { + // // TODO: USER role로 로그인 상태 설정 필요 + // await page.goto("/admin/reports") + // await page.waitForURL("/") + // }) + }) + + test.describe("초기 UI 요소", () => { + test.use({ storageState: "playwright/.auth/user.json" }) + + let pom: ReportsPOM + + test.beforeEach(async ({ page }) => { + pom = new ReportsPOM(page) + await pom.navigateToReports() + }) + + test("초기 UI 요소가 잘 표시되어야 한다", async () => { + await pom.checkNavigationHighlight() + await pom.checkReportedTable() + }) + }) + + test.describe("네비게이션", () => { + test.use({ storageState: "playwright/.auth/user.json" }) + + let pom: ReportsPOM + + test.beforeEach(async ({ page }) => { + pom = new ReportsPOM(page) + await pom.navigateToReports() + }) + + test("게임 관리 클릭시 게임 관리 페이지로 이동한다", async ({ page }) => { + await pom.clickGamesTab() + await page.waitForURL("/admin/games") + }) + + test("회원 관리 클릭시 회원 관리 페이지로 이동한다", async ({ page }) => { + await pom.clickUsersTab() + await page.waitForURL("/admin/users") + }) + }) + + test.describe("기본 리스트 렌더링", () => { + test.use({ storageState: "playwright/.auth/user.json" }) + + let pom: ReportsPOM + + test.beforeEach(async ({ page }) => { + pom = new ReportsPOM(page) + await pom.navigateToReports() + }) + + test("한 화면에 표시된 row 수가 7개 이하이다", async () => { + await pom.checkRowCount() + }) + + test("각 row에 컬럼이 존재한다 (번호, 게임명, 제작자, 신고자, 신고일자, 처리여부)", async () => { + await pom.checkRowColumns() + }) + }) + + test.describe("신고 상세 팝업: 공통", () => { + test.use({ storageState: "playwright/.auth/user.json" }) + + let pom: ReportsPOM + + test.beforeEach(async ({ page }) => { + pom = new ReportsPOM(page) + await pom.navigateToReports() + }) + + test("신고 항목을 클릭하면 신고 내용 확인 팝업이 표시된다", async () => { + await pom.clickReportedGameRow(0) + await pom.checkReportDetailPopup() + }) + + test("게임 정보가 표시된다 (게임명, 전체 문제 수, 신고 사유)", async () => { + await pom.clickReportedGameRow(0) + await pom.checkGameInfo() + }) + + test("팝업 닫기 버튼 클릭시 팝업이 닫힌다", async () => { + await pom.clickReportedGameRow(0) + await pom.checkReportDetailPopup() + await pom.clickCloseButton() + await expect(pom.reportDetail).not.toBeVisible() + }) + }) + + test.describe("신고 상세 팝업: 삭제되지 않은 게임", () => { + test.use({ storageState: "playwright/.auth/user.json" }) + + let pom: ReportsPOM + + test.beforeEach(async ({ page }) => { + pom = new ReportsPOM(page) + await pom.navigateToReports() + }) + + test("문제 목록이 캐러셀로 노출된다", async () => { + await pom.clickReportedGameRow(0) + await pom.checkCarousel() + }) + + test("삭제되지 않은 게임에서 게임 삭제 클릭시 팝업이 닫히고 상태가 삭제 완료로 업데이트된다", async () => { + await pom.clickReportedGameRow(0) + + const row = pom.reportedGameRows.first() + + await pom.clickDeleteGame() + await expect(pom.reportDetail).not.toBeVisible() + + await expect(row).toHaveAttribute("aria-label", /처리 여부 삭제 완료/) + }) + }) + + test.describe("신고 상세 팝업: 삭제된 게임", () => { + test.use({ storageState: "playwright/.auth/user.json" }) + + let pom: ReportsPOM + + test.beforeEach(async ({ page }) => { + pom = new ReportsPOM(page) + await pom.navigateToReports() + }) + test("삭제된 게임의 경우 삭제 처리된 게임입니다 문구가 표시되고 캐러셀이 노출되지 않는다", async () => { + await pom.clickReportedGameRow(1) + + await pom.checkDeletedGameMessage() + await expect(pom.gameQuestionList).not.toBeVisible() + }) + + test("삭제된 게임 클릭시 신고 무시 버튼과 게임 삭제 버튼이 비활성화된다", async () => { + await pom.clickReportedGameRow(1) + + await pom.checkButtonDisabled(pom.reportIgnoreButton) + await pom.checkButtonDisabled(pom.gameDeleteButton) + }) + }) + + test.describe("신고 무시", () => { + test.use({ storageState: "playwright/.auth/user.json" }) + + let pom: ReportsPOM + + test.beforeEach(async ({ page }) => { + pom = new ReportsPOM(page) + await pom.navigateToReports() + }) + + test("신고 무시 클릭시 팝업이 닫히고 상태가 신고 무시로 업데이트된다", async () => { + await pom.clickReportedGameRow(0) + const row = pom.reportedGameRows.first() + + await pom.clickIgnoreReport() + await expect(pom.reportDetail).not.toBeVisible() + + await expect(row).toHaveAttribute("aria-label", /처리 여부 신고 무시/) + }) + }) + + test.describe("사용자 차단", () => { + test.use({ storageState: "playwright/.auth/user.json" }) + + let pom: ReportsPOM + + test.beforeEach(async ({ page }) => { + pom = new ReportsPOM(page) + await pom.navigateToReports() + }) + + test("사용자 차단 클릭시 사용자 차단 버튼이 비활성화된다", async () => { + await pom.clickReportedGameRow(0) + await pom.clickBlockCreator() + await expect(pom.reportDetail).toBeVisible() + await pom.checkButtonDisabled(pom.blockCreatorButton) + }) + }) +}) diff --git a/service/app/src/app/admin/reports/__tests__/reportsPOM.ts b/service/app/src/app/admin/reports/__tests__/reportsPOM.ts new file mode 100644 index 00000000..4b8fa844 --- /dev/null +++ b/service/app/src/app/admin/reports/__tests__/reportsPOM.ts @@ -0,0 +1,160 @@ +import { expect, type Locator, type Page } from "@playwright/test" + +export class ReportsPOM { + readonly page: Page + + // 네비게이션 영역 + readonly reportsTab: Locator + readonly gamesTab: Locator + readonly usersTab: Locator + + // 메인 콘텐츠 영역 + readonly reportsTable: Locator + readonly reportedGameRows: Locator + + // 팝업 + readonly reportDetail: Locator + readonly reportedGameTitle: Locator + readonly reportDetailCloseButton: Locator + readonly reportIgnoreButton: Locator + readonly gameDeleteButton: Locator + readonly gameQuestionList: Locator + readonly gameQuestionCount: Locator + readonly gameReportTable: Locator + readonly gameCreatorInfo: Locator + readonly gameReporterInfo: Locator + readonly reportReasonInfo: Locator + readonly blockCreatorButton: Locator + readonly blockReporterButton: Locator + readonly deletedGameMessage: Locator + + constructor(page: Page) { + this.page = page + + // 네비게이션 영역 + this.reportsTab = page.getByRole("link", { name: "신고접수" }) + this.gamesTab = page.getByRole("link", { name: "게임관리" }) + this.usersTab = page.getByRole("link", { name: "회원관리" }) + + // 메인 콘텐츠 영역 + this.reportsTable = page.getByRole("table", { name: "신고접수 테이블" }) + this.reportedGameRows = this.reportsTable.locator('[tabindex="0"]') + + // 팝업 + this.reportDetail = page.getByRole("dialog") + this.reportDetailCloseButton = page.getByRole("button", { + name: "게임 미리보기 닫기", + }) + this.reportIgnoreButton = page.getByRole("button", { name: "신고 무시" }) + this.gameDeleteButton = page.getByRole("button", { name: "게임 삭제" }) + this.gameQuestionList = page.getByTestId("game-preview-questions") + this.gameQuestionCount = page.getByTestId("game-preview-question-count") + this.reportedGameTitle = page.getByTestId("game-preview-game-title") + this.gameReportTable = page.getByRole("table", { + name: "게임 신고 정보 테이블", + }) + this.blockCreatorButton = page.getByRole("button", { + name: /^제작자.*차단$/, + }) + this.blockReporterButton = page.getByRole("button", { + name: /^신고자.*차단$/, + }) + this.gameCreatorInfo = page.getByRole("cell", { + name: "제작자 이름과 이메일", + }) + this.gameReporterInfo = page.getByRole("cell", { + name: "신고자 이름과 이메일", + }) + this.reportReasonInfo = page.getByRole("cell", { name: "신고 사유" }) + this.deletedGameMessage = page.getByText("삭제 처리된 게임입니다.") + } + + async navigateToReports() { + await this.page.goto("/admin/reports") + } + + async checkNavigationHighlight() { + await expect(this.reportsTab).toHaveAttribute("aria-current", "page") + await expect(this.gamesTab).not.toHaveAttribute("aria-current", "page") + await expect(this.usersTab).not.toHaveAttribute("aria-current", "page") + } + + async checkReportedTable() { + await expect(this.reportsTable).toBeVisible() + } + + async clickGamesTab() { + await this.gamesTab.click() + } + + async clickUsersTab() { + await this.usersTab.click() + } + + async checkRowCount() { + const count = await this.reportedGameRows.count() + expect(count).toBeLessThanOrEqual(7) + } + + async checkRowColumns() { + const headerRow = this.reportsTable.getByRole("row", { + name: "신고접수 테이블 헤더", + }) + const headers = headerRow.getByRole("columnheader") + await expect(headers).toHaveCount(6) + + const headerTexts = await headers.allTextContents() + expect(headerTexts).toContain("번호") + expect(headerTexts).toContain("게임명") + expect(headerTexts).toContain("제작자") + expect(headerTexts).toContain("신고자") + expect(headerTexts).toContain("신고일자") + expect(headerTexts).toContain("처리 여부") + } + + async clickReportedGameRow(index: number) { + await this.reportedGameRows.nth(index).click() + } + + async checkReportDetailPopup() { + await expect(this.reportDetail).toBeVisible() + } + + async clickCloseButton() { + await this.reportDetailCloseButton.click() + } + + async checkGameInfo() { + await expect(this.reportedGameTitle).toBeVisible() + await expect(this.gameQuestionCount).toBeVisible() + await expect(this.reportReasonInfo).toBeVisible() + } + + async checkCarousel() { + await expect(this.gameQuestionList).toBeVisible() + } + + async checkDeletedGameMessage() { + await expect(this.deletedGameMessage).toBeVisible() + } + + async clickIgnoreReport() { + await this.reportIgnoreButton.click() + } + + async clickDeleteGame() { + await this.gameDeleteButton.click() + } + + async clickBlockCreator() { + await this.blockCreatorButton.click() + } + + async clickBlockReporter() { + await this.blockReporterButton.click() + } + + async checkButtonDisabled(button: Locator) { + await expect(button).toBeDisabled() + } +} diff --git a/service/app/src/app/admin/reports/page.tsx b/service/app/src/app/admin/reports/page.tsx new file mode 100644 index 00000000..78799d36 --- /dev/null +++ b/service/app/src/app/admin/reports/page.tsx @@ -0,0 +1,44 @@ +"use client" + +import { Pagination } from "@ject-5-fe/design/components/pagination" +import { useState } from "react" + +import { useAdminGamePreview } from "@/entities/game/hooks/useGameReportDetail" +import { useAdminReports } from "@/entities/report/hooks/useAdminReports" +import { ReportedGamesTable } from "@/widgets/admin/reported-games-table" + +export default function AdminReportsPage() { + const [currentPage, setCurrentPage] = useState(1) + const pageIndex = currentPage - 1 + + const { games, totalPages } = useAdminReports({ + page: pageIndex, + }) + + const { openAdminPreview } = useAdminGamePreview() + + const handlePageChange = (page: number) => { + setCurrentPage(page) + } + + const handleRowClick = (item: (typeof games)[number]) => { + openAdminPreview(item.id) + } + + return ( +
+
+

신고접수

+ +
+ + {totalPages > 0 && ( + + )} +
+ ) +} diff --git a/service/app/src/app/admin/users/_components/dialog/blockConfirmModal.tsx b/service/app/src/app/admin/users/_components/dialog/blockConfirmModal.tsx new file mode 100644 index 00000000..eab92c33 --- /dev/null +++ b/service/app/src/app/admin/users/_components/dialog/blockConfirmModal.tsx @@ -0,0 +1,56 @@ +import { + Dialog, + DialogBody, + DialogButton, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, +} from "@ject-5-fe/design/components/dialog" +import { overlay } from "overlay-kit" + +interface BlockConfirmModalOptions { + onConfirm?: () => void + onCancel?: () => void + confirmText?: string + cancelText?: string +} + +export function openBlockConfirmModal({ + onConfirm, + onCancel, + confirmText = "예", + cancelText = "아니요", +}: BlockConfirmModalOptions = {}) { + overlay.open(({ isOpen, close }) => ( + + + 선택한 사용자를{"\n"}차단하시겠습니까? + + 차단 처리 시 해당 계정은 게임 생성 및 공유, 신고 기능에 대한 접근 + 권한을 즉시 상실합니다. + + + + { + onCancel?.() + close() + }} + > + {cancelText} + + + { + onConfirm?.() + close() + }} + > + {confirmText} + + + + + )) +} diff --git a/service/app/src/app/admin/users/_components/dialog/unblockConfirmModal.tsx b/service/app/src/app/admin/users/_components/dialog/unblockConfirmModal.tsx new file mode 100644 index 00000000..a7d7c0a8 --- /dev/null +++ b/service/app/src/app/admin/users/_components/dialog/unblockConfirmModal.tsx @@ -0,0 +1,55 @@ +import { + Dialog, + DialogBody, + DialogButton, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, +} from "@ject-5-fe/design/components/dialog" +import { overlay } from "overlay-kit" + +interface UnblockConfirmModalOptions { + onConfirm?: () => void + onCancel?: () => void + confirmText?: string + cancelText?: string +} + +export function openUnblockConfirmModal({ + onConfirm, + onCancel, + confirmText = "예", + cancelText = "아니요", +}: UnblockConfirmModalOptions = {}) { + overlay.open(({ isOpen, close }) => ( + + + 선택한 사용자를{"\n"}차단 해제하시겠습니까? + + 차단 해제 즉시 사용자의 모든 이용 제한이 해제됩니다. + + + + { + onCancel?.() + close() + }} + > + {cancelText} + + + { + onConfirm?.() + close() + }} + > + {confirmText} + + + + + )) +} diff --git a/service/app/src/app/admin/users/page.tsx b/service/app/src/app/admin/users/page.tsx new file mode 100644 index 00000000..e892220f --- /dev/null +++ b/service/app/src/app/admin/users/page.tsx @@ -0,0 +1,3 @@ +export default function AdminUsersPage() { + return
AdminPage-Users
+} diff --git a/service/app/src/entities/auth/api/validateSession.ts b/service/app/src/entities/auth/api/validateSession.ts index 58f37314..dbb57e8d 100644 --- a/service/app/src/entities/auth/api/validateSession.ts +++ b/service/app/src/entities/auth/api/validateSession.ts @@ -1,6 +1,9 @@ import { fetchClient } from "@/shared/api/fetchClient" -export const validateSession = async () => { - const response = await fetchClient.get("users/me/games?limit=1") // TODO: 엔드포인트 생성 후 변경 - return response.json() -} +import type { UserSession, UserSessionResponse } from "../model/auth" + +export const validateSession = + async (): Promise => { + const response = await fetchClient.get("users/auth/me") + return response.json() + } diff --git a/service/app/src/entities/auth/model/auth.ts b/service/app/src/entities/auth/model/auth.ts index 9af71397..e6efd514 100644 --- a/service/app/src/entities/auth/model/auth.ts +++ b/service/app/src/entities/auth/model/auth.ts @@ -1,9 +1,20 @@ import type { ApiResponse } from "@/shared/api/types/response" +export type UserRole = "ADMIN" | "USER" | "BLOCKED" export interface KakaoLoginData { profileImageUrl: string nickname: string email: string + role: UserRole } export type KakaoLoginResponse = ApiResponse + +export interface UserSession { + profileImageUrl: string + nickname: string + email: string + role: UserRole +} + +export type UserSessionResponse = ApiResponse diff --git a/service/app/src/entities/game/hooks/useGamePreview.tsx b/service/app/src/entities/game/hooks/useGamePreview.tsx index 051df13e..5f185ceb 100644 --- a/service/app/src/entities/game/hooks/useGamePreview.tsx +++ b/service/app/src/entities/game/hooks/useGamePreview.tsx @@ -6,7 +6,7 @@ import { useCallback } from "react" import type { GameListItem } from "@/entities/game" import { getGameDetail } from "@/entities/game/api/getGameDetail" import { GameQuestion } from "@/entities/game/model/game" -import { GamePreview } from "@/entities/game/ui/gamePreview" +import { GamePreviewUser } from "@/entities/game/ui/GamePreview" import { useGameEntryNavigation } from "./useGameEntryNavigation" @@ -29,12 +29,12 @@ export const useGamePreview = ({ onError }: UseGamePreviewParams = {}) => { } return ( - ({ - id: question.questionId.toString(), + id: question.questionId, title: question.questionText, imageUrl: question.imageUrl, }))} diff --git a/service/app/src/entities/game/hooks/useGameReportDetail.tsx b/service/app/src/entities/game/hooks/useGameReportDetail.tsx new file mode 100644 index 00000000..8cd47d90 --- /dev/null +++ b/service/app/src/entities/game/hooks/useGameReportDetail.tsx @@ -0,0 +1,99 @@ +import { overlay } from "overlay-kit" +import { useCallback } from "react" + +import { REPORT_REASON_LABELS } from "@/entities/game/model/report" +import { GamePreviewAdmin } from "@/entities/game/ui/GamePreview" +import { getReportDetail } from "@/entities/report/api/getReportDetail" +import { useAdminReportMutations } from "@/entities/report/hooks/useAdminReportMutations" +import { useReportDetail } from "@/entities/report/hooks/useReportDetail" +import { queryClient } from "@/shared/lib/queryClient" + +export const useAdminGamePreview = () => { + const { ignoreReportMutation, deleteGameMutation, blockUserMutation } = + useAdminReportMutations() + + const openAdminPreview = useCallback( + async (reportId: number) => { + await queryClient.fetchQuery({ + queryKey: ["reportDetail", reportId] as const, + queryFn: async () => { + const response = await getReportDetail(reportId) + return response + }, + }) + + overlay.open(({ close, isOpen }) => { + const Content = () => { + const { data } = useReportDetail({ + reportId, + enabled: isOpen, + }) + + const handleIgnoreReport = async () => { + await ignoreReportMutation.mutateAsync(reportId) + close() + } + + const handleDeleteGame = async () => { + await deleteGameMutation.mutateAsync(reportId) + close() + } + + const handleBlockCreator = async () => { + if (data) { + await blockUserMutation.mutateAsync(data.makerEmail) + } + } + + const handleBlockReporter = async () => { + if (data) { + await blockUserMutation.mutateAsync(data.reporterEmail) + } + } + + if (!data) { + return null + } + + return ( + ({ + id: q.questionId, + title: q.questionText, + imageUrl: q.imageUrl, + }))} + reportData={{ + creator: { + name: data.makerNickname, + email: data.makerEmail, + isBlocked: data.isMakerBlock, + }, + reporter: { + name: data.reporterNickname, + email: data.reporterEmail, + isBlocked: data.isReporterBlock, + }, + category: + REPORT_REASON_LABELS[data.reasonCode] ?? data.reasonCode, + }} + status={data.status} + onClose={close} + isOpen={isOpen} + onIgnoreReport={handleIgnoreReport} + onDeleteGame={handleDeleteGame} + onBlockCreator={handleBlockCreator} + onBlockReporter={handleBlockReporter} + /> + ) + } + + return + }) + }, + [ignoreReportMutation, deleteGameMutation, blockUserMutation], + ) + + return { openAdminPreview } +} diff --git a/service/app/src/entities/game/ui/GamePreview/gamePreviewAdmin.tsx b/service/app/src/entities/game/ui/GamePreview/gamePreviewAdmin.tsx new file mode 100644 index 00000000..d6710368 --- /dev/null +++ b/service/app/src/entities/game/ui/GamePreview/gamePreviewAdmin.tsx @@ -0,0 +1,114 @@ +"use client" + +import { PrimaryBoxButton } from "@ject-5-fe/design/components/button" + +import type { AdminReportStatus } from "@/entities/report/model/types" + +import { GamePreviewQuestionList } from "./gamePreviewQuestionList" +import { GamePreviewReportTable } from "./gamePreviewReportTable" +import { GamePreviewShell } from "./gamePreviewShell" +import { GamePreviewTitle } from "./gamePreviewTitle" + +interface GamePreviewAdminProps { + className?: string + gameTitle?: string + questionCount?: number + questions?: Array<{ + id: number + title: string + imageUrl?: string + }> + reportData?: { + creator: { + name: string + email: string + isBlocked: boolean + } + reporter: { + name: string + email: string + isBlocked: boolean + } + category: string + } + status: AdminReportStatus + onClose?: () => void + onIgnoreReport?: () => void + onDeleteGame?: () => void + onBlockCreator?: () => void + onBlockReporter?: () => void + isOpen?: boolean +} + +export const GamePreviewAdmin = ({ + className = "", + gameTitle, + status, + questionCount = 10, + questions = [], + reportData, + onClose, + onIgnoreReport, + onDeleteGame, + onBlockCreator, + onBlockReporter, + isOpen = true, +}: GamePreviewAdminProps) => { + return ( + +
+
+
+ +
+ + 신고 무시 + + + 게임 삭제 + +
+
+
+
+ + 총 {questionCount} 문제 + + {status === "GAME_DELETED" ? ( +
+ 삭제 처리된 게임입니다. +
+ ) : ( + + )} +
+ + {reportData && ( + + )} +
+
+
+
+ ) +} diff --git a/service/app/src/entities/game/ui/GamePreview/gamePreviewQuestionList.tsx b/service/app/src/entities/game/ui/GamePreview/gamePreviewQuestionList.tsx new file mode 100644 index 00000000..b278e5f8 --- /dev/null +++ b/service/app/src/entities/game/ui/GamePreview/gamePreviewQuestionList.tsx @@ -0,0 +1,35 @@ +"use client" + +import * as GameCard from "@/entities/game/ui/GameCard/gameCard" + +interface GamePreviewQuestionListProps { + questions: Array<{ + id: number + title: string + imageUrl?: string + }> +} + +export const GamePreviewQuestionList = ({ + questions, +}: GamePreviewQuestionListProps) => { + return ( +
+ {questions.map((question) => ( + + + {question.title} + + ))} +
+ ) +} diff --git a/service/app/src/entities/game/ui/GamePreview/gamePreviewReportTable.tsx b/service/app/src/entities/game/ui/GamePreview/gamePreviewReportTable.tsx new file mode 100644 index 00000000..1cb74cb3 --- /dev/null +++ b/service/app/src/entities/game/ui/GamePreview/gamePreviewReportTable.tsx @@ -0,0 +1,109 @@ +"use client" + +import { PrimaryBoxButton } from "@ject-5-fe/design/components/button" + +interface GamePreviewReportTableProps { + creator: { name: string; email: string; isBlocked: boolean } + reporter: { name: string; email: string; isBlocked: boolean } + category: string + onBlockCreator?: () => void + onBlockReporter?: () => void +} + +export const GamePreviewReportTable = ({ + creator, + reporter, + category, + onBlockCreator, + onBlockReporter, +}: GamePreviewReportTableProps) => { + return ( +
+
+
+ + 제작자 + +
+
+ + {creator.name}({creator.email}) + + + 차단 + +
+
+ +
+
+ + 신고자 + +
+
+ + {reporter.name}({reporter.email}) + + + 차단 + +
+
+ +
+
+ + 항목 + +
+
+ + {category} + +
+
+
+ ) +} diff --git a/service/app/src/entities/game/ui/GamePreview/gamePreviewShell.tsx b/service/app/src/entities/game/ui/GamePreview/gamePreviewShell.tsx new file mode 100644 index 00000000..0a2347af --- /dev/null +++ b/service/app/src/entities/game/ui/GamePreview/gamePreviewShell.tsx @@ -0,0 +1,42 @@ +"use client" + +import { SecondaryPlainIconButton } from "@ject-5-fe/design/components/button" +import { Dialog, DialogContent } from "@ject-5-fe/design/components/dialog" +import { Cross } from "@ject-5-fe/design/icons" +import type { PropsWithChildren } from "react" + +interface GamePreviewShellProps extends PropsWithChildren { + className?: string + isOpen?: boolean + onClose?: () => void +} + +export const GamePreviewShell = ({ + children, + className = "", + isOpen = true, + onClose, +}: GamePreviewShellProps) => { + const handleClose = () => { + onClose?.() + } + + return ( + + +
+ + + +
+ {children} +
+
+ ) +} diff --git a/service/app/src/entities/game/ui/GamePreview/gamePreviewTitle.tsx b/service/app/src/entities/game/ui/GamePreview/gamePreviewTitle.tsx new file mode 100644 index 00000000..df29a691 --- /dev/null +++ b/service/app/src/entities/game/ui/GamePreview/gamePreviewTitle.tsx @@ -0,0 +1,36 @@ +"use client" + +import { DialogHeader } from "@ject-5-fe/design/components/dialog" + +interface GamePreviewTitleProps { + gameTitle?: string + creatorName?: string + showCreatorName?: boolean +} + +export const GamePreviewTitle = ({ + gameTitle, + creatorName, + showCreatorName = true, +}: GamePreviewTitleProps) => { + return ( +
+ +

+ {gameTitle} +

+
+ {showCreatorName && creatorName && ( + + {creatorName} + + )} +
+ ) +} diff --git a/service/app/src/entities/game/ui/GamePreview/gamePreviewUser.tsx b/service/app/src/entities/game/ui/GamePreview/gamePreviewUser.tsx new file mode 100644 index 00000000..22bc74ae --- /dev/null +++ b/service/app/src/entities/game/ui/GamePreview/gamePreviewUser.tsx @@ -0,0 +1,68 @@ +"use client" + +import { PrimaryBoxButton } from "@ject-5-fe/design/components/button" +import { Play } from "@ject-5-fe/design/icons" + +import { GamePreviewQuestionList } from "./gamePreviewQuestionList" +import { GamePreviewShell } from "./gamePreviewShell" +import { GamePreviewTitle } from "./gamePreviewTitle" + +interface GamePreviewUserProps { + className?: string + gameTitle?: string + creatorName?: string + questionCount?: number + questions?: Array<{ + id: number + title: string + imageUrl?: string + }> + onClose?: () => void + onStartGame?: () => void + isOpen?: boolean +} + +export const GamePreviewUser = ({ + className = "", + gameTitle, + creatorName, + questionCount = 10, + questions = [], + onClose, + onStartGame, + isOpen = true, +}: GamePreviewUserProps) => { + const handleStartGame = () => { + onStartGame?.() + } + + return ( + +
+
+
+ + + + 게임 시작 + +
+ +
+ + 총 {questionCount} 문제 + + +
+
+
+
+ ) +} diff --git a/service/app/src/entities/game/ui/GamePreview/index.ts b/service/app/src/entities/game/ui/GamePreview/index.ts new file mode 100644 index 00000000..562fd37a --- /dev/null +++ b/service/app/src/entities/game/ui/GamePreview/index.ts @@ -0,0 +1,6 @@ +export { GamePreviewAdmin } from "./gamePreviewAdmin" +export { GamePreviewQuestionList } from "./gamePreviewQuestionList" +export { GamePreviewReportTable } from "./gamePreviewReportTable" +export { GamePreviewShell } from "./gamePreviewShell" +export { GamePreviewTitle } from "./gamePreviewTitle" +export { GamePreviewUser } from "./gamePreviewUser" diff --git a/service/app/src/entities/game/ui/gamePreview.tsx b/service/app/src/entities/game/ui/gamePreview.tsx deleted file mode 100644 index 08a85e4a..00000000 --- a/service/app/src/entities/game/ui/gamePreview.tsx +++ /dev/null @@ -1,133 +0,0 @@ -"use client" - -import { - PrimaryBoxButton, - SecondaryPlainIconButton, -} from "@ject-5-fe/design/components/button" -import { - Dialog, - DialogBody, - DialogContent, - DialogHeader, -} from "@ject-5-fe/design/components/dialog" -import { Cross, Play } from "@ject-5-fe/design/icons" - -import * as GameCard from "@/entities/game/ui/GameCard/gameCard" -import { DEFAULT_BLUR_DATA_URL } from "@/shared/constants/images" - -interface GamePreviewProps { - className?: string - gameTitle?: string - creatorName?: string - questionCount?: number - questions?: Array<{ - id: string - title: string - imageUrl?: string - }> - onClose?: () => void - onStartGame?: () => void - isOpen?: boolean -} - -export const GamePreview = ({ - className = "", - gameTitle, - creatorName, - questionCount = 10, - questions = [], - onClose, - onStartGame, - isOpen = true, -}: GamePreviewProps) => { - const handleClose = () => { - onClose?.() - } - - const handleStartGame = () => { - onStartGame?.() - } - - return ( - - -
- - - -
- -
-
-
-
- -

- {gameTitle} -

-
- - {creatorName} - -
- - - - 게임 시작 - -
- - -
- - 총 {questionCount} 문제 - -
- {questions.map((question) => ( - - - - {question.title} - - - ))} -
-
-
-
-
-
-
- ) -} diff --git a/service/app/src/entities/report/api/getAdminReports.ts b/service/app/src/entities/report/api/getAdminReports.ts new file mode 100644 index 00000000..a3883185 --- /dev/null +++ b/service/app/src/entities/report/api/getAdminReports.ts @@ -0,0 +1,18 @@ +import { fetchClient } from "@/shared/api/fetchClient" + +import type { AdminReportsResponse } from "../model/types" + +export interface GetAdminReportsParams { + page?: number +} + +export const getAdminReports = async ( + params: GetAdminReportsParams = {}, +): Promise => { + const { page = 0 } = params + const queryString = page > 0 ? `?page=${page}` : "" + const response = await fetchClient.get( + `admin/games${queryString}`, + ) + return response.json().then((res) => res.data) +} diff --git a/service/app/src/entities/report/api/getReportDetail.ts b/service/app/src/entities/report/api/getReportDetail.ts new file mode 100644 index 00000000..5cafe850 --- /dev/null +++ b/service/app/src/entities/report/api/getReportDetail.ts @@ -0,0 +1,11 @@ +import { fetchClient } from "@/shared/api/fetchClient" + +import type { ReportDetailData } from "../model/types" + +export const getReportDetail = async (reportId: number) => { + const response = await fetchClient.get( + `admin/games/${reportId}`, + ) + const res = await response.json() + return res.data +} diff --git a/service/app/src/entities/report/api/index.ts b/service/app/src/entities/report/api/index.ts new file mode 100644 index 00000000..dcf16248 --- /dev/null +++ b/service/app/src/entities/report/api/index.ts @@ -0,0 +1,3 @@ +export * from "./getAdminReports" +export * from "./getReportDetail" +export * from "./updateAdminReportStatus" diff --git a/service/app/src/entities/report/api/updateAdminReportStatus.ts b/service/app/src/entities/report/api/updateAdminReportStatus.ts new file mode 100644 index 00000000..a21ff923 --- /dev/null +++ b/service/app/src/entities/report/api/updateAdminReportStatus.ts @@ -0,0 +1,13 @@ +import { fetchClient } from "@/shared/api/fetchClient" + +import type { AdminReportUpdateRequest } from "../model/types" + +export const updateAdminReportStatus = async ( + payload: AdminReportUpdateRequest, +) => { + const response = await fetchClient.post("admin/games/delete", { + json: payload, + }) + + return response.json() +} diff --git a/service/app/src/entities/report/hooks/index.ts b/service/app/src/entities/report/hooks/index.ts new file mode 100644 index 00000000..97e7dc3d --- /dev/null +++ b/service/app/src/entities/report/hooks/index.ts @@ -0,0 +1 @@ +export * from "./useAdminReports" diff --git a/service/app/src/entities/report/hooks/useAdminReportMutations.ts b/service/app/src/entities/report/hooks/useAdminReportMutations.ts new file mode 100644 index 00000000..8ae78aa9 --- /dev/null +++ b/service/app/src/entities/report/hooks/useAdminReportMutations.ts @@ -0,0 +1,40 @@ +import { useMutation } from "@tanstack/react-query" + +import { blockUsers } from "@/entities/suspension/api/blockUsers" +import { queryClient } from "@/shared/lib/queryClient" + +import { updateAdminReportStatus } from "../api/updateAdminReportStatus" + +export const useAdminReportMutations = () => { + const ignoreReportMutation = useMutation({ + mutationFn: (reportId: number) => + updateAdminReportStatus({ status: "IGNORE_REPORT", reportId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["adminReports"] }) + }, + }) + + const deleteGameMutation = useMutation({ + mutationFn: (reportId: number) => + updateAdminReportStatus({ status: "DELETE_GAME", reportId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["adminReports"] }) + }, + }) + + const blockUserMutation = useMutation({ + mutationFn: (email: string) => + blockUsers({ + banList: [{ email }], + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["reportDetail"] }) + }, + }) + + return { + ignoreReportMutation, + deleteGameMutation, + blockUserMutation, + } +} diff --git a/service/app/src/entities/report/hooks/useAdminReports.ts b/service/app/src/entities/report/hooks/useAdminReports.ts new file mode 100644 index 00000000..3258a490 --- /dev/null +++ b/service/app/src/entities/report/hooks/useAdminReports.ts @@ -0,0 +1,47 @@ +import { useQuery } from "@tanstack/react-query" + +import { getAdminReports } from "../api/getAdminReports" +import type { ReportedGame } from "../model/types" +import { mapAdminReportToReportedGame } from "../utils/mapAdminReportToReportedGame" + +interface UseAdminReportsParams { + page?: number + enabled?: boolean +} + +interface UseAdminReportsReturn { + games: ReportedGame[] + currentPage: number + totalPages: number + totalElements: number + isLoading: boolean + error: Error | null +} + +export const useAdminReports = ({ + page = 0, + enabled = true, +}: UseAdminReportsParams = {}): UseAdminReportsReturn => { + const { data, isLoading, error } = useQuery({ + queryKey: ["adminReports", { page }] as const, + queryFn: async () => { + const response = await getAdminReports({ page }) + return response + }, + enabled, + staleTime: 5 * 60 * 1000, + retry: 2, + }) + + const games: ReportedGame[] = + data?.report.map((report) => mapAdminReportToReportedGame(report)) ?? [] + + return { + games, + currentPage: data?.page ?? page, + totalPages: data?.totalPages ?? 0, + totalElements: data?.totalElements ?? 0, + isLoading, + error: error as Error | null, + } +} diff --git a/service/app/src/entities/report/hooks/useReportDetail.ts b/service/app/src/entities/report/hooks/useReportDetail.ts new file mode 100644 index 00000000..1b55773b --- /dev/null +++ b/service/app/src/entities/report/hooks/useReportDetail.ts @@ -0,0 +1,27 @@ +import { useQuery } from "@tanstack/react-query" + +import { getReportDetail } from "../api/getReportDetail" +import type { ReportDetailData } from "../model/types" + +interface UseReportDetailParams { + reportId: number + enabled?: boolean +} + +export const useReportDetail = ({ + reportId, + enabled = true, +}: UseReportDetailParams) => { + const { data } = useQuery({ + queryKey: ["reportDetail", reportId] as const, + queryFn: async () => { + const response = await getReportDetail(reportId) + return response + }, + enabled, + staleTime: 0, + retry: 2, + }) + + return { data } +} diff --git a/service/app/src/entities/report/model/types.ts b/service/app/src/entities/report/model/types.ts new file mode 100644 index 00000000..aa1095b1 --- /dev/null +++ b/service/app/src/entities/report/model/types.ts @@ -0,0 +1,78 @@ +import z from "zod" + +import type { ReportReasonCode } from "@/entities/game/model/report" + +export interface ReportedGame { + id: number + title: string + creatorName: string + reporterName: string + reportedAt: string + statusLabel: string +} + +export interface AdminReportsResponse { + report: AdminReport[] + page: number + size: number + totalElements: number + totalPages: number + hasNext: boolean +} + +export type AdminReportStatus = "PENDING" | "GAME_DELETED" | "IGNORED" + +export interface AdminReport { + reportId: number + gameName: string + creatorName: string + reporterName: string + reportedAt: string + status: AdminReportStatus +} + +export const adminReportUpdateStatusSchema = z.enum([ + "IGNORE_REPORT", + "DELETE_GAME", +]) + +export type AdminReportUpdateStatus = z.infer< + typeof adminReportUpdateStatusSchema +> + +export const adminReportUpdateRequestSchema = z.object({ + status: adminReportUpdateStatusSchema, + reportId: z.coerce.number(), +}) + +export type AdminReportUpdateRequest = z.infer< + typeof adminReportUpdateRequestSchema +> + +export const reportIdParamsSchema = z.object({ + reportId: z.coerce.number(), +}) + +export type ReportIdParams = z.infer + +export interface ReportDetailData { + status: AdminReportStatus + gameTitle: string + makerNickname: string + makerEmail: string + questionCount: number + version: number + questions: { + questionId: number + questionOrder: number + imageUrl: string + questionText: string + questionAnswer: string + version: number + }[] + reporterEmail: string + reporterNickname: string + reasonCode: ReportReasonCode + isMakerBlock: boolean + isReporterBlock: boolean +} diff --git a/service/app/src/entities/report/utils/formatReportDate.ts b/service/app/src/entities/report/utils/formatReportDate.ts new file mode 100644 index 00000000..b9abb91a --- /dev/null +++ b/service/app/src/entities/report/utils/formatReportDate.ts @@ -0,0 +1,7 @@ +export const formatReportDate = (isoString: string): string => { + const date = new Date(isoString) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, "0") + const day = String(date.getDate()).padStart(2, "0") + return `${year}-${month}-${day}` +} diff --git a/service/app/src/entities/report/utils/formatReportNumber.ts b/service/app/src/entities/report/utils/formatReportNumber.ts new file mode 100644 index 00000000..d7b2678e --- /dev/null +++ b/service/app/src/entities/report/utils/formatReportNumber.ts @@ -0,0 +1,3 @@ +export const formatReportNumber = (num: number): string => { + return String(num).padStart(2, "0") +} diff --git a/service/app/src/entities/report/utils/mapAdminReportToReportedGame.ts b/service/app/src/entities/report/utils/mapAdminReportToReportedGame.ts new file mode 100644 index 00000000..f353cb17 --- /dev/null +++ b/service/app/src/entities/report/utils/mapAdminReportToReportedGame.ts @@ -0,0 +1,21 @@ +import type { AdminReport, ReportedGame } from "../model/types" +import { formatReportDate } from "./formatReportDate" + +const STATUS_LABEL_MAP: Record = { + PENDING: "미처리", + GAME_DELETED: "삭제 완료", + IGNORED: "신고 무시", +} + +export const mapAdminReportToReportedGame = ( + report: AdminReport, +): ReportedGame => { + return { + id: report.reportId, + title: report.gameName, + creatorName: report.creatorName, + reporterName: report.reporterName, + reportedAt: formatReportDate(report.reportedAt), + statusLabel: STATUS_LABEL_MAP[report.status], + } +} diff --git a/service/app/src/entities/suspension/api/blockUsers.ts b/service/app/src/entities/suspension/api/blockUsers.ts new file mode 100644 index 00000000..3847b31e --- /dev/null +++ b/service/app/src/entities/suspension/api/blockUsers.ts @@ -0,0 +1,11 @@ +import { fetchClient } from "@/shared/api/fetchClient" + +import type { BlockUsersRequest } from "../model/types" + +export const blockUsers = async (payload: BlockUsersRequest) => { + const response = await fetchClient.post("admin/users/block", { + json: payload, + }) + + return response.json() +} diff --git a/service/app/src/entities/suspension/api/index.ts b/service/app/src/entities/suspension/api/index.ts new file mode 100644 index 00000000..26352323 --- /dev/null +++ b/service/app/src/entities/suspension/api/index.ts @@ -0,0 +1 @@ +export * from "./blockUsers" diff --git a/service/app/src/entities/suspension/model/types.ts b/service/app/src/entities/suspension/model/types.ts new file mode 100644 index 00000000..c53b6cf8 --- /dev/null +++ b/service/app/src/entities/suspension/model/types.ts @@ -0,0 +1,22 @@ +import z from "zod" + +export interface SuspendedUser { + id: string + name: string + email: string + reason: string + suspendedAt: string + blockStatusLabel?: string +} + +export const banListItemSchema = z.object({ + email: z.string().min(1), + reason: z.string().optional(), +}) + +export const blockUsersRequestSchema = z.object({ + banList: z.array(banListItemSchema).min(1), +}) + +export type BanListItem = z.infer +export type BlockUsersRequest = z.infer diff --git a/service/app/src/mocks/data/admin.ts b/service/app/src/mocks/data/admin.ts new file mode 100644 index 00000000..45331d0f --- /dev/null +++ b/service/app/src/mocks/data/admin.ts @@ -0,0 +1,84 @@ +import type { AdminReport } from "@/entities/report/model/types" + +const gameNames = [ + "퀴즈 마스터", + "두뇌 게임", + "상식 퀴즈", + "역사 퀴즈", + "과학 퀴즈", + "스포츠 퀴즈", + "음악 퀴즈", + "영화 퀴즈", + "문학 퀴즈", + "지리 퀴즈", +] + +const creatorNames = [ + "홍길동", + "김철수", + "이영희", + "박민수", + "정수진", + "최동현", + "강미영", + "윤태준", + "임지은", + "한소연", +] + +const reporterNames = [ + "신고자1", + "신고자2", + "신고자3", + "신고자4", + "신고자5", + "신고자6", + "신고자7", + "신고자8", + "신고자9", + "신고자10", +] + +export const generateMockAdminReports = ( + count: number, + seed?: number, +): AdminReport[] => { + let originalRandom: (() => number) | undefined + + if (seed !== undefined) { + originalRandom = Math.random + let currentSeed = seed + Math.random = () => { + const x = Math.sin(currentSeed++) * 10000 + return x - Math.floor(x) + } + } + + const result: AdminReport[] = [] + for (let i = 0; i < count; i++) { + const randomIndex = Math.floor(Math.random() * gameNames.length) + const creatorIndex = Math.floor(Math.random() * creatorNames.length) + const reporterIndex = Math.floor(Math.random() * reporterNames.length) + const daysAgo = Math.floor(Math.random() * 30) + + const date = new Date() + date.setDate(date.getDate() - daysAgo) + + result.push({ + reportId: i + 1, + gameName: gameNames[randomIndex], + creatorName: creatorNames[creatorIndex], + reporterName: reporterNames[reporterIndex], + reportedAt: date.toISOString(), + status: "PENDING", + }) + } + + if (originalRandom) { + Math.random = originalRandom + } + + return result +} + +export const mockAdminReports: AdminReport[] = generateMockAdminReports(21, 456) diff --git a/service/app/src/mocks/data/auth.ts b/service/app/src/mocks/data/auth.ts index f5efe106..210d9677 100644 --- a/service/app/src/mocks/data/auth.ts +++ b/service/app/src/mocks/data/auth.ts @@ -4,6 +4,7 @@ const mockKakaoLoginData: KakaoLoginData = { profileImageUrl: "/avatar.svg", nickname: "testUser", email: "test@example.com", + role: "ADMIN", } export const kakaoLoginSuccess: KakaoLoginResponse = { @@ -11,3 +12,9 @@ export const kakaoLoginSuccess: KakaoLoginResponse = { data: mockKakaoLoginData, error: null, } + +export const userInfoSuccess: KakaoLoginResponse = { + result: "SUCCESS", + data: mockKakaoLoginData, + error: null, +} diff --git a/service/app/src/mocks/data/game.ts b/service/app/src/mocks/data/game.ts index 8ca937a0..7513d488 100644 --- a/service/app/src/mocks/data/game.ts +++ b/service/app/src/mocks/data/game.ts @@ -8,6 +8,7 @@ import { UUID } from "@/shared/api/types/common" import { generatePresignedUrlData } from "../utils/mockGenerators" import { generateGameErrorResponse, + generateReportErrorResponse, generateSuccessResponse, } from "../utils/responseHelpers" import { mockGameList } from "./common" @@ -53,6 +54,9 @@ export const gameDetailSuccess = (gameDetailData: GameDetailData) => ({ export const gameNotFoundError = (gameId: UUID) => generateGameErrorResponse.notFound(gameId) +export const reportNotFoundError = (reportId: number) => + generateReportErrorResponse.notFound(reportId) + export const gameUnauthorizedError = () => generateGameErrorResponse.unauthorized() diff --git a/service/app/src/mocks/fixtures/adminReports.ts b/service/app/src/mocks/fixtures/adminReports.ts new file mode 100644 index 00000000..5faa7d3c --- /dev/null +++ b/service/app/src/mocks/fixtures/adminReports.ts @@ -0,0 +1,22 @@ +import type { AdminReport } from "@/entities/report/model/types" + +export const adminReportFixtures = { + pending: (id: number): AdminReport => ({ + reportId: id, + gameName: "테스트 게임", + creatorName: "김관리", + reporterName: "박신고", + reportedAt: "2026-01-01T00:00:00.000Z", + status: "PENDING", + }), + + deleted: (id: number): AdminReport => ({ + ...adminReportFixtures.pending(id), + status: "GAME_DELETED", + }), + + ignored: (id: number): AdminReport => ({ + ...adminReportFixtures.pending(id), + status: "IGNORED", + }), +} diff --git a/service/app/src/mocks/handlers/admin.ts b/service/app/src/mocks/handlers/admin.ts new file mode 100644 index 00000000..232a7cf6 --- /dev/null +++ b/service/app/src/mocks/handlers/admin.ts @@ -0,0 +1,183 @@ +import { http, HttpResponse } from "msw" + +import type { + AdminReport, + AdminReportsResponse, +} from "@/entities/report/model/types" +import { + adminReportUpdateRequestSchema, + reportIdParamsSchema, +} from "@/entities/report/model/types" +import { blockUsersRequestSchema } from "@/entities/suspension/model/types" + +import { internalServerError, loginRequiredError } from "../data/common" +import { + gameMissingFieldsError, + gameSuccessResponse, + reportNotFoundError, +} from "../data/game" +import { validateSessionCookie } from "../utils/gameHandlers" +import { generateMockQuestions } from "../utils/mockGenerators" +import { generateSuccessResponse } from "../utils/responseHelpers" + +const MSW_BASE_URL = process.env.MSW_BASE_URL || "http://localhost:3000" + +const PAGE_SIZE = 7 + +export const createAdminHandlers = (initialReports: AdminReport[]) => { + const store: AdminReport[] = structuredClone(initialReports) + const blockedEmails = new Set() + + return [ + http.get(`${MSW_BASE_URL}/admin/games`, ({ request }) => { + try { + const cookieHeader = request.headers.get("Cookie") + if (!validateSessionCookie(cookieHeader)) { + return HttpResponse.json(loginRequiredError, { status: 401 }) + } + + const url = new URL(request.url) + const pageParam = url.searchParams.get("page") + const page = pageParam ? Math.max(0, parseInt(pageParam, 10)) : 0 + + const totalElements = store.length + const totalPages = Math.ceil(totalElements / PAGE_SIZE) + + const startIndex = page * PAGE_SIZE + const endIndex = startIndex + PAGE_SIZE + + const paginatedReports = store.slice(startIndex, endIndex) + const hasNext = page < totalPages - 1 + + const response: AdminReportsResponse = { + report: paginatedReports, + page, + size: PAGE_SIZE, + totalElements, + totalPages, + hasNext, + } + + return HttpResponse.json(generateSuccessResponse(response)) + } catch { + return HttpResponse.json(internalServerError, { status: 500 }) + } + }), + + http.get(`${MSW_BASE_URL}/admin/games/:reportId`, ({ request, params }) => { + try { + const cookieHeader = request.headers.get("Cookie") + if (!validateSessionCookie(cookieHeader)) { + return HttpResponse.json(loginRequiredError, { status: 401 }) + } + + const parsed = reportIdParamsSchema.safeParse(params) + if (!parsed.success) { + return HttpResponse.json(gameMissingFieldsError(), { status: 400 }) + } + + const { reportId } = parsed.data + const report = store.find((r) => r.reportId === reportId) + + if (!report) { + return HttpResponse.json(reportNotFoundError(reportId), { + status: 404, + }) + } + + const makerNickname = report.creatorName + const makerEmail = `${makerNickname + .replace(/\s+/g, ".") + .toLowerCase()}@example.com` + + const reporterNickname = report.reporterName + const reporterEmail = `${reporterNickname + .replace(/\s+/g, ".") + .toLowerCase()}@example.com` + + const questionCount = 10 + const questions = generateMockQuestions(questionCount, 1) + + const data = { + gameTitle: report.gameName, + status: report.status, + makerNickname, + makerEmail, + questionCount, + version: 1, + questions, + reporterEmail, + reporterNickname, + reasonCode: "VIOLENT_OR_DISTURBING_CONTENT", + isMakerBlock: blockedEmails.has(makerEmail), + isReporterBlock: blockedEmails.has(reporterEmail), + } + + return HttpResponse.json(generateSuccessResponse(data)) + } catch { + return HttpResponse.json(internalServerError, { status: 500 }) + } + }), + + http.post(`${MSW_BASE_URL}/admin/games/delete`, async ({ request }) => { + try { + const cookieHeader = request.headers.get("Cookie") + if (!validateSessionCookie(cookieHeader)) { + return HttpResponse.json(loginRequiredError, { status: 401 }) + } + + const json = await request.json() + const parsed = adminReportUpdateRequestSchema.safeParse(json) + + if (!parsed.success) { + return HttpResponse.json(gameMissingFieldsError(), { status: 400 }) + } + + const { reportId, status } = parsed.data + + const idx = store.findIndex((r) => r.reportId === reportId) + + if (idx === -1) { + return HttpResponse.json(reportNotFoundError(reportId), { + status: 404, + }) + } + + const nextStatus = status === "DELETE_GAME" ? "GAME_DELETED" : "IGNORED" + + store[idx] = { + ...store[idx], + status: nextStatus, + } + + return HttpResponse.json(gameSuccessResponse()) + } catch { + return HttpResponse.json(internalServerError, { status: 500 }) + } + }), + + http.post(`${MSW_BASE_URL}/admin/users/block`, async ({ request }) => { + try { + const cookieHeader = request.headers.get("Cookie") + if (!validateSessionCookie(cookieHeader)) { + return HttpResponse.json(loginRequiredError, { status: 401 }) + } + + const json = await request.json() + const parsed = blockUsersRequestSchema.safeParse(json) + + if (!parsed.success) { + return HttpResponse.json(gameMissingFieldsError(), { status: 400 }) + } + + parsed.data.banList.forEach(({ email }) => { + blockedEmails.add(email) + }) + + return HttpResponse.json(gameSuccessResponse()) + } catch { + return HttpResponse.json(internalServerError, { status: 500 }) + } + }), + ] +} diff --git a/service/app/src/mocks/handlers/auth.ts b/service/app/src/mocks/handlers/auth.ts index bce40d41..9091ef6d 100644 --- a/service/app/src/mocks/handlers/auth.ts +++ b/service/app/src/mocks/handlers/auth.ts @@ -2,7 +2,9 @@ import { http, HttpResponse } from "msw" import { KakaoLoginRequest } from "@/entities/auth/model/authRequest" -import { kakaoLoginSuccess } from "../data/auth" +import { kakaoLoginSuccess, userInfoSuccess } from "../data/auth" +import { loginRequiredError } from "../data/common" +import { validateSessionCookie } from "../utils/gameHandlers" const MSW_BASE_URL = process.env.MSW_BASE_URL || "http://localhost:3000" export const MSW_MOCK_CODE = "someValidCode" @@ -36,4 +38,16 @@ export const authHandlers = [ }, ) }), + http.get(`${MSW_BASE_URL}/users/auth/me`, async ({ request }) => { + const cookieHeader = request.headers.get("Cookie") + if (!validateSessionCookie(cookieHeader)) { + return HttpResponse.json(loginRequiredError, { status: 401 }) + } + return new HttpResponse(JSON.stringify(userInfoSuccess), { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }) + }), ] diff --git a/service/app/src/mocks/handlers/index.ts b/service/app/src/mocks/handlers/index.ts index b1bae729..cb503377 100644 --- a/service/app/src/mocks/handlers/index.ts +++ b/service/app/src/mocks/handlers/index.ts @@ -1,4 +1,18 @@ +import { adminReportFixtures } from "../fixtures/adminReports" +import { createAdminHandlers } from "./admin" import { authHandlers } from "./auth" import { gameHandlers } from "./game" -export const handlers = [...authHandlers, ...gameHandlers] +export const handlers = [ + ...authHandlers, + ...gameHandlers, + ...createAdminHandlers([ + adminReportFixtures.pending(1), + adminReportFixtures.deleted(2), + adminReportFixtures.pending(3), + adminReportFixtures.pending(4), + adminReportFixtures.pending(5), + adminReportFixtures.pending(6), + adminReportFixtures.pending(7), + ]), +] diff --git a/service/app/src/mocks/utils/responseHelpers.ts b/service/app/src/mocks/utils/responseHelpers.ts index f140ec70..a4166e71 100644 --- a/service/app/src/mocks/utils/responseHelpers.ts +++ b/service/app/src/mocks/utils/responseHelpers.ts @@ -39,3 +39,10 @@ export const generateGameErrorResponse = { gameId, }), } + +export const generateReportErrorResponse = { + notFound: (reportId: number) => + generateErrorResponse("E404", "해당 신고가 존재하지 않습니다.", { + reportId, + }), +} diff --git a/service/app/src/shared/api/fetchClientFactory.ts b/service/app/src/shared/api/fetchClientFactory.ts deleted file mode 100644 index 8ac81339..00000000 --- a/service/app/src/shared/api/fetchClientFactory.ts +++ /dev/null @@ -1,315 +0,0 @@ -// import ky, { type OptionsInit } from "ky" - -// import type { ResponsePromise } from "@/shared/api/type" - -// export type RequestInterceptor = { -// onFulfilled: ( -// url: string, -// options: RequestInit, -// ) => Promise<{ url: string; options: RequestInit }> -// onRejected?: (error: unknown) => Promise -// } - -// export type ResponseInterceptor = { -// // eslint-disable-next-line @typescript-eslint/no-explicit-any -// onFulfilled: (response: any) => any -// onRejected?: (error: unknown) => Promise -// } - -// type RequestExecutor = ( -// path: string, -// options?: RequestInit, -// ) => ResponsePromise - -// type GetOptions = Omit -// type MutateOptions = Omit - -// const toHeaderObject = (headers?: HeadersInit): Record => { -// if (!headers) { -// return {} -// } - -// if (headers instanceof Headers) { -// const result: Record = {} -// headers.forEach((value, key) => { -// result[key] = value -// }) -// return result -// } - -// if (Array.isArray(headers)) { -// return headers.reduce>((acc, [key, value]) => { -// acc[key] = value -// return acc -// }, {}) -// } - -// return headers -// } - -// const normalizeBody = (value: unknown): BodyInit | undefined => { -// if (value === undefined) { -// return undefined -// } - -// if (value === null) { -// return "null" -// } - -// if (typeof value === "string") { -// return value -// } - -// if (typeof FormData !== "undefined" && value instanceof FormData) { -// return value -// } - -// if ( -// typeof URLSearchParams !== "undefined" && -// value instanceof URLSearchParams -// ) { -// return value -// } - -// if (typeof Blob !== "undefined" && value instanceof Blob) { -// return value -// } - -// return JSON.stringify(value) -// } - -// const createMutateInit = ( -// method: string, -// body: B | undefined, -// options?: MutateOptions, -// ): RequestInit => { -// const { body: optionsBody, ...rest } = options || {} -// const payload = body === undefined ? optionsBody : normalizeBody(body) - -// const init: RequestInit = { -// ...rest, -// method, -// } - -// if (payload !== undefined) { -// init.body = payload -// } - -// return init -// } - -// const createResponsePromise = ( -// executor: () => Promise, -// ): ResponsePromise => { -// const rawPromise = executor().then((response) => { -// const augmented = Object.assign(response, { -// json: () => response.clone().json() as Promise, -// }) as Response & { json: () => Promise } - -// return augmented -// }) - -// const responsePromise = rawPromise as ResponsePromise - -// responsePromise.arrayBuffer = () => -// rawPromise.then((response) => response.clone().arrayBuffer()) - -// responsePromise.blob = () => -// rawPromise.then((response) => response.clone().blob()) - -// responsePromise.formData = () => -// rawPromise.then((response) => response.clone().formData()) - -// responsePromise.bytes = () => -// rawPromise.then(async (response) => { -// const buffer = await response.clone().arrayBuffer() -// return new Uint8Array(buffer) -// }) - -// responsePromise.json = () => rawPromise.then((response) => response.json()) - -// responsePromise.text = () => -// rawPromise.then((response) => response.clone().text()) - -// return responsePromise -// } - -// export interface FetchClientOptions { -// baseUrl: string -// defaultHeaders?: Record -// timeout?: number -// credentials?: RequestCredentials -// } - -// export interface FetchClientInstance { -// request: RequestExecutor -// fetch: RequestExecutor -// get: (path: string, options?: GetOptions) => ResponsePromise -// head: (path: string, options?: GetOptions) => ResponsePromise -// delete: ( -// path: string, -// options?: MutateOptions, -// ) => ResponsePromise -// post: ( -// path: string, -// body?: B, -// options?: MutateOptions, -// ) => ResponsePromise -// put: ( -// path: string, -// body?: B, -// options?: MutateOptions, -// ) => ResponsePromise -// patch: ( -// path: string, -// body?: B, -// options?: MutateOptions, -// ) => ResponsePromise -// addRequestInterceptor: ( -// onFulfilled: RequestInterceptor["onFulfilled"], -// onRejected?: RequestInterceptor["onRejected"], -// ) => void -// addResponseInterceptor: ( -// onFulfilled: ResponseInterceptor["onFulfilled"], -// onRejected?: ResponseInterceptor["onRejected"], -// ) => void -// removeRequestInterceptor: (index: number) => void -// removeResponseInterceptor: (index: number) => void -// } - -// export const createFetchClient = ({ -// baseUrl, -// defaultHeaders = {}, -// timeout, -// credentials, -// }: FetchClientOptions): FetchClientInstance => { -// const requestInterceptors: RequestInterceptor[] = [] -// const responseInterceptors: ResponseInterceptor[] = [] - -// const baseKyOptions: OptionsInit = { -// throwHttpErrors: false, -// retry: { -// limit: 0, -// }, -// ...(timeout !== undefined ? { timeout } : {}), -// ...(credentials ? { credentials } : {}), -// } - -// const addRequestInterceptor = ( -// onFulfilled: RequestInterceptor["onFulfilled"], -// onRejected?: RequestInterceptor["onRejected"], -// ) => { -// requestInterceptors.push({ onFulfilled, onRejected }) -// } - -// const addResponseInterceptor = ( -// onFulfilled: ResponseInterceptor["onFulfilled"], -// onRejected?: ResponseInterceptor["onRejected"], -// ) => { -// responseInterceptors.push({ onFulfilled, onRejected }) -// } - -// const removeRequestInterceptor = (index: number) => { -// if (index >= 0 && index < requestInterceptors.length) -// requestInterceptors.splice(index, 1) -// } - -// const removeResponseInterceptor = (index: number) => { -// if (index >= 0 && index < responseInterceptors.length) -// responseInterceptors.splice(index, 1) -// } - -// const executeRequest = ( -// path: string, -// options: RequestInit = {}, -// ): ResponsePromise => -// createResponsePromise(async () => { -// let url = new URL(path, baseUrl).toString() -// const { headers: optionHeaders, ...restOptions } = options -// let fetchOptions: RequestInit = { -// ...restOptions, -// headers: { ...defaultHeaders, ...toHeaderObject(optionHeaders) }, -// ...(credentials && { credentials }), -// } - -// for (const { onFulfilled, onRejected } of requestInterceptors) { -// try { -// const result = await onFulfilled(url, fetchOptions) -// url = result.url -// fetchOptions = result.options -// } catch (error) { -// if (onRejected) await onRejected(error) -// else throw error -// } -// } - -// let response: Response -// try { -// response = await ky(url, { -// ...baseKyOptions, -// ...fetchOptions, -// headers: fetchOptions.headers, -// }) -// } catch (error) { -// for (const { onRejected } of responseInterceptors) { -// if (onRejected) await onRejected(error) -// } -// throw error -// } - -// let processed: Response = response - -// for (const { onFulfilled, onRejected } of responseInterceptors) { -// try { -// processed = await onFulfilled(processed) -// } catch (error) { -// if (onRejected) await onRejected(error) -// else throw error -// } -// } - -// return processed -// }) - -// const get = (path: string, options?: GetOptions) => -// executeRequest(path, { ...(options || {}), method: "GET" }) - -// const head = (path: string, options?: GetOptions) => -// executeRequest(path, { ...(options || {}), method: "HEAD" }) - -// const del = (path: string, options?: MutateOptions) => -// executeRequest(path, createMutateInit("DELETE", undefined, options)) - -// const post = ( -// path: string, -// body?: B, -// options?: MutateOptions, -// ) => executeRequest(path, createMutateInit("POST", body, options)) - -// const put = ( -// path: string, -// body?: B, -// options?: MutateOptions, -// ) => executeRequest(path, createMutateInit("PUT", body, options)) - -// const patchRequest = ( -// path: string, -// body?: B, -// options?: MutateOptions, -// ) => executeRequest(path, createMutateInit("PATCH", body, options)) - -// return { -// request: executeRequest, -// fetch: executeRequest, -// get, -// head, -// delete: del, -// post, -// put, -// patch: patchRequest, -// addRequestInterceptor, -// addResponseInterceptor, -// removeRequestInterceptor, -// removeResponseInterceptor, -// } -// } diff --git a/service/app/src/shared/ui/admin-table/AdminTableGroup.tsx b/service/app/src/shared/ui/admin-table/AdminTableGroup.tsx new file mode 100644 index 00000000..8bebd95b --- /dev/null +++ b/service/app/src/shared/ui/admin-table/AdminTableGroup.tsx @@ -0,0 +1,16 @@ +import { cn } from "@ject-5-fe/design/utils/cn" +import type { ReactNode } from "react" + +interface AdminTableGroupProps { + children: ReactNode + className?: string +} + +export const AdminTableGroup = ({ + children, + className, +}: AdminTableGroupProps) => { + return ( +
{children}
+ ) +} diff --git a/service/app/src/shared/ui/admin-table/AdminTableRoot.tsx b/service/app/src/shared/ui/admin-table/AdminTableRoot.tsx new file mode 100644 index 00000000..3cd6ab69 --- /dev/null +++ b/service/app/src/shared/ui/admin-table/AdminTableRoot.tsx @@ -0,0 +1,26 @@ +import { cn } from "@ject-5-fe/design/utils/cn" +import type { ReactNode } from "react" + +interface AdminTableRootProps { + children: ReactNode + className?: string + ariaLabel?: string +} + +export const AdminTableRoot = ({ + children, + className, + ariaLabel, +}: AdminTableRootProps) => { + return ( +
+
+
{children}
+
+
+ ) +} diff --git a/service/app/src/shared/ui/admin-table/AdminTableRow.tsx b/service/app/src/shared/ui/admin-table/AdminTableRow.tsx new file mode 100644 index 00000000..aad5d3d2 --- /dev/null +++ b/service/app/src/shared/ui/admin-table/AdminTableRow.tsx @@ -0,0 +1,29 @@ +import { cn } from "@ject-5-fe/design/utils/cn" +import type { HTMLAttributes, ReactNode } from "react" + +type AdminTableRowVariant = "header" | "data" + +interface AdminTableRowProps extends HTMLAttributes { + children: ReactNode + variant?: AdminTableRowVariant +} + +export const AdminTableRow = ({ + children, + className, + variant = "data", + ...rest +}: AdminTableRowProps) => { + const base = "flex items-center py-20 min-w-0 px-16" + const border = "border border-border-interactive-tertiary" + const bg = + variant === "header" + ? "bg-background-interactive-secondary-hovered" + : "bg-transparent" + + return ( +
+ {children} +
+ ) +} diff --git a/service/app/src/shared/ui/admin-table/AdminTableText.tsx b/service/app/src/shared/ui/admin-table/AdminTableText.tsx new file mode 100644 index 00000000..87528f9c --- /dev/null +++ b/service/app/src/shared/ui/admin-table/AdminTableText.tsx @@ -0,0 +1,33 @@ +import { cn } from "@ject-5-fe/design/utils/cn" +import type { ReactNode } from "react" + +type AdminTableTextTone = "header" | "body" + +interface AdminTableTextProps { + children: ReactNode + className?: string + tone?: AdminTableTextTone +} + +export const AdminTableText = ({ + children, + className, + tone = "body", +}: AdminTableTextProps) => { + const header = + "typography-heading-md-medium text-text-interactive-secondary-hovered" + const body = "typography-heading-md-medium text-text-interactive-input-filled" + + return ( + + {children} + + ) +} diff --git a/service/app/src/shared/ui/admin-table/index.ts b/service/app/src/shared/ui/admin-table/index.ts new file mode 100644 index 00000000..51a6daab --- /dev/null +++ b/service/app/src/shared/ui/admin-table/index.ts @@ -0,0 +1,4 @@ +export * from "./AdminTableGroup" +export * from "./AdminTableRoot" +export * from "./AdminTableRow" +export * from "./AdminTableText" diff --git a/service/app/src/widgets/admin/reported-games-table/index.ts b/service/app/src/widgets/admin/reported-games-table/index.ts new file mode 100644 index 00000000..f615a157 --- /dev/null +++ b/service/app/src/widgets/admin/reported-games-table/index.ts @@ -0,0 +1 @@ +export * from "./ui/ReportedGamesTable" diff --git a/service/app/src/widgets/admin/reported-games-table/ui/ReportedGameRow.tsx b/service/app/src/widgets/admin/reported-games-table/ui/ReportedGameRow.tsx new file mode 100644 index 00000000..0d9bcba9 --- /dev/null +++ b/service/app/src/widgets/admin/reported-games-table/ui/ReportedGameRow.tsx @@ -0,0 +1,39 @@ +import type { ReportedGame } from "@/entities/report/model/types" +import { + AdminTableGroup, + AdminTableRow, + AdminTableText, +} from "@/shared/ui/admin-table" + +interface ReportedGameRowProps { + item: ReportedGame + onClick?: (item: ReportedGame) => void +} + +export const ReportedGameRow = ({ item, onClick }: ReportedGameRowProps) => { + return ( + onClick?.(item)} + aria-label={`게임명 ${item.title} 제작자 ${item.creatorName} 신고자 ${item.reporterName} 신고일자 ${item.reportedAt} 처리 여부 ${item.statusLabel}`} + tabIndex={0} + > + + {item.id} + {item.title} + + + + {item.creatorName} + {item.reporterName} + + + + + {item.reportedAt} + + {item.statusLabel} + + + ) +} diff --git a/service/app/src/widgets/admin/reported-games-table/ui/ReportedGamesTable.tsx b/service/app/src/widgets/admin/reported-games-table/ui/ReportedGamesTable.tsx new file mode 100644 index 00000000..e96f09d4 --- /dev/null +++ b/service/app/src/widgets/admin/reported-games-table/ui/ReportedGamesTable.tsx @@ -0,0 +1,52 @@ +import type { ReportedGame } from "@/entities/report/model/types" +import { + AdminTableGroup, + AdminTableRoot, + AdminTableRow, + AdminTableText, +} from "@/shared/ui/admin-table" + +import { ReportedGameRow } from "./ReportedGameRow" + +interface ReportedGamesTableProps { + items: ReportedGame[] + onRowClick?: (item: ReportedGame) => void +} + +export const ReportedGamesTable = ({ + items, + onRowClick, +}: ReportedGamesTableProps) => { + return ( + + + + + 번호 + + 게임명 + + + + 제작자 + 신고자 + + + + + 신고일자 + + 처리 여부 + + + + {items.map((item) => ( + + ))} + + ) +} diff --git a/shared/design/src/components/pagination/index.tsx b/shared/design/src/components/pagination/index.tsx new file mode 100644 index 00000000..c9f046df --- /dev/null +++ b/shared/design/src/components/pagination/index.tsx @@ -0,0 +1,2 @@ +export type { PaginationProps } from "./pagination" +export { Pagination } from "./pagination" diff --git a/shared/design/src/components/pagination/pagination.tsx b/shared/design/src/components/pagination/pagination.tsx new file mode 100644 index 00000000..a811434c --- /dev/null +++ b/shared/design/src/components/pagination/pagination.tsx @@ -0,0 +1,130 @@ +import { type ComponentPropsWithoutRef } from "react" +import { forwardRef } from "react" + +import { Arrow } from "../../icons" +import { cn } from "../../utils/cn" +import { SecondaryPlainBoxButton, SecondaryPlainIconButton } from "../button" + +export interface PaginationProps extends ComponentPropsWithoutRef<"nav"> { + currentPage: number + totalPages: number + onPageChange: (page: number) => void + maxVisible?: number +} + +interface PaginationItemProps { + page: number + isActive: boolean + onClick: () => void +} + +const PaginationItem = ({ page, isActive, onClick }: PaginationItemProps) => { + return ( + + {page} + + ) +} + +export const Pagination = forwardRef( + ( + { + currentPage, + totalPages, + onPageChange, + maxVisible = 5, + className, + ...rest + }, + ref, + ) => { + const handlePrev = () => { + if (currentPage > 1) { + onPageChange(currentPage - 1) + } + } + + const handleNext = () => { + if (currentPage < totalPages) { + onPageChange(currentPage + 1) + } + } + + const handlePageClick = (page: number) => { + if (page !== currentPage) { + onPageChange(page) + } + } + + const getVisiblePages = () => { + const pages: number[] = [] + const half = Math.floor(maxVisible / 2) + + let start = Math.max(1, currentPage - half) + const end = Math.min(totalPages, start + maxVisible - 1) + + if (end - start < maxVisible - 1) { + start = Math.max(1, end - maxVisible + 1) + } + + for (let i = start; i <= end; i++) { + pages.push(i) + } + + return pages + } + + const visiblePages = getVisiblePages() + const isPrevDisabled = currentPage <= 1 + const isNextDisabled = currentPage >= totalPages + + return ( + + ) + }, +) + +Pagination.displayName = "Pagination"