Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fe6b90d08 |
@@ -20,6 +20,7 @@ const { requireAuth } = require('../middleware/auth')
|
|||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
const FREEFORM_GAME_ID = 'freeform'
|
const FREEFORM_GAME_ID = 'freeform'
|
||||||
|
const FREEFORM_DEFAULT_TITLE = '직접 티어표 만들기'
|
||||||
|
|
||||||
function normalizePoolItem(item) {
|
function normalizePoolItem(item) {
|
||||||
if (!item || item.origin !== 'game' || typeof item.src !== 'string') return item
|
if (!item || item.origin !== 'game' || typeof item.src !== 'string') return item
|
||||||
@@ -200,6 +201,9 @@ router.post('/:id/template-request', requireAuth, async (req, res) => {
|
|||||||
if (parsed.data.type === 'create') {
|
if (parsed.data.type === 'create') {
|
||||||
if (tierList.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
|
if (tierList.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
|
||||||
if (!isTierListBoardEmpty(tierList)) return res.status(400).json({ error: 'board_must_be_empty' })
|
if (!isTierListBoardEmpty(tierList)) return res.status(400).json({ error: 'board_must_be_empty' })
|
||||||
|
if (!(tierList.title || '').trim() || (tierList.title || '').trim() === FREEFORM_DEFAULT_TITLE) {
|
||||||
|
return res.status(400).json({ error: 'title_required' })
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (tierList.gameId === FREEFORM_GAME_ID) return res.status(400).json({ error: 'game_template_required' })
|
if (tierList.gameId === FREEFORM_GAME_ID) return res.status(400).json({ error: 'game_template_required' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-03-27 v0.1.48
|
||||||
|
- 템플릿 등록 요청은 실패 원인이 불명확하면 혼란이 크므로, 요청 전에 체크리스트 모달로 조건을 먼저 확인시키고 조건이 맞을 때만 전송하게 하는 편이 낫다고 정리했다.
|
||||||
|
- freeform 템플릿 등록 요청은 제목이 곧 게임 이름 후보가 되므로, 기본값이 아닌 사용자가 직접 입력한 제목을 요구하기로 했다.
|
||||||
|
- 관리자 입장에서는 처리하지 않을 요청을 대기 목록에서 바로 치울 수 있어야 하므로, 반려는 단순 상태 변경이 아니라 “대기 목록에서 숨김”으로 인지되게 문구를 맞추기로 했다.
|
||||||
|
|
||||||
## 2026-03-27 v0.1.47
|
## 2026-03-27 v0.1.47
|
||||||
- 새 게임 템플릿 등록과 기존 템플릿 업데이트는 운영자가 직접 일일이 훑기보다, 사용자가 명시적으로 요청을 보내고 관리자가 승인하는 흐름이 더 빠르고 명확하다고 정리했다.
|
- 새 게임 템플릿 등록과 기존 템플릿 업데이트는 운영자가 직접 일일이 훑기보다, 사용자가 명시적으로 요청을 보내고 관리자가 승인하는 흐름이 더 빠르고 명확하다고 정리했다.
|
||||||
- 템플릿 요청에 포함되는 커스텀 아이템 이름은 관리자 판단의 핵심 정보이므로, 티어표 편집 화면 안에서도 직접 이름을 정리하고 저장 시 원본 커스텀 아이템 라벨까지 함께 동기화하기로 결정했다.
|
- 템플릿 요청에 포함되는 커스텀 아이템 이름은 관리자 판단의 핵심 정보이므로, 티어표 편집 화면 안에서도 직접 이름을 정리하고 저장 시 원본 커스텀 아이템 라벨까지 함께 동기화하기로 결정했다.
|
||||||
|
|||||||
@@ -129,6 +129,7 @@
|
|||||||
- `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다.
|
- `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다.
|
||||||
- `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다.
|
- `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다.
|
||||||
- 관리자 티어표 관리 상단에는 사용자가 보낸 템플릿 등록/업데이트 요청 목록이 별도로 표시되며, 여기서 승인/반려를 바로 처리할 수 있다.
|
- 관리자 티어표 관리 상단에는 사용자가 보낸 템플릿 등록/업데이트 요청 목록이 별도로 표시되며, 여기서 승인/반려를 바로 처리할 수 있다.
|
||||||
|
- 관리자 템플릿 요청 목록에서 `반려 후 숨김`을 누르면 해당 요청은 pending 목록에서 즉시 제외된다.
|
||||||
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
|
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
|
||||||
- 회원 관리 카드에는 아바타, 작성 티어표 수, 최근 활동 시각을 함께 표시한다.
|
- 회원 관리 카드에는 아바타, 작성 티어표 수, 최근 활동 시각을 함께 표시한다.
|
||||||
|
|
||||||
@@ -148,6 +149,7 @@
|
|||||||
- 제목이 비어 있는 상태로 저장하면 내부 제목은 현재 게임명을 기본값으로 사용한다.
|
- 제목이 비어 있는 상태로 저장하면 내부 제목은 현재 게임명을 기본값으로 사용한다.
|
||||||
- 제목 입력이 비어 있는 동안에는 무분별한 도배 방지를 위한 관리자 임의 삭제 가능성 안내 문구를 표시한다.
|
- 제목 입력이 비어 있는 동안에는 무분별한 도배 방지를 위한 관리자 임의 삭제 가능성 안내 문구를 표시한다.
|
||||||
- `freeform` 티어표는 보드가 비어 있고 커스텀 아이템이 준비된 상태에서만 `템플릿 등록 요청`을 보낼 수 있다.
|
- `freeform` 티어표는 보드가 비어 있고 커스텀 아이템이 준비된 상태에서만 `템플릿 등록 요청`을 보낼 수 있다.
|
||||||
|
- `템플릿 등록 요청` 전에는 체크리스트 모달로 `제목 직접 입력`, `보드 비움 상태`를 확인하고 두 조건이 충족될 때만 전송할 수 있다.
|
||||||
- 기존 게임 티어표는 커스텀 아이템이 포함되어 있으면 `템플릿 업데이트 요청`을 보낼 수 있다.
|
- 기존 게임 티어표는 커스텀 아이템이 포함되어 있으면 `템플릿 업데이트 요청`을 보낼 수 있다.
|
||||||
- 티어표는 편집 화면에서 16:9 썸네일 이미지를 별도로 선택해 저장할 수 있고, 목록 카드에서는 그 이미지를 상단 대표 썸네일로 사용한다.
|
- 티어표는 편집 화면에서 16:9 썸네일 이미지를 별도로 선택해 저장할 수 있고, 목록 카드에서는 그 이미지를 상단 대표 썸네일로 사용한다.
|
||||||
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
|
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-03-27 v0.1.48
|
||||||
|
- **템플릿 등록 요청 체크리스트 모달 추가**: freeform 템플릿 등록 요청 전 `제목 직접 입력 여부`, `보드 비움 상태`를 확인하는 모달과 안내 문구를 추가하고, 조건이 맞을 때만 요청 버튼이 활성화되도록 조정
|
||||||
|
- **등록 요청 실패 원인 구체화**: 템플릿 등록 요청 실패 시 제목 미입력, 보드 비우지 않음, 커스텀 아이템 없음, 중복 대기 요청 같은 주요 원인을 토스트로 구체적으로 안내하도록 보강
|
||||||
|
- **관리자 요청 목록 정리 문구 추가**: 관리자 템플릿 요청 탭에서 반려 시 대기 목록에서 바로 제외된다는 안내와 `반려 후 숨김` 버튼 문구를 추가해 운영 관점의 흐름을 더 명확히 정리
|
||||||
|
|
||||||
## 2026-03-27 v0.1.47
|
## 2026-03-27 v0.1.47
|
||||||
- **템플릿 등록/업데이트 요청 추가**: 사용자가 저장된 티어표를 기준으로 관리자에게 `새 템플릿 등록` 또는 `기존 템플릿 업데이트` 요청을 보낼 수 있도록 요청 API와 관리자 승인 흐름을 추가
|
- **템플릿 등록/업데이트 요청 추가**: 사용자가 저장된 티어표를 기준으로 관리자에게 `새 템플릿 등록` 또는 `기존 템플릿 업데이트` 요청을 보낼 수 있도록 요청 API와 관리자 승인 흐름을 추가
|
||||||
- **커스텀 아이템 이름 편집 확장**: 티어표 편집 화면에서 사용자가 직접 추가한 커스텀 아이템 이름을 정리할 수 있는 전용 입력 목록을 추가하고, 저장 시 MariaDB의 커스텀 아이템 라벨도 함께 동기화
|
- **커스텀 아이템 이름 편집 확장**: 티어표 편집 화면에서 사용자가 직접 추가한 커스텀 아이템 이름을 정리할 수 있는 전용 입력 목록을 추가하고, 저장 시 MariaDB의 커스텀 아이템 라벨도 함께 동기화
|
||||||
|
|||||||
@@ -1088,7 +1088,7 @@ async function saveFeaturedOrder() {
|
|||||||
<div class="sectionHeader">
|
<div class="sectionHeader">
|
||||||
<div>
|
<div>
|
||||||
<div class="panel__title">사용자 템플릿 요청</div>
|
<div class="panel__title">사용자 템플릿 요청</div>
|
||||||
<div class="hint hint--tight">freeform 템플릿 등록 요청과 기존 게임 템플릿 업데이트 요청을 여기서 승인하거나 반려할 수 있어요.</div>
|
<div class="hint hint--tight">freeform 템플릿 등록 요청과 기존 게임 템플릿 업데이트 요청을 여기서 승인하거나 반려할 수 있어요. 반려한 요청은 대기 목록에서 바로 제외됩니다.</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn--ghost" @click="refreshTemplateRequests">새로고침</button>
|
<button class="btn btn--ghost" @click="refreshTemplateRequests">새로고침</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1125,7 +1125,7 @@ async function saveFeaturedOrder() {
|
|||||||
<button class="btn btn--primary" :disabled="request.isHandling" @click="approveTemplateRequest(request)">
|
<button class="btn btn--primary" :disabled="request.isHandling" @click="approveTemplateRequest(request)">
|
||||||
{{ request.isHandling ? '처리중...' : '승인' }}
|
{{ request.isHandling ? '처리중...' : '승인' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn--danger" :disabled="request.isHandling" @click="rejectTemplateRequest(request)">반려</button>
|
<button class="btn btn--danger" :disabled="request.isHandling" @click="rejectTemplateRequest(request)">반려 후 숨김</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const error = ref('')
|
|||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
const isExporting = ref(false)
|
const isExporting = ref(false)
|
||||||
const isSaveModalOpen = ref(false)
|
const isSaveModalOpen = ref(false)
|
||||||
|
const isTemplateRequestModalOpen = ref(false)
|
||||||
const ownerId = ref('')
|
const ownerId = ref('')
|
||||||
const authorName = ref('')
|
const authorName = ref('')
|
||||||
const authorAccountName = ref('')
|
const authorAccountName = ref('')
|
||||||
@@ -97,6 +98,19 @@ const canRequestTemplateCreate = computed(
|
|||||||
const canRequestTemplateUpdate = computed(
|
const canRequestTemplateUpdate = computed(
|
||||||
() => canEdit.value && !isNewTierList.value && gameId.value !== 'freeform' && customItems.value.length > 0
|
() => canEdit.value && !isNewTierList.value && gameId.value !== 'freeform' && customItems.value.length > 0
|
||||||
)
|
)
|
||||||
|
const templateRequestChecks = computed(() => [
|
||||||
|
{
|
||||||
|
id: 'title',
|
||||||
|
label: '티어표 이름(게임 이름)을 직접 입력했는지',
|
||||||
|
passed: !!(title.value || '').trim() && (title.value || '').trim() !== (gameName.value || '').trim(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'empty-board',
|
||||||
|
label: '등록한 이미지를 티어에 배치하지 않은 원본 상태인지',
|
||||||
|
passed: !hasPlacedItems.value,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const canSubmitTemplateCreateRequest = computed(() => templateRequestChecks.value.every((item) => item.passed))
|
||||||
|
|
||||||
watch(error, (message) => {
|
watch(error, (message) => {
|
||||||
if (!message) return
|
if (!message) return
|
||||||
@@ -455,6 +469,14 @@ function closeSaveModal() {
|
|||||||
isSaveModalOpen.value = false
|
isSaveModalOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openTemplateRequestModal() {
|
||||||
|
isTemplateRequestModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTemplateRequestModal() {
|
||||||
|
isTemplateRequestModalOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
async function removeTierList() {
|
async function removeTierList() {
|
||||||
if (!canEdit.value || isNewTierList.value) return
|
if (!canEdit.value || isNewTierList.value) return
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -494,8 +516,13 @@ async function requestTemplate(type) {
|
|||||||
isRequestingTemplate.value = true
|
isRequestingTemplate.value = true
|
||||||
await persistTierList({ showModal: false })
|
await persistTierList({ showModal: false })
|
||||||
await api.requestTierListTemplate(tierListId.value, { type })
|
await api.requestTierListTemplate(tierListId.value, { type })
|
||||||
|
if (type === 'create') closeTemplateRequestModal()
|
||||||
toast.success(type === 'create' ? '템플릿 등록 요청을 보냈어요.' : '템플릿 업데이트 요청을 보냈어요.')
|
toast.success(type === 'create' ? '템플릿 등록 요청을 보냈어요.' : '템플릿 업데이트 요청을 보냈어요.')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e?.status === 400 && e?.data?.error === 'title_required') {
|
||||||
|
toast.error('템플릿 등록 요청 전에는 티어표 이름을 직접 입력해주세요.')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (e?.status === 409) {
|
if (e?.status === 409) {
|
||||||
toast.error('이미 처리 대기 중인 같은 요청이 있어요.')
|
toast.error('이미 처리 대기 중인 같은 요청이 있어요.')
|
||||||
return
|
return
|
||||||
@@ -504,6 +531,10 @@ async function requestTemplate(type) {
|
|||||||
toast.error('템플릿 등록 요청은 보드를 비운 상태에서만 보낼 수 있어요.')
|
toast.error('템플릿 등록 요청은 보드를 비운 상태에서만 보낼 수 있어요.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (e?.status === 400 && e?.data?.error === 'custom_items_required') {
|
||||||
|
toast.error('먼저 커스텀 아이템을 추가한 뒤 요청해주세요.')
|
||||||
|
return
|
||||||
|
}
|
||||||
toast.error(type === 'create' ? '템플릿 등록 요청에 실패했어요.' : '템플릿 업데이트 요청에 실패했어요.')
|
toast.error(type === 'create' ? '템플릿 등록 요청에 실패했어요.' : '템플릿 업데이트 요청에 실패했어요.')
|
||||||
} finally {
|
} finally {
|
||||||
isRequestingTemplate.value = false
|
isRequestingTemplate.value = false
|
||||||
@@ -629,9 +660,9 @@ onUnmounted(() => {
|
|||||||
v-if="canRequestTemplateCreate"
|
v-if="canRequestTemplateCreate"
|
||||||
class="btn btn--ghost"
|
class="btn btn--ghost"
|
||||||
:disabled="isRequestingTemplate"
|
:disabled="isRequestingTemplate"
|
||||||
@click="requestTemplate('create')"
|
@click="openTemplateRequestModal"
|
||||||
>
|
>
|
||||||
{{ isRequestingTemplate ? '요청중...' : '템플릿 등록 요청' }}
|
템플릿 등록 요청
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="canRequestTemplateUpdate"
|
v-if="canRequestTemplateUpdate"
|
||||||
@@ -660,6 +691,35 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isTemplateRequestModalOpen" class="modalOverlay" @click.self="closeTemplateRequestModal">
|
||||||
|
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="templateRequestTitle">
|
||||||
|
<div id="templateRequestTitle" class="modalCard__title">템플릿 등록 요청</div>
|
||||||
|
<div class="modalCard__desc">
|
||||||
|
여러 사용자가 비슷한 주제로 요청할 수 있으니, 관리자에게 전달되기 전에 아래 조건을 먼저 확인해주세요.
|
||||||
|
</div>
|
||||||
|
<div class="requestChecklist">
|
||||||
|
<div
|
||||||
|
v-for="check in templateRequestChecks"
|
||||||
|
:key="check.id"
|
||||||
|
class="requestChecklist__item"
|
||||||
|
:class="{ 'requestChecklist__item--passed': check.passed }"
|
||||||
|
>
|
||||||
|
<span class="requestChecklist__icon">{{ check.passed ? '완료' : '확인 필요' }}</span>
|
||||||
|
<span>{{ check.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="requestChecklist__hint">
|
||||||
|
제목이 명확하고, 보드는 비워둔 채 원본 아이템만 정리되어 있을수록 관리자가 새 게임 템플릿으로 빠르게 등록하기 쉬워져요.
|
||||||
|
</div>
|
||||||
|
<div class="modalCard__actions">
|
||||||
|
<button class="btn btn--ghost" @click="closeTemplateRequestModal">취소</button>
|
||||||
|
<button class="btn btn--save" :disabled="!canSubmitTemplateCreateRequest || isRequestingTemplate" @click="requestTemplate('create')">
|
||||||
|
{{ isRequestingTemplate ? '요청중...' : '등록 요청 보내기' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="layout" :style="{ '--thumb-size': `${iconSize}px` }">
|
<section class="layout" :style="{ '--thumb-size': `${iconSize}px` }">
|
||||||
<div ref="boardEl" class="board">
|
<div ref="boardEl" class="board">
|
||||||
<div v-if="canEdit && !isExporting" class="boardTools">
|
<div v-if="canEdit && !isExporting" class="boardTools">
|
||||||
@@ -1018,6 +1078,36 @@ onUnmounted(() => {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
.requestChecklist {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.requestChecklist__item {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(251, 191, 36, 0.22);
|
||||||
|
background: rgba(251, 191, 36, 0.08);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.requestChecklist__item--passed {
|
||||||
|
border-color: rgba(52, 211, 153, 0.24);
|
||||||
|
background: rgba(52, 211, 153, 0.1);
|
||||||
|
}
|
||||||
|
.requestChecklist__icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 56px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.requestChecklist__hint {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
.boardTools {
|
.boardTools {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
Reference in New Issue
Block a user