Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a3fce2130 | |||
| 1d8e8581b8 | |||
| 036fc84fa6 | |||
| 472b511b89 | |||
| 6f8de5adf3 |
@@ -93,13 +93,21 @@ function canManageAdminRole(actingUser, primaryAdmin) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
router.post('/games', requireAdmin, async (req, res) => {
|
router.post('/games', requireAdmin, async (req, res) => {
|
||||||
const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) })
|
const schema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
name: z.string().min(1).max(60),
|
||||||
|
thumbnailSrc: z.string().max(255).optional().default(''),
|
||||||
|
})
|
||||||
const parsed = schema.safeParse(req.body)
|
const parsed = schema.safeParse(req.body)
|
||||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||||
const exists = await findGameById(parsed.data.id)
|
const exists = await findGameById(parsed.data.id)
|
||||||
if (exists) return res.status(409).json({ error: 'game_id_taken' })
|
if (exists) return res.status(409).json({ error: 'game_id_taken' })
|
||||||
const game = await createGame({ id: parsed.data.id, name: parsed.data.name })
|
const game = await createGame({ id: parsed.data.id, name: parsed.data.name })
|
||||||
res.json({ game })
|
if (parsed.data.thumbnailSrc) {
|
||||||
|
const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc)
|
||||||
|
await updateGameThumbnail(game.id, copiedThumb)
|
||||||
|
}
|
||||||
|
res.json({ game: await findGameById(game.id) })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.patch('/games/display-order', requireAdmin, async (req, res) => {
|
router.patch('/games/display-order', requireAdmin, async (req, res) => {
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.55
|
||||||
|
- 관리자 요청/업로드 배지는 문구만 다르면 빠르게 구분하기 어려우므로, 같은 `pill` 구조를 유지하되 색으로도 역할을 나누는 편이 운영 판단에 더 적합하다고 정리했다.
|
||||||
|
- 신규 템플릿 요청으로 새 게임을 만들 때는 아이템만 가져오고 썸네일이 비어 있으면 식별성이 떨어지므로, 요청 썸네일도 기본값으로 함께 승계하는 편이 맞다고 판단했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.54
|
||||||
|
- 관리자 요청 카드는 운영자가 이미 흐름을 알고 있다는 전제에서, 설명형 힌트보다 즉시 판단에 필요한 메타와 액션만 남기는 편이 더 적합하다고 정리했다.
|
||||||
|
- 요청 종류 표시는 중복 텍스트보다 오른쪽 상단의 짧은 상태 배지 하나로 고정하고, 하단 액션 줄은 `보조 링크는 왼쪽 / 실제 처리 버튼은 오른쪽` 구조가 더 읽기 쉽다고 판단했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.53
|
||||||
|
- 관리자 후속 리팩터링은 남은 큰 액션 묶음인 `상단 고정 게임 정렬`과 `커스텀 아이템 검수`부터 composable로 분리하는 편이 `AdminView.vue` 체감 복잡도를 가장 빨리 낮춘다고 판단했다.
|
||||||
|
- 이 단계에서도 레이아웃이나 문구보다 로직 책임 경계를 먼저 옮기고, 실제 스타일 파일 분리는 그 다음 단계로 이어가는 편이 안전하다고 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.52
|
||||||
|
- 관리자 화면은 본문을 컴포넌트로 나눈 뒤에도 같은 시각 문법을 유지해야 하므로, `scoped`를 유지한 채 각 섹션에 스타일을 복붙하기보다 관리자 범위 공통 스타일로 다시 묶는 편이 더 안전하다고 정리했다.
|
||||||
|
- `템플릿 요청 관리 / 전체 티어표 관리` 내부 모드 값은 URL과 버튼 상태가 어긋나지 않도록 `all` 하나로 통일하는 편이 맞다고 판단했다.
|
||||||
|
- 릴리스 기록은 문서 버전만 올라가고 태그가 빠지면 추적이 끊기므로, 뒤늦게라도 누락 태그를 다시 맞춰 버전 흐름을 복구하는 편이 낫다고 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.51
|
||||||
|
- 관리자 리팩터링은 본문 분리 다음 단계에서 `회원 관리`처럼 모달과 부수 액션이 많은 영역을 composable로 떼어내는 편이 효과가 크다고 판단했다.
|
||||||
|
- 이 단계에서는 UI 문구나 사용자가 이미 손본 CSS를 다시 건드리기보다, 현재 동작을 유지한 채 책임 경계만 옮기는 쪽이 더 안전하다고 정리했다.
|
||||||
|
|
||||||
## 2026-04-02 v1.3.50
|
## 2026-04-02 v1.3.50
|
||||||
- 템플릿 요청 카드는 게임 이름/ID만 남기기보다 대표 썸네일까지 유지하는 편이 운영자가 요청 대상을 훨씬 빨리 구분할 수 있다고 정리했다.
|
- 템플릿 요청 카드는 게임 이름/ID만 남기기보다 대표 썸네일까지 유지하는 편이 운영자가 요청 대상을 훨씬 빨리 구분할 수 있다고 정리했다.
|
||||||
- 템플릿 요청의 `확인하기`는 단순히 해당 게임을 선택하는 동작이 아니라, 게임 관리 화면에서 요청 아이템 후보가 실제 작업 상태로 복원되어야 한다고 판단했다.
|
- 템플릿 요청의 `확인하기`는 단순히 해당 게임을 선택하는 동작이 아니라, 게임 관리 화면에서 요청 아이템 후보가 실제 작업 상태로 복원되어야 한다고 판단했다.
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
## 중기 개선
|
## 중기 개선
|
||||||
- 관리자 URL 분리는 시작했으므로, 다음 단계에서는 `AdminView.vue` 단일 대형 파일을 섹션별 뷰/컴포저블로 쪼개고 직접 진입 시 선택 게임/요청 작업 상태 복원 범위도 함께 정리한다.
|
- 관리자 URL 분리는 시작했으므로, 다음 단계에서는 `AdminView.vue` 단일 대형 파일을 섹션별 뷰/컴포저블로 쪼개고 직접 진입 시 선택 게임/요청 작업 상태 복원 범위도 함께 정리한다.
|
||||||
- 관리자 본문 컴포넌트 분리는 시작했으므로, 다음 단계에서는 `AdminView.vue`에 남아 있는 상태/액션도 `useAdmin*` composable 단위로 분리해 실제 로직 결합도를 줄인다.
|
- 관리자 본문 컴포넌트 분리와 `게임/템플릿 요청/회원 관리/아이템 관리/목록 관리` composable 분리는 시작했으므로, 다음 단계에서는 공통 모달 상태를 어느 계층에서 소유할지 정리하고 남은 관리자 유틸 함수를 더 줄인다.
|
||||||
- 관리자 게임/템플릿 요청 composable 분리는 시작했으므로, 다음 단계에서는 회원/아이템/목록 관리도 같은 기준으로 정리하고 공통 모달 상태를 어느 계층에서 소유할지 정리한다.
|
- 관리자 화면은 섹션 경로 분리까지 끝났으므로, 다음 단계에서는 `AdminView.vue`를 실제 레이아웃 뷰와 섹션별 라우트 컴포넌트로 더 쪼갤지 결정한다.
|
||||||
|
- 관리자 공통 스타일은 `adminUiScope` 기준으로 다시 묶었으므로, 다음 단계에서는 각 섹션을 별도 파일로 완전히 분리할 때 스타일도 `admin.css` 또는 섹션별 스타일로 옮길지 결정한다.
|
||||||
|
- 관리자 요청 카드 밀도는 줄였으므로, 다음 단계에서는 전체 티어표 카드와 요청 카드의 상단/하단 액션 정렬을 한 번 더 통일할지 비교 QA한다.
|
||||||
|
- 신규 템플릿 요청 썸네일 기본 승계는 붙였으므로, 다음 단계에서는 요청 아이템 반영 후 `처리 완료`까지의 관리자 흐름을 실제 데이터로 한 번 더 QA한다.
|
||||||
- 관리자 게임 아이템 순서 저장은 추가됐으므로, 다음 단계에서는 새 아이템 추가 직후 `자동 맨 앞 배치`와 `관리자 수동 고정 순서`의 우선순위를 실제 운영 흐름 기준으로 한 번 더 QA한다.
|
- 관리자 게임 아이템 순서 저장은 추가됐으므로, 다음 단계에서는 새 아이템 추가 직후 `자동 맨 앞 배치`와 `관리자 수동 고정 순서`의 우선순위를 실제 운영 흐름 기준으로 한 번 더 QA한다.
|
||||||
- 관리자 템플릿 요청 미리보기는 실제 완성본 iframe 방식과의 체감 차이를 마지막으로 한 번 더 QA한다.
|
- 관리자 템플릿 요청 미리보기는 실제 완성본 iframe 방식과의 체감 차이를 마지막으로 한 번 더 QA한다.
|
||||||
- 라이트모드/다크모드 2차 보정까지 반영했으므로, 남은 작업은 전체 화면을 실제 사용 흐름으로 돌려 보며 대비·명도·아이콘 가독성을 미세하게 QA하는 최종 테마 점검 단계로 가져간다.
|
- 라이트모드/다크모드 2차 보정까지 반영했으므로, 남은 작업은 전체 화면을 실제 사용 흐름으로 돌려 보며 대비·명도·아이콘 가독성을 미세하게 QA하는 최종 테마 점검 단계로 가져간다.
|
||||||
|
|||||||
@@ -1,5 +1,30 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.55
|
||||||
|
- 관리자 요청 카드 오른쪽 상단의 `신규 템플릿 / 보유 템플릿` 배지는 서로 다른 색상으로 분리해, 카드 타입을 텍스트보다 더 빠르게 구분할 수 있게 조정함.
|
||||||
|
- 게임 관리의 기본 아이템 추가 미리보기에서도 `요청 아이템 / 직접 추가 파일` 배지를 서로 다른 색상으로 구분해, 요청 반영분과 직접 업로드분이 한눈에 섞이지 않도록 정리함.
|
||||||
|
- 신규 템플릿 요청에서 `새 게임 만들기`를 진행할 때는 요청 티어표 대표 썸네일도 함께 새 게임 썸네일로 복사되도록 보강해, 관리자가 이후 수정하더라도 초기 식별용 썸네일은 바로 이어받을 수 있게 함.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.54
|
||||||
|
- 관리자 `티어표 관리` 요청 카드에서는 사용법 힌트 문구와 중복 타입 텍스트를 제거해, 카드 본문이 관리 정보만 더 빠르게 읽히도록 정리함.
|
||||||
|
- `신규 템플릿 / 보유 템플릿` 구분은 카드 오른쪽 상단의 별도 배지로 옮기고, 기존 `추가 아이템 / 확인함 여부` 배지는 그대로 유지해 정보 계층을 더 단순하게 맞춤.
|
||||||
|
- `요청 티어표 보기` 링크는 하단 액션 줄의 왼쪽으로 옮기고 `확인하기 / 처리 완료` 버튼은 오른쪽에 정렬해, 실제 작업 버튼과 보조 링크의 역할이 한 줄 안에서도 분명하게 보이도록 조정함.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.53
|
||||||
|
- 관리자 리팩터링 4차로 `목록 관리` 정렬 로직과 `아이템 관리` 모달/삭제/승격 액션을 각각 `useAdminFeaturedGames`, `useAdminCustomItems` composable로 분리해 `AdminView.vue`의 직접 액션 코드를 더 줄임.
|
||||||
|
- 따라서 관리자 메인 뷰는 섹션 연결과 공통 상태 중심으로 더 가까워졌고, 상단 고정 게임 정렬과 커스텀 아이템 처리 흐름은 각 영역 책임에 맞는 파일로 옮겨 유지보수 범위를 좁힘.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.52
|
||||||
|
- 관리자 본문 섹션을 컴포넌트로 나눈 뒤 `AdminView.vue` 스타일이 `scoped`에 묶여 자식 컴포넌트까지 제대로 닿지 않던 문제를 정리하고, 관리자 전용 공통 스타일을 `adminUiScope` 범위로 다시 묶어 각 페이지 CSS가 함께 살아나도록 보강함.
|
||||||
|
- 템플릿 요청 카드의 신규 게임 입력 영역에는 `게임 이름 / 게임 ID` 필드 스타일을 다시 붙여, 요청 카드만 따로 풀린 것처럼 보이던 레이아웃을 복구함.
|
||||||
|
- 관리자 사이드바의 `전체 티어표 관리` 모드는 내부 값이 `lists`와 `all`로 엇갈리던 상태를 `all` 기준으로 통일해, 버튼 활성 상태와 실제 목록 전환이 어긋나지 않게 정리함.
|
||||||
|
- 운영 이력 정합성을 위해 누락돼 있던 릴리스 태그도 다시 점검하고, `v1.3.50`, `v1.3.51`, `v1.3.52` 흐름으로 이어서 관리함.
|
||||||
|
|
||||||
|
## 2026-04-02 v1.3.51
|
||||||
|
- 관리자 리팩터링 3차로 회원 관리 액션을 `useAdminUsers` composable로 분리해, 아바타 변경, 회원 정보 수정, 비밀번호 초기화, 권한 변경, 삭제 모달 흐름을 `AdminView.vue` 밖으로 옮김.
|
||||||
|
- 따라서 관리자 메인 뷰는 섹션 연결과 공통 상태에 더 집중하고, 회원 관리 로직은 다른 관리자 영역과 같은 composable 분리 기준으로 맞추기 시작함.
|
||||||
|
- 이번 정리에서도 관리자 화면에 직접 반영돼 있던 텍스트와 게임 관리 CSS 수정분은 유지한 채 구조만 옮기도록 정리함.
|
||||||
|
|
||||||
## 2026-04-02 v1.3.50
|
## 2026-04-02 v1.3.50
|
||||||
- 관리자 `템플릿 요청 관리` 카드에서는 대표 썸네일을 다시 복구해 게임 이름/ID와 함께 요청 대상을 더 빠르게 식별할 수 있게 정리함.
|
- 관리자 `템플릿 요청 관리` 카드에서는 대표 썸네일을 다시 복구해 게임 이름/ID와 함께 요청 대상을 더 빠르게 식별할 수 있게 정리함.
|
||||||
- `확인하기` 후 게임을 불러오면서 요청 아이템 임시 목록이 비워지던 흐름을 수정하고, 신규 게임 생성 직후에도 요청 아이템이 기본 아이템 추가 미리보기에 유지되도록 보강함.
|
- `확인하기` 후 게임을 불러오면서 요청 아이템 임시 목록이 비워지던 흐름을 수정하고, 신규 게임 생성 직후에도 요청 아이템이 기본 아이템 추가 미리보기에 유지되도록 보강함.
|
||||||
|
|||||||
@@ -119,7 +119,9 @@ const props = defineProps({
|
|||||||
<input v-model="draft.label" class="input input--labelEdit input--dense" maxlength="60" placeholder="아이템 이름" />
|
<input v-model="draft.label" class="input input--labelEdit input--dense" maxlength="60" placeholder="아이템 이름" />
|
||||||
<div class="hint hint--tight">{{ draft.sourceName }}</div>
|
<div class="hint hint--tight">{{ draft.sourceName }}</div>
|
||||||
<div class="itemDraftRow__meta">
|
<div class="itemDraftRow__meta">
|
||||||
<span class="pill pill--soft">{{ draft.kind === 'request' ? '요청 아이템' : '직접 추가 파일' }}</span>
|
<span class="pill" :class="draft.kind === 'request' ? 'pill--requestItem' : 'pill--directFile'">
|
||||||
|
{{ draft.kind === 'request' ? '요청 아이템' : '직접 추가 파일' }}
|
||||||
|
</span>
|
||||||
<button class="btn btn--danger btn--small" type="button" @click="props.removeUploadDraft(draft)">제외</button>
|
<button class="btn btn--danger btn--small" type="button" @click="props.removeUploadDraft(draft)">제외</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ const props = defineProps({
|
|||||||
tierlistsMode: { type: String, required: true },
|
tierlistsMode: { type: String, required: true },
|
||||||
templateRequests: { type: Array, required: true },
|
templateRequests: { type: Array, required: true },
|
||||||
openTemplateRequestPreview: { type: Function, required: true },
|
openTemplateRequestPreview: { type: Function, required: true },
|
||||||
templateRequestTypeLabel: { type: Function, required: true },
|
|
||||||
fmt: { type: Function, required: true },
|
fmt: { type: Function, required: true },
|
||||||
templateRequestTargetLabel: { type: Function, required: true },
|
templateRequestTargetLabel: { type: Function, required: true },
|
||||||
templateRequestStatusLabel: { type: Function, required: true },
|
templateRequestStatusLabel: { type: Function, required: true },
|
||||||
templateRequestSourceUrl: { type: Function, required: true },
|
templateRequestSourceUrl: { type: Function, required: true },
|
||||||
templateRequestReviewHint: { type: Function, required: true },
|
|
||||||
startTemplateRequestReview: { type: Function, required: true },
|
startTemplateRequestReview: { type: Function, required: true },
|
||||||
completeTemplateRequest: { type: Function, required: true },
|
completeTemplateRequest: { type: Function, required: true },
|
||||||
adminTierLists: { type: Array, required: true },
|
adminTierLists: { type: Array, required: true },
|
||||||
@@ -32,7 +30,6 @@ const props = defineProps({
|
|||||||
<div class="sectionHeader">
|
<div class="sectionHeader">
|
||||||
<div>
|
<div>
|
||||||
<div class="panel__title">사용자 요청</div>
|
<div class="panel__title">사용자 요청</div>
|
||||||
<div class="hint hint--tight">요청 카드는 미확인/확인함 상태로 관리하고, 실제 아이템 반영은 게임 관리 화면에서 직접 진행합니다. 처리 완료를 눌러야 카드가 목록에서 빠져요.</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -67,10 +64,16 @@ const props = defineProps({
|
|||||||
<div class="tierAdminCard__body">
|
<div class="tierAdminCard__body">
|
||||||
<div class="tierAdminCard__head">
|
<div class="tierAdminCard__head">
|
||||||
<div>
|
<div>
|
||||||
|
<span
|
||||||
|
class="pill templateRequestCard__cornerBadge"
|
||||||
|
:class="request.type === 'create' ? 'pill--create' : 'pill--owned'"
|
||||||
|
>
|
||||||
|
{{ request.type === 'create' ? '신규 템플릿' : '보유 템플릿' }}
|
||||||
|
</span>
|
||||||
<div class="tierAdminCard__title">{{ request.sourceTierListTitle }}</div>
|
<div class="tierAdminCard__title">{{ request.sourceTierListTitle }}</div>
|
||||||
<div v-if="request.sourceDescription" class="tierAdminCard__desc">{{ request.sourceDescription }}</div>
|
<div v-if="request.sourceDescription" class="tierAdminCard__desc">{{ request.sourceDescription }}</div>
|
||||||
<div class="tierAdminCard__meta">
|
<div class="tierAdminCard__meta">
|
||||||
{{ props.templateRequestTypeLabel(request) }} · {{ request.requesterName }} · {{ props.fmt(request.createdAt) }}
|
{{ request.requesterName }} · {{ props.fmt(request.createdAt) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="tierAdminCard__meta">{{ props.templateRequestTargetLabel(request) }}</div>
|
<div class="tierAdminCard__meta">{{ props.templateRequestTargetLabel(request) }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,7 +81,6 @@ const props = defineProps({
|
|||||||
|
|
||||||
<div class="tierAdminCard__stats">
|
<div class="tierAdminCard__stats">
|
||||||
<span class="pill">추가 아이템 {{ request.items?.length || 0 }}개</span>
|
<span class="pill">추가 아이템 {{ request.items?.length || 0 }}개</span>
|
||||||
<span class="pill">{{ request.type === 'create' ? '새 템플릿' : '기존 템플릿 업데이트' }}</span>
|
|
||||||
<span class="pill" :class="{ 'pill--accent': request.status === 'reviewing' }">{{ props.templateRequestStatusLabel(request) }}</span>
|
<span class="pill" :class="{ 'pill--accent': request.status === 'reviewing' }">{{ props.templateRequestStatusLabel(request) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -89,24 +91,24 @@ const props = defineProps({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="templateRequestCard__links">
|
<div class="templateRequestCard__footer">
|
||||||
<a
|
<div class="templateRequestCard__footerLeft">
|
||||||
v-if="props.templateRequestSourceUrl(request)"
|
<a
|
||||||
class="btn btn--ghost btn--small"
|
v-if="props.templateRequestSourceUrl(request)"
|
||||||
:href="props.templateRequestSourceUrl(request)"
|
class="btn btn--ghost btn--small"
|
||||||
target="_blank"
|
:href="props.templateRequestSourceUrl(request)"
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
>
|
rel="noreferrer"
|
||||||
요청 티어표 보기
|
>
|
||||||
</a>
|
요청 티어표 보기
|
||||||
<div class="hint hint--tight">{{ props.templateRequestReviewHint(request) }}</div>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="templateRequestCard__actions">
|
||||||
<div class="templateRequestCard__actions">
|
<button class="btn btn--primary" :disabled="request.isHandling" @click="props.startTemplateRequestReview(request)">
|
||||||
<button class="btn btn--primary" :disabled="request.isHandling" @click="props.startTemplateRequestReview(request)">
|
{{ request.isHandling ? '이동중...' : '확인하기' }}
|
||||||
{{ request.isHandling ? '이동중...' : '확인하기' }}
|
</button>
|
||||||
</button>
|
<button class="btn btn--ghost" :disabled="request.isHandling || request.status !== 'reviewing'" @click="props.completeTemplateRequest(request)">처리 완료</button>
|
||||||
<button class="btn btn--ghost" :disabled="request.isHandling || request.status !== 'reviewing'" @click="props.completeTemplateRequest(request)">처리 완료</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -117,7 +119,6 @@ const props = defineProps({
|
|||||||
<div class="sectionHeader">
|
<div class="sectionHeader">
|
||||||
<div>
|
<div>
|
||||||
<div class="panel__title">전체 티어표 관리</div>
|
<div class="panel__title">전체 티어표 관리</div>
|
||||||
<div class="hint hint--tight">공개/비공개를 포함한 최근 티어표를 모두 확인하고, 추가 아이템을 기존 게임 템플릿으로 승격하거나 커스텀 티어표를 새 게임 템플릿으로 만들 수 있어요. 여기는 요청 목록과 별개로 전체 저장 티어표를 보는 영역입니다.</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
203
frontend/src/composables/useAdminCustomItems.js
Normal file
203
frontend/src/composables/useAdminCustomItems.js
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { nextTick } from 'vue'
|
||||||
|
|
||||||
|
export function useAdminCustomItems({
|
||||||
|
api,
|
||||||
|
toast,
|
||||||
|
customItems,
|
||||||
|
customItemPage,
|
||||||
|
customItemLimit,
|
||||||
|
customItemPageCount,
|
||||||
|
customItemQuery,
|
||||||
|
customItemOrphanOnly,
|
||||||
|
customItemModalOpen,
|
||||||
|
customItemDeleteModalOpen,
|
||||||
|
customItemModalHistoryActive,
|
||||||
|
modalTargetCustomItem,
|
||||||
|
customItemModalDraftLabel,
|
||||||
|
customItemModalLabelSaving,
|
||||||
|
customItemModalTargetGameId,
|
||||||
|
customItemModalGameQuery,
|
||||||
|
customItemModalGameSort,
|
||||||
|
games,
|
||||||
|
selectedGameId,
|
||||||
|
refreshCustomItems,
|
||||||
|
loadGame,
|
||||||
|
setTab,
|
||||||
|
selectAdminGame,
|
||||||
|
resetMessages,
|
||||||
|
success,
|
||||||
|
error,
|
||||||
|
}) {
|
||||||
|
function submitCustomItemSearch() {
|
||||||
|
customItemPage.value = 1
|
||||||
|
refreshCustomItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCustomItemOrphanOnly() {
|
||||||
|
customItemPage.value = 1
|
||||||
|
refreshCustomItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeCustomItemLimit(limit) {
|
||||||
|
customItemLimit.value = limit
|
||||||
|
customItemPage.value = 1
|
||||||
|
refreshCustomItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveCustomItemPage(direction) {
|
||||||
|
const nextPage = customItemPage.value + direction
|
||||||
|
if (nextPage < 1 || nextPage > customItemPageCount.value) return
|
||||||
|
customItemPage.value = nextPage
|
||||||
|
refreshCustomItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushCustomItemModalHistoryState() {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.history.pushState({ ...(window.history.state || {}), adminCustomItemModal: true }, '', window.location.href)
|
||||||
|
customItemModalHistoryActive.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCustomItemModal(item) {
|
||||||
|
modalTargetCustomItem.value = item || null
|
||||||
|
customItemModalDraftLabel.value = item?.label || ''
|
||||||
|
customItemModalTargetGameId.value = ''
|
||||||
|
customItemModalGameQuery.value = ''
|
||||||
|
customItemModalGameSort.value = 'recent'
|
||||||
|
customItemModalOpen.value = true
|
||||||
|
pushCustomItemModalHistoryState()
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCustomItemModal({ fromPopState = false } = {}) {
|
||||||
|
customItemModalOpen.value = false
|
||||||
|
customItemDeleteModalOpen.value = false
|
||||||
|
modalTargetCustomItem.value = null
|
||||||
|
customItemModalDraftLabel.value = ''
|
||||||
|
customItemModalLabelSaving.value = false
|
||||||
|
customItemModalTargetGameId.value = ''
|
||||||
|
customItemModalGameQuery.value = ''
|
||||||
|
customItemModalGameSort.value = 'recent'
|
||||||
|
|
||||||
|
if (fromPopState) {
|
||||||
|
customItemModalHistoryActive.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customItemModalHistoryActive.value && typeof window !== 'undefined') {
|
||||||
|
customItemModalHistoryActive.value = false
|
||||||
|
window.history.back()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCustomItemDeleteModal(item) {
|
||||||
|
if (!item) return
|
||||||
|
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) {
|
||||||
|
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modalTargetCustomItem.value = item
|
||||||
|
customItemDeleteModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCustomItemDeleteModal() {
|
||||||
|
customItemDeleteModalOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToGameAdmin(gameId) {
|
||||||
|
if (!gameId) return
|
||||||
|
closeCustomItemModal()
|
||||||
|
setTab('game-admin')
|
||||||
|
nextTick(() => {
|
||||||
|
selectAdminGame(gameId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeCustomItem(item = modalTargetCustomItem.value) {
|
||||||
|
resetMessages()
|
||||||
|
if (!item) return
|
||||||
|
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) {
|
||||||
|
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.deleteAdminCustomItem(item.id)
|
||||||
|
closeCustomItemDeleteModal()
|
||||||
|
closeCustomItemModal()
|
||||||
|
await refreshCustomItems()
|
||||||
|
success.value = item.sourceType === 'template' ? '선택한 템플릿 아이템을 제거했어요.' : '사용자 업로드 이미지를 삭제했어요.'
|
||||||
|
} catch (e) {
|
||||||
|
error.value = item.sourceType === 'template' ? '템플릿 아이템 제거에 실패했어요.' : '사용자 업로드 이미지 삭제에 실패했어요.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUnusedCustomItems() {
|
||||||
|
resetMessages()
|
||||||
|
const ok = window.confirm('현재 검색 조건에 맞는 미사용 커스텀 이미지를 모두 삭제할까요?')
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.deleteAdminUnusedCustomItems({ q: customItemQuery.value })
|
||||||
|
await refreshCustomItems()
|
||||||
|
success.value = `${data.deletedCount || 0}개의 미사용 사용자 업로드 이미지를 삭제했어요.`
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '미사용 커스텀 이미지 일괄 삭제에 실패했어요.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCustomItemModalLabel() {
|
||||||
|
const item = modalTargetCustomItem.value
|
||||||
|
const nextLabel = customItemModalDraftLabel.value.trim().slice(0, 60)
|
||||||
|
if (!item || !nextLabel || nextLabel === item.label || customItemModalLabelSaving.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
customItemModalLabelSaving.value = true
|
||||||
|
const data = await api.updateAdminCustomItemLabel(item.id, { label: nextLabel, sourceType: item.sourceType })
|
||||||
|
item.label = data.item?.label || nextLabel
|
||||||
|
customItemModalDraftLabel.value = item.label
|
||||||
|
customItems.value = customItems.value.map((entry) => (entry.id === item.id ? { ...entry, label: item.label } : entry))
|
||||||
|
toast.success('아이템 이름을 변경했어요.')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '아이템 이름 변경에 실패했어요.'
|
||||||
|
} finally {
|
||||||
|
customItemModalLabelSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promoteCustomItem(item) {
|
||||||
|
resetMessages()
|
||||||
|
if (!customItemModalTargetGameId.value) {
|
||||||
|
error.value = '추가할 게임을 먼저 선택해주세요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
item.isPromoting = true
|
||||||
|
await api.promoteAdminCustomItem(item.id, { gameId: customItemModalTargetGameId.value })
|
||||||
|
const targetGameName = games.value.find((game) => game.id === customItemModalTargetGameId.value)?.name || customItemModalTargetGameId.value
|
||||||
|
if (selectedGameId.value === customItemModalTargetGameId.value) await loadGame()
|
||||||
|
closeCustomItemModal()
|
||||||
|
success.value = `"${item.label}" 이미지를 ${targetGameName} 템플릿으로 추가했어요.`
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '선택한 이미지를 템플릿으로 추가하지 못했어요.'
|
||||||
|
} finally {
|
||||||
|
item.isPromoting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
submitCustomItemSearch,
|
||||||
|
toggleCustomItemOrphanOnly,
|
||||||
|
changeCustomItemLimit,
|
||||||
|
moveCustomItemPage,
|
||||||
|
pushCustomItemModalHistoryState,
|
||||||
|
openCustomItemModal,
|
||||||
|
closeCustomItemModal,
|
||||||
|
openCustomItemDeleteModal,
|
||||||
|
closeCustomItemDeleteModal,
|
||||||
|
jumpToGameAdmin,
|
||||||
|
removeCustomItem,
|
||||||
|
removeUnusedCustomItems,
|
||||||
|
saveCustomItemModalLabel,
|
||||||
|
promoteCustomItem,
|
||||||
|
}
|
||||||
|
}
|
||||||
93
frontend/src/composables/useAdminFeaturedGames.js
Normal file
93
frontend/src/composables/useAdminFeaturedGames.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { nextTick } from 'vue'
|
||||||
|
import Sortable from 'sortablejs'
|
||||||
|
|
||||||
|
export function useAdminFeaturedGames({
|
||||||
|
api,
|
||||||
|
featuredListEl,
|
||||||
|
featuredSortable,
|
||||||
|
featuredGameIds,
|
||||||
|
games,
|
||||||
|
resetMessages,
|
||||||
|
success,
|
||||||
|
error,
|
||||||
|
}) {
|
||||||
|
function destroyFeaturedSortable() {
|
||||||
|
if (featuredSortable.value) {
|
||||||
|
featuredSortable.value.destroy()
|
||||||
|
featuredSortable.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncFeaturedSortable() {
|
||||||
|
await nextTick()
|
||||||
|
destroyFeaturedSortable()
|
||||||
|
if (!featuredListEl.value) return
|
||||||
|
|
||||||
|
featuredSortable.value = Sortable.create(featuredListEl.value, {
|
||||||
|
animation: 160,
|
||||||
|
draggable: '[data-featured-id]',
|
||||||
|
handle: '[data-featured-handle]',
|
||||||
|
ghostClass: 'ghost',
|
||||||
|
chosenClass: 'chosen',
|
||||||
|
onEnd: (evt) => {
|
||||||
|
if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return
|
||||||
|
const nextIds = [...featuredGameIds.value]
|
||||||
|
const [moved] = nextIds.splice(evt.oldIndex, 1)
|
||||||
|
nextIds.splice(evt.newIndex, 0, moved)
|
||||||
|
featuredGameIds.value = nextIds
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFeaturedGame(gameId) {
|
||||||
|
resetMessages()
|
||||||
|
if (!gameId || featuredGameIds.value.includes(gameId)) return
|
||||||
|
if (featuredGameIds.value.length >= 50) {
|
||||||
|
error.value = '상단 고정 게임은 최대 50개까지만 설정할 수 있어요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
featuredGameIds.value = [...featuredGameIds.value, gameId]
|
||||||
|
syncFeaturedSortable()
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFeaturedGame(gameId) {
|
||||||
|
resetMessages()
|
||||||
|
featuredGameIds.value = featuredGameIds.value.filter((id) => id !== gameId)
|
||||||
|
syncFeaturedSortable()
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveFeaturedGame(gameId, direction) {
|
||||||
|
const currentIndex = featuredGameIds.value.indexOf(gameId)
|
||||||
|
const nextIndex = currentIndex + direction
|
||||||
|
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= featuredGameIds.value.length) return
|
||||||
|
const nextIds = [...featuredGameIds.value]
|
||||||
|
const [moved] = nextIds.splice(currentIndex, 1)
|
||||||
|
nextIds.splice(nextIndex, 0, moved)
|
||||||
|
featuredGameIds.value = nextIds
|
||||||
|
syncFeaturedSortable()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFeaturedOrder() {
|
||||||
|
resetMessages()
|
||||||
|
try {
|
||||||
|
const data = await api.updateAdminGameDisplayOrder({ gameIds: featuredGameIds.value })
|
||||||
|
games.value = data.games || []
|
||||||
|
featuredGameIds.value = games.value
|
||||||
|
.filter((game) => game.displayRank != null)
|
||||||
|
.sort((a, b) => a.displayRank - b.displayRank)
|
||||||
|
.map((game) => game.id)
|
||||||
|
success.value = '홈 화면 게임 순서를 저장했어요.'
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '게임 순서 저장에 실패했어요.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroyFeaturedSortable,
|
||||||
|
syncFeaturedSortable,
|
||||||
|
addFeaturedGame,
|
||||||
|
removeFeaturedGame,
|
||||||
|
moveFeaturedGame,
|
||||||
|
saveFeaturedOrder,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -135,7 +135,11 @@ export function useAdminGameManager({
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id: newGameId.value.trim(), name: newGameName.value.trim() }),
|
body: JSON.stringify({
|
||||||
|
id: newGameId.value.trim(),
|
||||||
|
name: newGameName.value.trim(),
|
||||||
|
thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '',
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('failed')
|
if (!res.ok) throw new Error('failed')
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export function useAdminTemplateRequests({
|
|||||||
id: request.id,
|
id: request.id,
|
||||||
type: request.type,
|
type: request.type,
|
||||||
status: request.status,
|
status: request.status,
|
||||||
|
thumbnailSrc: request.thumbnailSrc || '',
|
||||||
draftGameId: request.draftGameId || '',
|
draftGameId: request.draftGameId || '',
|
||||||
draftGameName: request.draftGameName || '',
|
draftGameName: request.draftGameName || '',
|
||||||
sourceTierListId: request.sourceTierListId || '',
|
sourceTierListId: request.sourceTierListId || '',
|
||||||
|
|||||||
265
frontend/src/composables/useAdminUsers.js
Normal file
265
frontend/src/composables/useAdminUsers.js
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
export function useAdminUsers({
|
||||||
|
api,
|
||||||
|
auth,
|
||||||
|
users,
|
||||||
|
userQuery,
|
||||||
|
userSort,
|
||||||
|
userSortDirection,
|
||||||
|
userAvatarInputs,
|
||||||
|
modalTargetUser,
|
||||||
|
modalPasswordDraft,
|
||||||
|
modalRoleNextAdmin,
|
||||||
|
modalUserDraftEmail,
|
||||||
|
modalUserDraftNickname,
|
||||||
|
modalUserDraftIsAdmin,
|
||||||
|
userEditModalOpen,
|
||||||
|
userPasswordModalOpen,
|
||||||
|
userDeleteModalOpen,
|
||||||
|
userRoleModalOpen,
|
||||||
|
resetMessages,
|
||||||
|
refreshUsers,
|
||||||
|
success,
|
||||||
|
error,
|
||||||
|
}) {
|
||||||
|
function setUserAvatarInput(userId, el) {
|
||||||
|
if (!userId) return
|
||||||
|
if (!el) {
|
||||||
|
delete userAvatarInputs.value[userId]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userAvatarInputs.value[userId] = el
|
||||||
|
}
|
||||||
|
|
||||||
|
const canManageModalRole = computed(() => {
|
||||||
|
if (!auth.user?.isPrimaryAdmin) return false
|
||||||
|
if (!modalTargetUser.value) return false
|
||||||
|
return !modalTargetUser.value.isPrimaryAdmin
|
||||||
|
})
|
||||||
|
|
||||||
|
const isUserEditDirty = computed(() => {
|
||||||
|
if (!modalTargetUser.value) return false
|
||||||
|
return (
|
||||||
|
modalUserDraftEmail.value.trim() !== (modalTargetUser.value.email || '') ||
|
||||||
|
modalUserDraftNickname.value.trim() !== (modalTargetUser.value.nickname || '') ||
|
||||||
|
!!modalUserDraftIsAdmin.value !== !!modalTargetUser.value.isAdmin
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function roleLabelOf(user) {
|
||||||
|
if (user?.isPrimaryAdmin) return '최고 관리자'
|
||||||
|
if (user?.isAdmin) return '운영자'
|
||||||
|
return '일반 회원'
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUserAvatarPicker(user) {
|
||||||
|
userAvatarInputs.value[user?.id]?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadUserAvatar(user, file, { remove = false } = {}) {
|
||||||
|
resetMessages()
|
||||||
|
if (!user?.id) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
user.isAvatarBusy = true
|
||||||
|
const data = await api.updateAdminUserAvatar(user.id, { file, removeAvatar: remove })
|
||||||
|
const updated = data.user
|
||||||
|
users.value = users.value.map((entry) =>
|
||||||
|
entry.id === updated.id
|
||||||
|
? {
|
||||||
|
...entry,
|
||||||
|
...updated,
|
||||||
|
isAvatarBusy: false,
|
||||||
|
}
|
||||||
|
: entry
|
||||||
|
)
|
||||||
|
if (modalTargetUser.value?.id === updated.id) {
|
||||||
|
modalTargetUser.value = { ...modalTargetUser.value, ...updated }
|
||||||
|
}
|
||||||
|
if (updated.id === auth.user?.id) await auth.refresh()
|
||||||
|
await refreshUsers()
|
||||||
|
success.value = remove ? '회원 썸네일을 삭제했어요.' : '회원 썸네일을 업데이트했어요.'
|
||||||
|
} catch (e) {
|
||||||
|
error.value = remove ? '회원 썸네일 삭제에 실패했어요.' : '회원 썸네일 변경에 실패했어요.'
|
||||||
|
} finally {
|
||||||
|
const target = users.value.find((entry) => entry.id === user.id)
|
||||||
|
if (target) target.isAvatarBusy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUserAvatarChange(user, event) {
|
||||||
|
const file = event.target.files && event.target.files[0] ? event.target.files[0] : null
|
||||||
|
event.target.value = ''
|
||||||
|
if (!file) return
|
||||||
|
await uploadUserAvatar(user, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUserAvatar(user) {
|
||||||
|
if (!user?.avatarSrc) return
|
||||||
|
await uploadUserAvatar(user, null, { remove: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUserEditModal(user) {
|
||||||
|
resetMessages()
|
||||||
|
modalTargetUser.value = user ? { ...user } : null
|
||||||
|
modalUserDraftEmail.value = user?.email || ''
|
||||||
|
modalUserDraftNickname.value = user?.nickname || ''
|
||||||
|
modalUserDraftIsAdmin.value = !!user?.isAdmin
|
||||||
|
userEditModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUserEditModal() {
|
||||||
|
userEditModalOpen.value = false
|
||||||
|
modalTargetUser.value = null
|
||||||
|
modalUserDraftEmail.value = ''
|
||||||
|
modalUserDraftNickname.value = ''
|
||||||
|
modalUserDraftIsAdmin.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUserEdit() {
|
||||||
|
resetMessages()
|
||||||
|
if (!modalTargetUser.value?.id) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.updateAdminUser(modalTargetUser.value.id, {
|
||||||
|
email: modalUserDraftEmail.value.trim(),
|
||||||
|
nickname: modalUserDraftNickname.value.trim(),
|
||||||
|
isAdmin: !!modalUserDraftIsAdmin.value,
|
||||||
|
})
|
||||||
|
const updated = data.user
|
||||||
|
users.value = users.value.map((entry) =>
|
||||||
|
entry.id === updated.id
|
||||||
|
? {
|
||||||
|
...entry,
|
||||||
|
...updated,
|
||||||
|
isAvatarBusy: entry.isAvatarBusy || false,
|
||||||
|
}
|
||||||
|
: entry
|
||||||
|
)
|
||||||
|
if (updated.id === auth.user?.id) await auth.refresh()
|
||||||
|
closeUserEditModal()
|
||||||
|
await refreshUsers()
|
||||||
|
success.value = '회원 정보를 저장했어요.'
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '회원 정보 저장에 실패했어요.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUserPasswordModal(user) {
|
||||||
|
resetMessages()
|
||||||
|
modalTargetUser.value = user ? { ...user } : null
|
||||||
|
modalPasswordDraft.value = ''
|
||||||
|
userPasswordModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUserPasswordModal() {
|
||||||
|
userPasswordModalOpen.value = false
|
||||||
|
modalTargetUser.value = null
|
||||||
|
modalPasswordDraft.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function userDisplayName(user) {
|
||||||
|
return user?.nickname || user?.email?.split('@')[0] || '알 수 없음'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmUserPasswordReset() {
|
||||||
|
resetMessages()
|
||||||
|
if (!modalTargetUser.value?.id) return
|
||||||
|
|
||||||
|
const password = modalPasswordDraft.value.trim()
|
||||||
|
if (!password) {
|
||||||
|
error.value = '초기화할 비밀번호를 입력해주세요.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.updateAdminUserPassword(modalTargetUser.value.id, { password })
|
||||||
|
success.value = `${userDisplayName(modalTargetUser.value)} 계정 비밀번호를 초기화했어요.`
|
||||||
|
closeUserPasswordModal()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '비밀번호 초기화에 실패했어요.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUserDeleteModal(user) {
|
||||||
|
resetMessages()
|
||||||
|
modalTargetUser.value = user ? { ...user } : null
|
||||||
|
userDeleteModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUserDeleteModal() {
|
||||||
|
userDeleteModalOpen.value = false
|
||||||
|
modalTargetUser.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmUserDelete() {
|
||||||
|
resetMessages()
|
||||||
|
if (!modalTargetUser.value?.id) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deletingSelf = modalTargetUser.value.id === auth.user?.id
|
||||||
|
const deletedName = userDisplayName(modalTargetUser.value)
|
||||||
|
await api.deleteAdminUser(modalTargetUser.value.id)
|
||||||
|
users.value = users.value.filter((entry) => entry.id !== modalTargetUser.value.id)
|
||||||
|
closeUserDeleteModal()
|
||||||
|
success.value = `${deletedName} 계정을 삭제했어요.`
|
||||||
|
if (deletingSelf) await auth.refresh()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '회원 삭제에 실패했어요.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUserRoleModal(user, nextIsAdmin = !modalUserDraftIsAdmin.value) {
|
||||||
|
resetMessages()
|
||||||
|
modalTargetUser.value = user ? { ...user } : null
|
||||||
|
modalRoleNextAdmin.value = !!nextIsAdmin
|
||||||
|
userRoleModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUserRoleModal() {
|
||||||
|
userRoleModalOpen.value = false
|
||||||
|
if (!userEditModalOpen.value) modalTargetUser.value = null
|
||||||
|
modalRoleNextAdmin.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmUserRoleDraft() {
|
||||||
|
if (!modalTargetUser.value?.id) return
|
||||||
|
modalUserDraftIsAdmin.value = modalRoleNextAdmin.value
|
||||||
|
const targetLabel = modalRoleNextAdmin.value ? '운영자 권한을 저장 대기 상태로 반영했어요.' : '운영자 권한 해제를 저장 대기 상태로 반영했어요.'
|
||||||
|
closeUserRoleModal()
|
||||||
|
success.value = targetLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitUserFilters() {
|
||||||
|
refreshUsers({
|
||||||
|
q: userQuery.value,
|
||||||
|
sort: userSort.value,
|
||||||
|
direction: userSortDirection.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
setUserAvatarInput,
|
||||||
|
canManageModalRole,
|
||||||
|
isUserEditDirty,
|
||||||
|
roleLabelOf,
|
||||||
|
openUserAvatarPicker,
|
||||||
|
onUserAvatarChange,
|
||||||
|
removeUserAvatar,
|
||||||
|
openUserEditModal,
|
||||||
|
closeUserEditModal,
|
||||||
|
saveUserEdit,
|
||||||
|
openUserPasswordModal,
|
||||||
|
closeUserPasswordModal,
|
||||||
|
confirmUserPasswordReset,
|
||||||
|
openUserDeleteModal,
|
||||||
|
closeUserDeleteModal,
|
||||||
|
confirmUserDelete,
|
||||||
|
openUserRoleModal,
|
||||||
|
closeUserRoleModal,
|
||||||
|
confirmUserRoleDraft,
|
||||||
|
submitUserFilters,
|
||||||
|
userDisplayName,
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user