From 0827352e6b894129ca3b02bc401c8df2b00bc13f Mon Sep 17 00:00:00 2001 From: mota Date: Fri, 31 Oct 2025 16:27:04 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/20251031063728_/migration.sql | 55 +++ .../migrations/20251031070529_/migration.sql | 11 + prisma/schema.prisma | 37 ++ prisma/seed.js | 23 ++ public/erd.svg | 2 +- src/app/admin/AdminSidebar.tsx | 1 + src/app/admin/boards/page.tsx | 371 +++++++++++++----- src/app/admin/mainpage-settings/page.tsx | 190 +++++++++ src/app/api/admin/boards/[id]/route.ts | 9 +- src/app/api/admin/boards/route.ts | 38 ++ src/app/api/admin/mainpage-settings/route.ts | 25 ++ src/app/api/admin/view-types/[id]/route.ts | 19 + src/app/api/admin/view-types/route.ts | 26 ++ src/app/api/categories/route.ts | 2 + 14 files changed, 704 insertions(+), 105 deletions(-) create mode 100644 prisma/migrations/20251031063728_/migration.sql create mode 100644 prisma/migrations/20251031070529_/migration.sql create mode 100644 src/app/admin/mainpage-settings/page.tsx create mode 100644 src/app/api/admin/mainpage-settings/route.ts create mode 100644 src/app/api/admin/view-types/[id]/route.ts create mode 100644 src/app/api/admin/view-types/route.ts diff --git a/prisma/migrations/20251031063728_/migration.sql b/prisma/migrations/20251031063728_/migration.sql new file mode 100644 index 0000000..db8f1a4 --- /dev/null +++ b/prisma/migrations/20251031063728_/migration.sql @@ -0,0 +1,55 @@ +-- CreateTable +CREATE TABLE "board_view_types" ( + "id" TEXT NOT NULL PRIMARY KEY, + "key" TEXT NOT NULL, + "name" TEXT NOT NULL, + "scope" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_boards" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "description" TEXT, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "status" TEXT NOT NULL DEFAULT 'active', + "type" TEXT NOT NULL DEFAULT 'general', + "requiresApproval" BOOLEAN NOT NULL DEFAULT false, + "allowAnonymousPost" BOOLEAN NOT NULL DEFAULT false, + "allowSecretComment" BOOLEAN NOT NULL DEFAULT false, + "isAdultOnly" BOOLEAN NOT NULL DEFAULT false, + "requiredTags" JSONB, + "requiredFields" JSONB, + "readLevel" TEXT NOT NULL DEFAULT 'public', + "writeLevel" TEXT NOT NULL DEFAULT 'member', + "categoryId" TEXT, + "mainPageViewTypeId" TEXT, + "listViewTypeId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "boards_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "board_categories" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "boards_mainPageViewTypeId_fkey" FOREIGN KEY ("mainPageViewTypeId") REFERENCES "board_view_types" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "boards_listViewTypeId_fkey" FOREIGN KEY ("listViewTypeId") REFERENCES "board_view_types" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_boards" ("allowAnonymousPost", "allowSecretComment", "categoryId", "createdAt", "description", "id", "isAdultOnly", "name", "readLevel", "requiredFields", "requiredTags", "requiresApproval", "slug", "sortOrder", "status", "type", "updatedAt", "writeLevel") SELECT "allowAnonymousPost", "allowSecretComment", "categoryId", "createdAt", "description", "id", "isAdultOnly", "name", "readLevel", "requiredFields", "requiredTags", "requiresApproval", "slug", "sortOrder", "status", "type", "updatedAt", "writeLevel" FROM "boards"; +DROP TABLE "boards"; +ALTER TABLE "new_boards" RENAME TO "boards"; +CREATE UNIQUE INDEX "boards_slug_key" ON "boards"("slug"); +CREATE INDEX "boards_status_sortOrder_idx" ON "boards"("status", "sortOrder"); +CREATE INDEX "boards_type_requiresApproval_idx" ON "boards"("type", "requiresApproval"); +CREATE INDEX "boards_categoryId_idx" ON "boards"("categoryId"); +CREATE INDEX "boards_mainPageViewTypeId_idx" ON "boards"("mainPageViewTypeId"); +CREATE INDEX "boards_listViewTypeId_idx" ON "boards"("listViewTypeId"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE UNIQUE INDEX "board_view_types_key_key" ON "board_view_types"("key"); + +-- CreateIndex +CREATE INDEX "board_view_types_scope_idx" ON "board_view_types"("scope"); diff --git a/prisma/migrations/20251031070529_/migration.sql b/prisma/migrations/20251031070529_/migration.sql new file mode 100644 index 0000000..1916282 --- /dev/null +++ b/prisma/migrations/20251031070529_/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "settings" ( + "id" TEXT NOT NULL PRIMARY KEY, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "settings_key_key" ON "settings"("key"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 15358be..81e5950 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -141,6 +141,12 @@ model Board { categoryId String? category BoardCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull) + // 뷰 타입 설정 (동적 테이블 참조) + mainPageViewTypeId String? + mainPageViewType BoardViewType? @relation("MainPageViewType", fields: [mainPageViewTypeId], references: [id], onDelete: SetNull) + listViewTypeId String? + listViewType BoardViewType? @relation("ListViewType", fields: [listViewTypeId], references: [id], onDelete: SetNull) + posts Post[] moderators BoardModerator[] @@ -150,9 +156,29 @@ model Board { @@index([status, sortOrder]) @@index([type, requiresApproval]) @@index([categoryId]) + @@index([mainPageViewTypeId]) + @@index([listViewTypeId]) @@map("boards") } +// 게시판 뷰 타입 정의 (enum 대신 데이터 테이블) +model BoardViewType { + id String @id @default(cuid()) + key String @unique // 예: preview, text, special_rank + name String // 표시용 이름 + scope String // 용도 구분: 'main' | 'list' 등 자유 텍스트 + + // 역참조 + mainBoards Board[] @relation("MainPageViewType") + listBoards Board[] @relation("ListViewType") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([scope]) + @@map("board_view_types") +} + // 게시판 운영진 매핑 model BoardModerator { id String @id @default(cuid()) @@ -703,3 +729,14 @@ model PartnerRequest { @@index([status, createdAt]) @@map("partner_requests") } + +// 시스템 설정 (key-value 형태) +model Setting { + id String @id @default(cuid()) + key String @unique + value String // JSON 문자열로 저장 + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("settings") +} diff --git a/prisma/seed.js b/prisma/seed.js index b38d035..b7cbca3 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -207,6 +207,28 @@ async function upsertBoards(admin, categoryMap) { return created; } +async function upsertViewTypes() { + const viewTypes = [ + // main scope + { key: "main_default", name: "기본", scope: "main" }, + { 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" }, + { key: "list_text", name: "텍스트", scope: "list" }, + { key: "list_preview", name: "미리보기", scope: "list" }, + { key: "list_special_rank", name: "특수랭킹", scope: "list" }, + ]; + for (const vt of viewTypes) { + await prisma.boardViewType.upsert({ + where: { key: vt.key }, + update: { name: vt.name, scope: vt.scope }, + create: vt, + }); + } +} + async function seedPolicies() { // 금칙어 예시 const banned = [ @@ -241,6 +263,7 @@ async function main() { await upsertRoles(); const admin = await upsertAdmin(); const categoryMap = await upsertCategories(); + await upsertViewTypes(); const boards = await upsertBoards(admin, categoryMap); // 샘플 글 하나 diff --git a/public/erd.svg b/public/erd.svg index 528b105..5bc7d2c 100644 --- a/public/erd.svg +++ b/public/erd.svg @@ -1 +1 @@ -

enum:status

enum:type

enum:readLevel

enum:writeLevel

category

enum:role

board

user

enum:status

board

author

enum:status

enum:authLevel

enum:resource

enum:action

role

user

role

post

author

post

tag

enum:type

post

enum:type

post

user

post

user

post

enum:targetType

reporter

post

comment

sender

receiver

blocker

blocked

enum:referenceType

user

enum:appliesTo

enum:type

user

enum:targetType

actor

enum:targetType

createdByUser

user

createdByUser

user

user

coupon

user

BoardStatus

active

active

hidden

hidden

archived

archived

BoardType

general

general

special

special

AccessLevel

public

public

member

member

moderator

moderator

admin

admin

PostStatus

published

published

hidden

hidden

deleted

deleted

AttachmentType

image

image

file

file

BoardModRole

MODERATOR

MODERATOR

MANAGER

MANAGER

UserStatus

active

active

suspended

suspended

withdrawn

withdrawn

AuthLevel

USER

USER

MOD

MOD

ADMIN

ADMIN

ReactionType

RECOMMEND

RECOMMEND

REPORT

REPORT

SanctionType

SUSPEND

SUSPEND

BAN

BAN

DOWNGRADE

DOWNGRADE

TargetType

POST

POST

COMMENT

COMMENT

USER

USER

BOARD

BOARD

SYSTEM

SYSTEM

Resource

BOARD

BOARD

POST

POST

COMMENT

COMMENT

USER

USER

ADMIN

ADMIN

Action

READ

READ

CREATE

CREATE

UPDATE

UPDATE

DELETE

DELETE

MODERATE

MODERATE

ADMINISTER

ADMINISTER

board_categories

String

id

🗝️

String

name

String

slug

Int

sortOrder

String

status

DateTime

createdAt

DateTime

updatedAt

boards

String

id

🗝️

String

name

String

slug

String

description

Int

sortOrder

BoardStatus

status

BoardType

type

Boolean

requiresApproval

Boolean

allowAnonymousPost

Boolean

allowSecretComment

Boolean

isAdultOnly

Json

requiredTags

Json

requiredFields

AccessLevel

readLevel

AccessLevel

writeLevel

DateTime

createdAt

DateTime

updatedAt

board_moderators

String

id

🗝️

BoardModRole

role

DateTime

createdAt

posts

String

id

🗝️

String

title

String

content

PostStatus

status

Boolean

isAnonymous

Boolean

isPinned

Int

pinnedOrder

DateTime

createdAt

DateTime

updatedAt

DateTime

lastActivityAt

users

String

userId

🗝️

String

nickname

String

passwordHash

String

name

DateTime

birth

String

phone

Int

rank

UserStatus

status

AuthLevel

authLevel

String

profileImage

DateTime

lastLoginAt

Boolean

isAdultVerified

Int

loginFailCount

DateTime

agreementTermsAt

DateTime

createdAt

DateTime

updatedAt

roles

String

roleId

🗝️

String

name

String

description

DateTime

createdAt

role_permissions

String

id

🗝️

Resource

resource

Action

action

Boolean

allowed

DateTime

createdAt

user_roles

String

id

🗝️

DateTime

createdAt

comments

String

id

🗝️

String

content

Boolean

isAnonymous

Boolean

isSecret

String

secretPasswordHash

DateTime

createdAt

DateTime

updatedAt

tags

String

tagId

🗝️

String

name

String

slug

DateTime

createdAt

post_tags

String

id

🗝️

attachments

String

id

🗝️

String

url

AttachmentType

type

Int

size

Int

width

Int

height

Int

sortOrder

DateTime

createdAt

reactions

String

id

🗝️

String

clientHash

ReactionType

type

DateTime

createdAt

post_view_logs

String

id

🗝️

String

ip

String

userAgent

DateTime

createdAt

post_stats

Int

views

Int

recommendCount

Int

reportCount

Int

commentsCount

reports

String

id

🗝️

TargetType

targetType

String

reason

String

status

DateTime

createdAt

messages

String

id

🗝️

String

body

DateTime

readAt

DateTime

createdAt

blocks

String

id

🗝️

DateTime

createdAt

point_transactions

String

id

🗝️

Int

amount

String

reason

TargetType

referenceType

String

referenceId

DateTime

createdAt

level_thresholds

String

id

🗝️

Int

level

Int

minPoints

banned_keywords

String

id

🗝️

String

pattern

TargetType

appliesTo

Int

severity

Boolean

active

DateTime

createdAt

sanctions

String

id

🗝️

SanctionType

type

String

reason

DateTime

startAt

DateTime

endAt

String

createdBy

audit_logs

String

id

🗝️

String

action

TargetType

targetType

String

targetId

Json

meta

DateTime

createdAt

admin_notifications

String

id

🗝️

String

type

String

message

TargetType

targetType

String

targetId

DateTime

createdAt

DateTime

readAt

login_sessions

String

id

🗝️

String

device

String

ip

String

userAgent

DateTime

createdAt

DateTime

lastSeenAt

ip_blocks

String

id

🗝️

String

ip

String

reason

Boolean

active

DateTime

createdAt

nickname_changes

String

id

🗝️

String

oldNickname

String

newNickname

DateTime

changedAt

password_reset_tokens

String

id

🗝️

String

token

DateTime

expiresAt

DateTime

usedAt

DateTime

createdAt

coupons

String

id

🗝️

String

code

String

title

String

description

Int

stock

Int

maxPerUser

DateTime

expiresAt

Boolean

active

DateTime

createdAt

coupon_redemptions

String

id

🗝️

DateTime

createdAt

partners

String

id

🗝️

String

name

String

category

Float

latitude

Float

longitude

String

address

DateTime

createdAt

DateTime

updatedAt

banners

String

id

🗝️

String

title

String

imageUrl

String

linkUrl

Boolean

active

Int

sortOrder

DateTime

startAt

DateTime

endAt

DateTime

createdAt

DateTime

updatedAt

partner_inquiries

String

id

🗝️

String

name

String

contact

String

category

String

message

String

status

DateTime

createdAt

DateTime

approvedAt

partner_requests

String

id

🗝️

String

name

String

category

Float

latitude

Float

longitude

String

address

String

contact

String

status

DateTime

createdAt

DateTime

approvedAt

+

enum:status

enum:type

enum:readLevel

enum:writeLevel

category

mainPageViewType

listViewType

enum:role

board

user

enum:status

board

author

enum:status

enum:authLevel

enum:resource

enum:action

role

user

role

post

author

post

tag

enum:type

post

enum:type

post

user

post

user

post

enum:targetType

reporter

post

comment

sender

receiver

blocker

blocked

enum:referenceType

user

enum:appliesTo

enum:type

user

enum:targetType

actor

enum:targetType

createdByUser

user

createdByUser

user

user

coupon

user

BoardStatus

active

active

hidden

hidden

archived

archived

BoardType

general

general

special

special

AccessLevel

public

public

member

member

moderator

moderator

admin

admin

PostStatus

published

published

hidden

hidden

deleted

deleted

AttachmentType

image

image

file

file

BoardModRole

MODERATOR

MODERATOR

MANAGER

MANAGER

UserStatus

active

active

suspended

suspended

withdrawn

withdrawn

AuthLevel

USER

USER

MOD

MOD

ADMIN

ADMIN

ReactionType

RECOMMEND

RECOMMEND

REPORT

REPORT

SanctionType

SUSPEND

SUSPEND

BAN

BAN

DOWNGRADE

DOWNGRADE

TargetType

POST

POST

COMMENT

COMMENT

USER

USER

BOARD

BOARD

SYSTEM

SYSTEM

Resource

BOARD

BOARD

POST

POST

COMMENT

COMMENT

USER

USER

ADMIN

ADMIN

Action

READ

READ

CREATE

CREATE

UPDATE

UPDATE

DELETE

DELETE

MODERATE

MODERATE

ADMINISTER

ADMINISTER

board_categories

String

id

🗝️

String

name

String

slug

Int

sortOrder

String

status

DateTime

createdAt

DateTime

updatedAt

boards

String

id

🗝️

String

name

String

slug

String

description

Int

sortOrder

BoardStatus

status

BoardType

type

Boolean

requiresApproval

Boolean

allowAnonymousPost

Boolean

allowSecretComment

Boolean

isAdultOnly

Json

requiredTags

Json

requiredFields

AccessLevel

readLevel

AccessLevel

writeLevel

DateTime

createdAt

DateTime

updatedAt

board_view_types

String

id

🗝️

String

key

String

name

String

scope

DateTime

createdAt

DateTime

updatedAt

board_moderators

String

id

🗝️

BoardModRole

role

DateTime

createdAt

posts

String

id

🗝️

String

title

String

content

PostStatus

status

Boolean

isAnonymous

Boolean

isPinned

Int

pinnedOrder

DateTime

createdAt

DateTime

updatedAt

DateTime

lastActivityAt

users

String

userId

🗝️

String

nickname

String

passwordHash

String

name

DateTime

birth

String

phone

Int

rank

UserStatus

status

AuthLevel

authLevel

String

profileImage

DateTime

lastLoginAt

Boolean

isAdultVerified

Int

loginFailCount

DateTime

agreementTermsAt

DateTime

createdAt

DateTime

updatedAt

roles

String

roleId

🗝️

String

name

String

description

DateTime

createdAt

role_permissions

String

id

🗝️

Resource

resource

Action

action

Boolean

allowed

DateTime

createdAt

user_roles

String

id

🗝️

DateTime

createdAt

comments

String

id

🗝️

String

content

Boolean

isAnonymous

Boolean

isSecret

String

secretPasswordHash

DateTime

createdAt

DateTime

updatedAt

tags

String

tagId

🗝️

String

name

String

slug

DateTime

createdAt

post_tags

String

id

🗝️

attachments

String

id

🗝️

String

url

AttachmentType

type

Int

size

Int

width

Int

height

Int

sortOrder

DateTime

createdAt

reactions

String

id

🗝️

String

clientHash

ReactionType

type

DateTime

createdAt

post_view_logs

String

id

🗝️

String

ip

String

userAgent

DateTime

createdAt

post_stats

Int

views

Int

recommendCount

Int

reportCount

Int

commentsCount

reports

String

id

🗝️

TargetType

targetType

String

reason

String

status

DateTime

createdAt

messages

String

id

🗝️

String

body

DateTime

readAt

DateTime

createdAt

blocks

String

id

🗝️

DateTime

createdAt

point_transactions

String

id

🗝️

Int

amount

String

reason

TargetType

referenceType

String

referenceId

DateTime

createdAt

level_thresholds

String

id

🗝️

Int

level

Int

minPoints

banned_keywords

String

id

🗝️

String

pattern

TargetType

appliesTo

Int

severity

Boolean

active

DateTime

createdAt

sanctions

String

id

🗝️

SanctionType

type

String

reason

DateTime

startAt

DateTime

endAt

String

createdBy

audit_logs

String

id

🗝️

String

action

TargetType

targetType

String

targetId

Json

meta

DateTime

createdAt

admin_notifications

String

id

🗝️

String

type

String

message

TargetType

targetType

String

targetId

DateTime

createdAt

DateTime

readAt

login_sessions

String

id

🗝️

String

device

String

ip

String

userAgent

DateTime

createdAt

DateTime

lastSeenAt

ip_blocks

String

id

🗝️

String

ip

String

reason

Boolean

active

DateTime

createdAt

nickname_changes

String

id

🗝️

String

oldNickname

String

newNickname

DateTime

changedAt

password_reset_tokens

String

id

🗝️

String

token

DateTime

expiresAt

DateTime

usedAt

DateTime

createdAt

coupons

String

id

🗝️

String

code

String

title

String

description

Int

stock

Int

maxPerUser

DateTime

expiresAt

Boolean

active

DateTime

createdAt

coupon_redemptions

String

id

🗝️

DateTime

createdAt

partners

String

id

🗝️

String

name

String

category

Float

latitude

Float

longitude

String

address

DateTime

createdAt

DateTime

updatedAt

banners

String

id

🗝️

String

title

String

imageUrl

String

linkUrl

Boolean

active

Int

sortOrder

DateTime

startAt

DateTime

endAt

DateTime

createdAt

DateTime

updatedAt

partner_inquiries

String

id

🗝️

String

name

String

contact

String

category

String

message

String

status

DateTime

createdAt

DateTime

approvedAt

partner_requests

String

id

🗝️

String

name

String

category

Float

latitude

Float

longitude

String

address

String

contact

String

status

DateTime

createdAt

DateTime

approvedAt

settings

String

id

🗝️

String

key

String

value

DateTime

createdAt

DateTime

updatedAt

\ No newline at end of file diff --git a/src/app/admin/AdminSidebar.tsx b/src/app/admin/AdminSidebar.tsx index 53ab86f..f4c5666 100644 --- a/src/app/admin/AdminSidebar.tsx +++ b/src/app/admin/AdminSidebar.tsx @@ -9,6 +9,7 @@ const navItems = [ { href: "/admin/users", label: "사용자" }, { href: "/admin/logs", label: "로그" }, { href: "/admin/banners", label: "배너" }, + { href: "/admin/mainpage-settings", label: "메인페이지 설정" }, ]; export default function AdminSidebar() { diff --git a/src/app/admin/boards/page.tsx b/src/app/admin/boards/page.tsx index 49b692e..362ea2f 100644 --- a/src/app/admin/boards/page.tsx +++ b/src/app/admin/boards/page.tsx @@ -1,11 +1,14 @@ "use client"; import useSWR from "swr"; import { useMemo, useState, useEffect, useRef } from "react"; +import { useToast } from "@/app/components/ui/ToastProvider"; const fetcher = (url: string) => fetch(url).then((r) => r.json()); export default function AdminBoardsPage() { + const { show } = useToast(); const { data: boardsResp, mutate: mutateBoards } = useSWR<{ boards: any[] }>("/api/admin/boards", fetcher); + const { data: vtResp, mutate: mutateVt } = useSWR<{ items: any[] }>("/api/admin/view-types", fetcher); const { data: catsResp, mutate: mutateCats } = useSWR<{ categories: any[] }>("/api/admin/categories", fetcher); const rawBoards = boardsResp?.boards ?? []; const rawCategories = catsResp?.categories ?? []; @@ -19,9 +22,17 @@ export default function AdminBoardsPage() { const [catOrder, setCatOrder] = useState([]); const [draggingCatIndex, setDraggingCatIndex] = useState(null); const catRefs = useRef>({}); + const [boardOrderByCat, setBoardOrderByCat] = useState>({}); + const [draggingBoard, setDraggingBoard] = useState<{ catId: string; index: number } | null>(null); + const boardRefs = useRef>({}); const boards = useMemo(() => { return rawBoards.map((b: any) => ({ ...b, ...(dirtyBoards[b.id] ?? {}) })); }, [rawBoards, dirtyBoards]); + const viewTypes = vtResp?.items ?? []; + const mainTypes = useMemo(() => viewTypes.filter((v: any) => v.scope === 'main'), [viewTypes]); + 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 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)); @@ -41,7 +52,21 @@ export default function AdminBoardsPage() { if (!map[cid]) map[cid] = []; map[cid].push(b); } - return orderedCats.map((c: any) => ({ ...c, items: (map[c.id] ?? []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) })); + const result = orderedCats.map((c: any) => { + const itemsSorted = (map[c.id] ?? []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)); + const custom = boardOrderByCat[c.id]; + if (!custom || custom.length === 0) return { ...c, items: itemsSorted }; + const byId = new Map(itemsSorted.map((x: any) => [x.id, x])); + const ordered = custom.map((id) => byId.get(id)).filter(Boolean) as any[]; + const missing = itemsSorted.filter((x: any) => !custom.includes(x.id)); + return { ...c, items: [...ordered, ...missing] }; + }); + // 미분류(카테고리 없음) 그룹을 마지막에 추가 + const uncat = (map["uncat"] ?? []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)); + if (uncat.length) { + result.push({ id: "uncat", name: "미분류", slug: "uncategorized", items: uncat }); + } + return result; }, [boards, orderedCats]); // 최초/데이터 변경 시 표시용 카테고리 순서를 초기화 @@ -104,7 +129,7 @@ export default function AdminBoardsPage() { const updated: Record = { ...prev }; nextItems.forEach((b, idx) => { const targetSort = idx + 1; - const targetCat = categoryId; + const targetCat = categoryId === "uncat" ? null : categoryId; const baseVal = base.get(b.id) ?? { sortOrder: b.sortOrder ?? 0, categoryId: b.categoryId }; if (baseVal.sortOrder !== targetSort || baseVal.categoryId !== targetCat) { updated[b.id] = { ...(updated[b.id] ?? {}), sortOrder: targetSort, categoryId: targetCat }; @@ -163,6 +188,72 @@ export default function AdminBoardsPage() { } } + // 생성/삭제 액션들 + async function createCategory() { + const name = prompt("대분류 이름을 입력하세요"); + if (!name) return; + const slug = prompt("대분류 slug을 입력하세요(영문)"); + if (!slug) return; + const dupName = categories.some((c: any) => c.name === name); + const dupSlug = categories.some((c: any) => c.slug === slug); + if (dupName || dupSlug) { + show(dupName ? "대분류 이름이 중복입니다." : "대분류 slug가 중복입니다."); + return; + } + await fetch(`/api/admin/categories`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name, slug, sortOrder: categories.length + 1, status: "active" }) }); + await mutateCats(); + } + + async function deleteCategory(id: string) { + if (!confirm("대분류를 삭제하시겠습니까? 소분류의 카테고리는 해제됩니다.")) return; + await fetch(`/api/admin/categories/${id}`, { method: "DELETE" }); + await mutateCats(); + } + + async function createBoard(catId: string, currentItems: any[]) { + const name = prompt("소분류(게시판) 이름을 입력하세요"); + if (!name) return; + const slug = prompt("소분류 slug을 입력하세요(영문)"); + if (!slug) return; + const dupName = boards.some((b: any) => b.name === name); + const dupSlug = boards.some((b: any) => b.slug === slug); + if (dupName || dupSlug) { + show(dupName ? "게시판 이름이 중복입니다." : "게시판 slug가 중복입니다."); + 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 mutateBoards(); + } + + async function deleteBoard(id: string) { + if (!confirm("해당 소분류(게시판)를 삭제하시겠습니까?")) return; + await fetch(`/api/admin/boards/${id}`, { method: "DELETE" }); + await mutateBoards(); + } + + async function moveBoardToCategory(boardId: string, toCategoryId: string) { + try { + const target = boards.find((x: any) => x.id === boardId); + if (!target) return; + const targetGroup = groups.find((g: any) => g.id === toCategoryId); + // 미분류로 이동 시 uncat 그룹 기준으로 정렬 순서 계산 + const nextOrder = (toCategoryId === 'uncat' + ? (groups.find((g: any) => g.id === 'uncat')?.items?.length ?? 0) + : (targetGroup?.items?.length ?? 0)) + 1; + const res = await fetch(`/api/admin/boards/${boardId}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ categoryId: toCategoryId === 'uncat' ? null : toCategoryId, sortOrder: nextOrder }), + }); + if (!res.ok) throw new Error("move_failed"); + await mutateBoards(); + show("이동되었습니다."); + } catch { + show("이동 중 오류가 발생했습니다."); + } + } + return (

게시판 관리

@@ -176,7 +267,14 @@ export default function AdminBoardsPage() { {/* 대분류(헤더)와 소분류(보드 테이블)를 하나의 리스트로 통합, 대분류별 토글 */}
-
대분류 및 소분류
+
+ 게시판 리스트 + +
    { @@ -229,19 +327,50 @@ export default function AdminBoardsPage() { >
    {idx + 1}
    - markCatDirty(g.id, { ...payload })} onDragStart={() => { - setDraggingCatIndex(idx); - }} /> + {g.id === 'uncat' ? ( +
    +
    +
    미분류 (카테고리 없음)
    +
    + ) : ( + markCatDirty(g.id, { ...payload })} onDragStart={() => { + setDraggingCatIndex(idx); + }} /> + )} + {g.id !== 'uncat' && ( + <> + + + + )}
    {expanded[g.id] && (
    +
    + +
    @@ -249,6 +378,8 @@ export default function AdminBoardsPage() { + + @@ -256,22 +387,81 @@ export default function AdminBoardsPage() { - + + + - + { + e.preventDefault(); + if (!draggingBoard || draggingBoard.catId !== g.id) return; + const ids = (boardOrderByCat[g.id]?.length ? boardOrderByCat[g.id] : g.items.map((x: any) => x.id)); + let overIdx = -1; + for (let i = 0; i < ids.length; i++) { + const el = boardRefs.current[ids[i]]; + if (!el) continue; + const rect = el.getBoundingClientRect(); + const mid = rect.top + rect.height / 2; + if (e.clientY < mid) { overIdx = i; break; } + } + if (overIdx === -1) overIdx = ids.length - 1; + if (overIdx === draggingBoard.index) return; + setBoardOrderByCat((prev) => { + const base = (prev[g.id]?.length ? prev[g.id] : ids); + const next = [...base]; + const [moved] = next.splice(draggingBoard.index, 1); + next.splice(overIdx, 0, moved); + setDraggingBoard({ catId: g.id, index: overIdx }); + const nextItems = next.map((id) => g.items.find((x: any) => x.id === id)).filter(Boolean) as any[]; + reorderBoards(g.id, nextItems); + return { ...prev, [g.id]: next }; + }); + }} + onDrop={(e) => { + e.preventDefault(); + const ids = (boardOrderByCat[g.id]?.length ? boardOrderByCat[g.id] : g.items.map((x: any) => x.id)); + const nextItems = ids.map((id) => g.items.find((x: any) => x.id === id)).filter(Boolean) as any[]; + reorderBoards(g.id, nextItems); + setDraggingBoard(null); + }} + > {g.items.map((b, i) => ( { - const list = [...g.items]; - const [mv] = list.splice(from, 1); - list.splice(to, 0, mv); - reorderBoards(g.id, list); - }} + setRef={(el) => { boardRefs.current[b.id] = el; }} + onStart={() => setDraggingBoard({ catId: g.id, index: i })} + onEnd={() => setDraggingBoard(null)} > - markBoardDirty(id, draft)} /> + markBoardDirty(id, draft)} + onDelete={() => deleteBoard(b.id)} + allowMove={true} + categories={g.id === 'uncat' + ? orderedCats + : [{ id: 'uncat', name: '미분류' }, ...orderedCats.filter((c: any) => c.id !== g.id)]} + onMove={(toId) => moveBoardToCategory(b.id, toId)} + mainTypes={mainTypes} + listTypes={listTypes} + defaultMainTypeId={defaultMainTypeId} + defaultListTypeId={defaultListTypeId} + onAddType={async (scope: 'main'|'list') => { + const key = prompt(`${scope === 'main' ? '메인뷰' : '리스트뷰'} key (예: preview)`); + if (!key) return null; + const name = prompt(`${scope === 'main' ? '메인뷰' : '리스트뷰'} 표시 이름`); + if (!name) return null; + const res = await fetch('/api/admin/view-types', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key, name, scope }) }); + if (!res.ok) { show('타입 추가 실패'); return null; } + await mutateVt(); + const data = await res.json().catch(() => ({})); + return data?.item?.id ?? null; + }} + /> ))} @@ -286,12 +476,54 @@ export default function AdminBoardsPage() { ); } -function BoardRowCells({ b, onDirty }: { b: any; onDirty: (id: string, draft: any) => void }) { +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 ?? ''; return ( <> + + - + {allowMove && categories && onMove ? ( + + ) : null} + + ); } -function DraggableRow({ index, onMove, children }: { index: number; onMove: (from: number, to: number) => void; children: React.ReactNode }) { +function DraggableRow({ catId, boardId, index, children, setRef, onStart, onEnd }: { catId: string; boardId: string; index: number; children: React.ReactNode; setRef: (el: HTMLTableRowElement | null) => void; onStart: () => void; onEnd: () => void }) { return ( - { - // 수직만 허용: 기본 동작만 유지하고 수평 제스처는 무시 - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - const current = e.currentTarget as HTMLTableRowElement; - const state = (window as any).__adminDnd || {}; - const ph: HTMLElement | undefined = state.placeholder; - if (!ph || !current.parentElement) return; - const r = current.getBoundingClientRect(); - const before = e.clientY < r.top + r.height / 2; - // 목표만 저장하고 DOM 조작은 프레임당 1회 수행 - state.target = current; - state.before = before; - if (!state.rAF) { - state.rAF = requestAnimationFrame(() => { - const st = (window as any).__adminDnd || {}; - if (!st.placeholder || !st.target || !st.target.parentElement) { st.rAF = 0; return; } - const parent = st.target.parentElement as HTMLElement; - const desiredNode = st.before ? st.target : (st.target.nextSibling as any); - if (desiredNode !== st.placeholder) { - parent.insertBefore(st.placeholder, desiredNode || null); - if (st.dragging) { - const pr = st.placeholder.getBoundingClientRect(); - (st.dragging as HTMLElement).style.top = `${pr.top}px`; - } - } - st.rAF = 0; - }); - } - }} - onDrop={(e) => { - e.preventDefault(); - const from = Number(e.dataTransfer.getData("text/plain")); - const state = (window as any).__adminDnd || {}; - const ph: HTMLElement | undefined = state.placeholder; - let to = index; - if (ph && ph.parentElement) { - to = Array.from(ph.parentElement.children).indexOf(ph); - ph.remove(); - } - if (!Number.isNaN(from) && from !== to) onMove(from, to); - }} - className="align-middle select-none" - > + {children} diff --git a/src/app/admin/mainpage-settings/page.tsx b/src/app/admin/mainpage-settings/page.tsx new file mode 100644 index 0000000..0aec83a --- /dev/null +++ b/src/app/admin/mainpage-settings/page.tsx @@ -0,0 +1,190 @@ +"use client"; +import useSWR from "swr"; +import { useState, useEffect } from "react"; +import { useToast } from "@/app/components/ui/ToastProvider"; +import { Modal } from "@/app/components/ui/Modal"; + +const fetcher = (url: string) => fetch(url).then((r) => r.json()); + +export default function MainPageSettingsPage() { + const { show } = useToast(); + const { data: settingsResp, mutate: mutateSettings } = useSWR<{ settings: any }>("/api/admin/mainpage-settings", fetcher); + const { data: boardsResp } = useSWR<{ boards: any[] }>("/api/admin/boards", fetcher); + const { data: catsResp } = useSWR<{ categories: any[] }>("/api/admin/categories", fetcher); + + const settings = settingsResp?.settings ?? {}; + const boards = boardsResp?.boards ?? []; + const categories = catsResp?.categories ?? []; + + const [saving, setSaving] = useState(false); + const [showBoardModal, setShowBoardModal] = useState(false); + const [draft, setDraft] = useState({ + showBanner: settings.showBanner ?? true, + showPartnerShops: settings.showPartnerShops ?? true, + visibleBoardIds: settings.visibleBoardIds ?? [], + }); + + // settings가 로드되면 draft 동기화 + useEffect(() => { + if (settingsResp?.settings) { + setDraft({ + showBanner: settings.showBanner ?? true, + showPartnerShops: settings.showPartnerShops ?? true, + visibleBoardIds: settings.visibleBoardIds ?? [], + }); + } + }, [settingsResp, settings]); + + async function save() { + setSaving(true); + try { + const res = await fetch("/api/admin/mainpage-settings", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(draft), + }); + if (!res.ok) throw new Error("save_failed"); + const data = await res.json(); + // 저장된 설정으로 즉시 업데이트 + await mutateSettings({ settings: data.settings }, { revalidate: false }); + show("저장되었습니다."); + } catch (e) { + console.error(e); + show("저장 중 오류가 발생했습니다."); + } finally { + setSaving(false); + } + } + + return ( +
    +
    +

    메인페이지 설정

    + +
    + +
    + {/* 배너 표시 */} +
    + +
    + + {/* 제휴 샾 목록 표시 */} +
    + +
    + + {/* 보일 게시판 선택 */} +
    +
    + + +
    +
    + {draft.visibleBoardIds.length === 0 ? ( +

    선택된 게시판이 없습니다.

    + ) : ( + draft.visibleBoardIds.map((boardId: string) => { + const board = boards.find((b: any) => b.id === boardId); + if (!board) return null; + const category = categories.find((c: any) => c.id === board.categoryId); + return ( +
    +
    + {board.name} + {category && ( + ({category.name}) + )} +
    + +
    + ); + }) + )} +
    +
    +
    + + {/* 게시판 선택 모달 */} + setShowBoardModal(false)}> +
    +
    +

    게시판 선택

    +
    + {boards + .filter((b: any) => !draft.visibleBoardIds.includes(b.id)) + .map((b: any) => { + const category = categories.find((c: any) => c.id === b.categoryId); + return ( + + ); + })} + {boards.filter((b: any) => !draft.visibleBoardIds.includes(b.id)).length === 0 && ( +

    추가할 수 있는 게시판이 없습니다.

    + )} +
    +
    + +
    +
    +
    +
    +
    + ); +} + diff --git a/src/app/api/admin/boards/[id]/route.ts b/src/app/api/admin/boards/[id]/route.ts index dc87a7f..2111141 100644 --- a/src/app/api/admin/boards/[id]/route.ts +++ b/src/app/api/admin/boards/[id]/route.ts @@ -5,7 +5,7 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin const { id } = await context.params; const body = await req.json().catch(() => ({})); const data: any = {}; - for (const k of ["name", "slug", "description", "sortOrder", "readLevel", "writeLevel", "allowAnonymousPost", "allowSecretComment", "requiresApproval", "status", "type", "isAdultOnly", "categoryId"]) { + for (const k of ["name", "slug", "description", "sortOrder", "readLevel", "writeLevel", "allowAnonymousPost", "allowSecretComment", "requiresApproval", "status", "type", "isAdultOnly", "categoryId", "mainPageViewTypeId", "listViewTypeId"]) { if (k in body) data[k] = body[k]; } if ("requiredTags" in body) { @@ -18,4 +18,11 @@ export async function PATCH(req: Request, context: { params: Promise<{ id: strin return NextResponse.json({ board: updated }); } +export async function DELETE(req: Request, context: { params: Promise<{ id: string }> }) { + const { id } = await context.params; + // Soft delete: mark as archived instead of physical deletion + const updated = await prisma.board.update({ where: { id }, data: { status: 'archived' } }); + return NextResponse.json({ board: updated }); +} + diff --git a/src/app/api/admin/boards/route.ts b/src/app/api/admin/boards/route.ts index a5014a2..8e2c63f 100644 --- a/src/app/api/admin/boards/route.ts +++ b/src/app/api/admin/boards/route.ts @@ -1,8 +1,10 @@ import { NextResponse } from "next/server"; import prisma from "@/lib/prisma"; +import { z } from "zod"; export async function GET() { const boards = await prisma.board.findMany({ + where: { NOT: { status: 'archived' } }, orderBy: { sortOrder: "asc" }, select: { id: true, @@ -18,10 +20,46 @@ export async function GET() { type: true, status: true, categoryId: true, + mainPageViewTypeId: true, + listViewTypeId: true, category: { select: { id: true, name: true, slug: true } }, }, }); return NextResponse.json({ boards }); } +const createSchema = z.object({ + name: z.string().min(1), + slug: z.string().min(1), + description: z.string().optional(), + sortOrder: z.coerce.number().int().optional(), + readLevel: z.string().optional(), + writeLevel: z.string().optional(), + allowAnonymousPost: z.boolean().optional(), + allowSecretComment: z.boolean().optional(), + requiresApproval: z.boolean().optional(), + status: z.string().optional(), + type: z.string().optional(), + isAdultOnly: z.boolean().optional(), + categoryId: z.string().nullable().optional(), +}); + +export async function POST(req: Request) { + const body = await req.json().catch(() => ({})); + const parsed = createSchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + const data = parsed.data; + // sortOrder 기본: 같은 카테고리 내 마지막 다음 순서 + let sortOrder = data.sortOrder; + if (!sortOrder) { + const max = await prisma.board.aggregate({ + _max: { sortOrder: true }, + where: { categoryId: data.categoryId ?? undefined }, + }); + sortOrder = (max._max.sortOrder ?? 0) + 1; + } + const created = await prisma.board.create({ data: { ...data, sortOrder } }); + return NextResponse.json({ board: created }, { status: 201 }); +} + diff --git a/src/app/api/admin/mainpage-settings/route.ts b/src/app/api/admin/mainpage-settings/route.ts new file mode 100644 index 0000000..b301d80 --- /dev/null +++ b/src/app/api/admin/mainpage-settings/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; + +const SETTINGS_KEY = "mainpage_settings"; + +export async function GET() { + const setting = await prisma.setting.findUnique({ + where: { key: SETTINGS_KEY }, + }); + const settings = setting ? JSON.parse(setting.value) : {}; + return NextResponse.json({ settings }, { headers: { "Cache-Control": "no-store" } }); +} + +export async function POST(req: Request) { + const body = await req.json().catch(() => ({})); + const value = JSON.stringify(body); + const updated = await prisma.setting.upsert({ + where: { key: SETTINGS_KEY }, + update: { value }, + create: { key: SETTINGS_KEY, value }, + }); + const settings = JSON.parse(updated.value); + return NextResponse.json({ settings, ok: true }); +} + diff --git a/src/app/api/admin/view-types/[id]/route.ts b/src/app/api/admin/view-types/[id]/route.ts new file mode 100644 index 0000000..38b0868 --- /dev/null +++ b/src/app/api/admin/view-types/[id]/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; + +export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) { + const { id } = await context.params; + const body = await req.json().catch(() => ({})); + const data: any = {}; + for (const k of ['key', 'name', 'scope']) { if (k in body) data[k] = body[k]; } + const updated = await prisma.boardViewType.update({ where: { id }, data }); + return NextResponse.json({ item: updated }); +} + +export async function DELETE(req: Request, context: { params: Promise<{ id: string }> }) { + const { id } = await context.params; + await prisma.boardViewType.delete({ where: { id } }); + return NextResponse.json({ ok: true }); +} + + diff --git a/src/app/api/admin/view-types/route.ts b/src/app/api/admin/view-types/route.ts new file mode 100644 index 0000000..084daac --- /dev/null +++ b/src/app/api/admin/view-types/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { z } from "zod"; + +export async function GET() { + const items = await prisma.boardViewType.findMany({ orderBy: [{ scope: 'asc' }, { name: 'asc' }] }); + return NextResponse.json({ items }); +} + +const createSchema = z.object({ + key: z.string().min(1), + name: z.string().min(1), + scope: z.string().min(1), // 'main' | 'list' 등 자유 텍스트 +}); + +export async function POST(req: Request) { + const body = await req.json().catch(() => ({})); + const parsed = createSchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); + const exists = await prisma.boardViewType.findFirst({ where: { key: parsed.data.key } }); + if (exists) return NextResponse.json({ error: 'duplicate_key' }, { status: 409 }); + const created = await prisma.boardViewType.create({ data: parsed.data }); + return NextResponse.json({ item: created }, { status: 201 }); +} + + diff --git a/src/app/api/categories/route.ts b/src/app/api/categories/route.ts index 55fe64e..d74c8b9 100644 --- a/src/app/api/categories/route.ts +++ b/src/app/api/categories/route.ts @@ -4,9 +4,11 @@ import prisma from "@/lib/prisma"; // 대분류(BoardCategory)와 소분류(Board)를 함께 반환 export async function GET() { const categories = await prisma.boardCategory.findMany({ + where: { status: "active" }, orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], include: { boards: { + where: { status: "active" }, orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], select: { id: true, name: true, slug: true, requiresApproval: true, type: true }, },
    # 이름 slug메인뷰리스트뷰 읽기 쓰기 익명승인 유형 성인대분류 이동활성삭제
    { 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, isAdultOnly: e.target.checked }; setEdit(v); onDirty(b.id, v); }} /> + + { const v = { ...edit, status: e.target.checked ? 'active' : 'hidden' }; setEdit(v); onDirty(b.id, v); }} /> + +
    { e.dataTransfer.setData("text/plain", String(index)); - e.dataTransfer.effectAllowed = "move"; - const row = (e.currentTarget as HTMLElement).closest('tr') as HTMLTableRowElement; - const rect = row.getBoundingClientRect(); - const table = row.closest('table') as HTMLElement | null; - const tableRect = table?.getBoundingClientRect(); - const placeholder = document.createElement('tr'); - placeholder.style.height = `${rect.height}px`; - (row.parentNode as HTMLElement).insertBefore(placeholder, row); - (window as any).__adminDnd = { placeholder, dragging: row, target: null, before: false, rAF: 0 }; - const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, v)); - const offsetY = e.clientY - rect.top; - row.style.position = 'fixed'; - row.style.left = `${tableRect ? tableRect.left : rect.left}px`; - row.style.width = `${tableRect ? tableRect.width : rect.width}px`; - row.style.zIndex = '9999'; - row.classList.add('bg-white'); - const updatePos = (clientY: number) => { - const top = clamp(clientY - offsetY, (tableRect?.top ?? 0), (tableRect?.bottom ?? (rect.top + rect.height)) - rect.height); - row.style.top = `${top}px`; - }; - updatePos(e.clientY); - const img = document.createElement('canvas'); - img.width = 1; img.height = 1; const ctx = img.getContext('2d'); ctx?.clearRect(0,0,1,1); - e.dataTransfer.setDragImage(img, 0, 0); - const onDragOver = (ev: DragEvent) => { if (typeof ev.clientY === 'number') updatePos(ev.clientY); }; - const cleanup = () => { - row.style.position = ''; - row.style.left = ''; - row.style.top = ''; - row.style.width = ''; - row.style.zIndex = ''; - row.classList.remove('bg-white'); - placeholder.remove(); - window.removeEventListener('dragover', onDragOver, true); - window.removeEventListener('dragend', cleanup, true); - const st = (window as any).__adminDnd; if (st?.rAF) cancelAnimationFrame(st.rAF); - (window as any).__adminDnd = undefined; - }; - window.addEventListener('dragover', onDragOver, true); - window.addEventListener('dragend', cleanup, true); + e.dataTransfer.effectAllowed = 'move'; + const img = document.createElement('canvas'); img.width = 1; img.height = 1; e.dataTransfer.setDragImage(img, 0, 0); + onStart(); }} + onDragEnd={() => { onEnd(); }} >≡ {index + 1}