Compare commits

...

3 Commits

9 changed files with 142 additions and 32 deletions

View File

@@ -93,13 +93,21 @@ function canManageAdminRole(actingUser, primaryAdmin) {
}
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)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const exists = await findGameById(parsed.data.id)
if (exists) return res.status(409).json({ error: 'game_id_taken' })
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) => {

View File

@@ -1,5 +1,13 @@
# 의사결정 이력
## 2026-04-02 v1.3.55
- 관리자 요청/업로드 배지는 문구만 다르면 빠르게 구분하기 어려우므로, 같은 `pill` 구조를 유지하되 색으로도 역할을 나누는 편이 운영 판단에 더 적합하다고 정리했다.
- 신규 템플릿 요청으로 새 게임을 만들 때는 아이템만 가져오고 썸네일이 비어 있으면 식별성이 떨어지므로, 요청 썸네일도 기본값으로 함께 승계하는 편이 맞다고 판단했다.
## 2026-04-02 v1.3.54
- 관리자 요청 카드는 운영자가 이미 흐름을 알고 있다는 전제에서, 설명형 힌트보다 즉시 판단에 필요한 메타와 액션만 남기는 편이 더 적합하다고 정리했다.
- 요청 종류 표시는 중복 텍스트보다 오른쪽 상단의 짧은 상태 배지 하나로 고정하고, 하단 액션 줄은 `보조 링크는 왼쪽 / 실제 처리 버튼은 오른쪽` 구조가 더 읽기 쉽다고 판단했다.
## 2026-04-02 v1.3.53
- 관리자 후속 리팩터링은 남은 큰 액션 묶음인 `상단 고정 게임 정렬``커스텀 아이템 검수`부터 composable로 분리하는 편이 `AdminView.vue` 체감 복잡도를 가장 빨리 낮춘다고 판단했다.
- 이 단계에서도 레이아웃이나 문구보다 로직 책임 경계를 먼저 옮기고, 실제 스타일 파일 분리는 그 다음 단계로 이어가는 편이 안전하다고 정리했다.

View File

@@ -5,6 +5,8 @@
- 관리자 본문 컴포넌트 분리와 `게임/템플릿 요청/회원 관리/아이템 관리/목록 관리` composable 분리는 시작했으므로, 다음 단계에서는 공통 모달 상태를 어느 계층에서 소유할지 정리하고 남은 관리자 유틸 함수를 더 줄인다.
- 관리자 화면은 섹션 경로 분리까지 끝났으므로, 다음 단계에서는 `AdminView.vue`를 실제 레이아웃 뷰와 섹션별 라우트 컴포넌트로 더 쪼갤지 결정한다.
- 관리자 공통 스타일은 `adminUiScope` 기준으로 다시 묶었으므로, 다음 단계에서는 각 섹션을 별도 파일로 완전히 분리할 때 스타일도 `admin.css` 또는 섹션별 스타일로 옮길지 결정한다.
- 관리자 요청 카드 밀도는 줄였으므로, 다음 단계에서는 전체 티어표 카드와 요청 카드의 상단/하단 액션 정렬을 한 번 더 통일할지 비교 QA한다.
- 신규 템플릿 요청 썸네일 기본 승계는 붙였으므로, 다음 단계에서는 요청 아이템 반영 후 `처리 완료`까지의 관리자 흐름을 실제 데이터로 한 번 더 QA한다.
- 관리자 게임 아이템 순서 저장은 추가됐으므로, 다음 단계에서는 새 아이템 추가 직후 `자동 맨 앞 배치``관리자 수동 고정 순서`의 우선순위를 실제 운영 흐름 기준으로 한 번 더 QA한다.
- 관리자 템플릿 요청 미리보기는 실제 완성본 iframe 방식과의 체감 차이를 마지막으로 한 번 더 QA한다.
- 라이트모드/다크모드 2차 보정까지 반영했으므로, 남은 작업은 전체 화면을 실제 사용 흐름으로 돌려 보며 대비·명도·아이콘 가독성을 미세하게 QA하는 최종 테마 점검 단계로 가져간다.

View File

@@ -1,5 +1,15 @@
# 업데이트 로그
## 2026-04-02 v1.3.55
- 관리자 요청 카드 오른쪽 상단의 `신규 템플릿 / 보유 템플릿` 배지는 서로 다른 색상으로 분리해, 카드 타입을 텍스트보다 더 빠르게 구분할 수 있게 조정함.
- 게임 관리의 기본 아이템 추가 미리보기에서도 `요청 아이템 / 직접 추가 파일` 배지를 서로 다른 색상으로 구분해, 요청 반영분과 직접 업로드분이 한눈에 섞이지 않도록 정리함.
- 신규 템플릿 요청에서 `새 게임 만들기`를 진행할 때는 요청 티어표 대표 썸네일도 함께 새 게임 썸네일로 복사되도록 보강해, 관리자가 이후 수정하더라도 초기 식별용 썸네일은 바로 이어받을 수 있게 함.
## 2026-04-02 v1.3.54
- 관리자 `티어표 관리` 요청 카드에서는 사용법 힌트 문구와 중복 타입 텍스트를 제거해, 카드 본문이 관리 정보만 더 빠르게 읽히도록 정리함.
- `신규 템플릿 / 보유 템플릿` 구분은 카드 오른쪽 상단의 별도 배지로 옮기고, 기존 `추가 아이템 / 확인함 여부` 배지는 그대로 유지해 정보 계층을 더 단순하게 맞춤.
- `요청 티어표 보기` 링크는 하단 액션 줄의 왼쪽으로 옮기고 `확인하기 / 처리 완료` 버튼은 오른쪽에 정렬해, 실제 작업 버튼과 보조 링크의 역할이 한 줄 안에서도 분명하게 보이도록 조정함.
## 2026-04-02 v1.3.53
- 관리자 리팩터링 4차로 `목록 관리` 정렬 로직과 `아이템 관리` 모달/삭제/승격 액션을 각각 `useAdminFeaturedGames`, `useAdminCustomItems` composable로 분리해 `AdminView.vue`의 직접 액션 코드를 더 줄임.
- 따라서 관리자 메인 뷰는 섹션 연결과 공통 상태 중심으로 더 가까워졌고, 상단 고정 게임 정렬과 커스텀 아이템 처리 흐름은 각 영역 책임에 맞는 파일로 옮겨 유지보수 범위를 좁힘.

View File

@@ -119,7 +119,9 @@ const props = defineProps({
<input v-model="draft.label" class="input input--labelEdit input--dense" maxlength="60" placeholder="아이템 이름" />
<div class="hint hint--tight">{{ draft.sourceName }}</div>
<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>
</div>
</div>
@@ -145,6 +147,7 @@ const props = defineProps({
<div v-if="!props.selectedGame?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
<div v-else :ref="props.gameItemListRef" class="thumbGrid">
<div v-for="item in props.selectedGame.items" :key="item.id" class="thumbCard" :data-game-item-id="item.id">
<div class="thumbCard__dragHandle" data-game-item-handle>순서 이동</div>
<img class="thumb thumb--game" :src="toApiUrl(item.src)" :alt="item.label" />
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" />
<div class="thumbCard__actions">

View File

@@ -5,12 +5,10 @@ const props = defineProps({
tierlistsMode: { type: String, required: true },
templateRequests: { type: Array, required: true },
openTemplateRequestPreview: { type: Function, required: true },
templateRequestTypeLabel: { type: Function, required: true },
fmt: { type: Function, required: true },
templateRequestTargetLabel: { type: Function, required: true },
templateRequestStatusLabel: { type: Function, required: true },
templateRequestSourceUrl: { type: Function, required: true },
templateRequestReviewHint: { type: Function, required: true },
startTemplateRequestReview: { type: Function, required: true },
completeTemplateRequest: { type: Function, required: true },
adminTierLists: { type: Array, required: true },
@@ -32,7 +30,6 @@ const props = defineProps({
<div class="sectionHeader">
<div>
<div class="panel__title">사용자 요청</div>
<div class="hint hint--tight">요청 카드는 미확인/확인함 상태로 관리하고, 실제 아이템 반영은 게임 관리 화면에서 직접 진행합니다. 처리 완료를 눌러야 카드가 목록에서 빠져요.</div>
</div>
</div>
@@ -67,10 +64,16 @@ const props = defineProps({
<div class="tierAdminCard__body">
<div class="tierAdminCard__head">
<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 v-if="request.sourceDescription" class="tierAdminCard__desc">{{ request.sourceDescription }}</div>
<div class="tierAdminCard__meta">
{{ props.templateRequestTypeLabel(request) }} · {{ request.requesterName }} · {{ props.fmt(request.createdAt) }}
{{ request.requesterName }} · {{ props.fmt(request.createdAt) }}
</div>
<div class="tierAdminCard__meta">{{ props.templateRequestTargetLabel(request) }}</div>
</div>
@@ -78,7 +81,6 @@ const props = defineProps({
<div class="tierAdminCard__stats">
<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>
</div>
@@ -89,24 +91,24 @@ const props = defineProps({
</button>
</div>
<div class="templateRequestCard__links">
<a
v-if="props.templateRequestSourceUrl(request)"
class="btn btn--ghost btn--small"
:href="props.templateRequestSourceUrl(request)"
target="_blank"
rel="noreferrer"
>
요청 티어표 보기
</a>
<div class="hint hint--tight">{{ props.templateRequestReviewHint(request) }}</div>
</div>
<div class="templateRequestCard__actions">
<button class="btn btn--primary" :disabled="request.isHandling" @click="props.startTemplateRequestReview(request)">
{{ request.isHandling ? '이동중...' : '확인하기' }}
</button>
<button class="btn btn--ghost" :disabled="request.isHandling || request.status !== 'reviewing'" @click="props.completeTemplateRequest(request)">처리 완료</button>
<div class="templateRequestCard__footer">
<div class="templateRequestCard__footerLeft">
<a
v-if="props.templateRequestSourceUrl(request)"
class="btn btn--ghost btn--small"
:href="props.templateRequestSourceUrl(request)"
target="_blank"
rel="noreferrer"
>
요청 티어표 보기
</a>
</div>
<div class="templateRequestCard__actions">
<button class="btn btn--primary" :disabled="request.isHandling" @click="props.startTemplateRequestReview(request)">
{{ request.isHandling ? '이동중...' : '확인하기' }}
</button>
<button class="btn btn--ghost" :disabled="request.isHandling || request.status !== 'reviewing'" @click="props.completeTemplateRequest(request)">처리 완료</button>
</div>
</div>
</div>
</article>
@@ -117,7 +119,6 @@ const props = defineProps({
<div class="sectionHeader">
<div>
<div class="panel__title">전체 티어표 관리</div>
<div class="hint hint--tight">공개/비공개를 포함한 최근 티어표를 모두 확인하고, 추가 아이템을 기존 게임 템플릿으로 승격하거나 커스텀 티어표를 게임 템플릿으로 만들 있어요. 여기는 요청 목록과 별개로 전체 저장 티어표를 보는 영역입니다.</div>
</div>
</div>

View File

@@ -50,6 +50,7 @@ export function useAdminGameManager({
gameItemSortable.value = Sortable.create(gameItemListEl.value, {
animation: 160,
draggable: '[data-game-item-id]',
handle: '[data-game-item-handle]',
ghostClass: 'ghost',
chosenClass: 'chosen',
onEnd: (evt) => {
@@ -135,7 +136,11 @@ export function useAdminGameManager({
method: 'POST',
credentials: 'include',
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')

View File

@@ -18,6 +18,7 @@ export function useAdminTemplateRequests({
id: request.id,
type: request.type,
status: request.status,
thumbnailSrc: request.thumbnailSrc || '',
draftGameId: request.draftGameId || '',
draftGameName: request.draftGameName || '',
sourceTierListId: request.sourceTierListId || '',

View File

@@ -1374,12 +1374,10 @@ function userAvatarFallback(user) {
:tierlists-mode="tierlistsMode"
:template-requests="templateRequests"
:open-template-request-preview="openTemplateRequestPreview"
:template-request-type-label="templateRequestTypeLabel"
:fmt="fmt"
:template-request-target-label="templateRequestTargetLabel"
:template-request-status-label="templateRequestStatusLabel"
:template-request-source-url="templateRequestSourceUrl"
:template-request-review-hint="templateRequestReviewHint"
:start-template-request-review="startTemplateRequestReview"
:complete-template-request="completeTemplateRequest"
:admin-tier-lists="adminTierLists"
@@ -2736,6 +2734,25 @@ function userAvatarFallback(user) {
padding: 12px;
min-width: 0;
}
.adminUiScope .thumbCard__dragHandle {
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
padding: 5px 9px;
border-radius: 999px;
border: 1px dashed rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.04);
color: var(--theme-text-soft);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.02em;
cursor: grab;
user-select: none;
}
.adminUiScope .thumbCard__dragHandle:active {
cursor: grabbing;
}
.adminUiScope .thumb {
width: 100%;
aspect-ratio: 1 / 1;
@@ -2766,6 +2783,26 @@ function userAvatarFallback(user) {
.adminUiScope .pill--soft {
background: rgba(255, 255, 255, 0.08);
}
.adminUiScope .pill--create {
border-color: rgba(56, 189, 248, 0.36);
background: rgba(56, 189, 248, 0.16);
color: rgba(224, 242, 254, 0.98);
}
.adminUiScope .pill--owned {
border-color: rgba(167, 139, 250, 0.34);
background: rgba(167, 139, 250, 0.14);
color: rgba(243, 232, 255, 0.98);
}
.adminUiScope .pill--requestItem {
border-color: rgba(250, 204, 21, 0.34);
background: rgba(250, 204, 21, 0.14);
color: rgba(254, 249, 195, 0.98);
}
.adminUiScope .pill--directFile {
border-color: rgba(52, 211, 153, 0.34);
background: rgba(52, 211, 153, 0.14);
color: rgba(209, 250, 229, 0.98);
}
.adminUiScope .requestWorkspace {
display: grid;
gap: 14px;
@@ -3368,9 +3405,17 @@ function userAvatarFallback(user) {
.adminUiScope .templateRequestCard__items {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
}
.adminUiScope .templateRequestCard__links {
display: grid;
.adminUiScope .templateRequestCard__footer {
display: flex;
gap: 12px;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
.adminUiScope .templateRequestCard__footerLeft {
display: flex;
gap: 10px;
align-items: center;
}
.adminUiScope .templateRequestCard__actions {
display: flex;
@@ -3557,6 +3602,7 @@ function userAvatarFallback(user) {
min-width: 0;
display: grid;
gap: 14px;
position: relative;
}
.adminUiScope .tierAdminCard__head {
display: flex;
@@ -3564,9 +3610,15 @@ function userAvatarFallback(user) {
justify-content: space-between;
align-items: flex-start;
}
.adminUiScope .templateRequestCard__cornerBadge {
position: absolute;
top: 0;
right: 0;
}
.adminUiScope .tierAdminCard__title {
font-size: 18px;
font-weight: 900;
padding-right: 132px;
}
.adminUiScope .tierAdminCard__desc {
margin-top: 6px;
@@ -3599,6 +3651,26 @@ function userAvatarFallback(user) {
font-size: 12px;
font-weight: 800;
}
.adminUiScope .pill--create {
border-color: rgba(56, 189, 248, 0.36);
background: rgba(56, 189, 248, 0.16);
color: rgba(224, 242, 254, 0.98);
}
.adminUiScope .pill--owned {
border-color: rgba(167, 139, 250, 0.34);
background: rgba(167, 139, 250, 0.14);
color: rgba(243, 232, 255, 0.98);
}
.adminUiScope .pill--requestItem {
border-color: rgba(250, 204, 21, 0.34);
background: rgba(250, 204, 21, 0.14);
color: rgba(254, 249, 195, 0.98);
}
.adminUiScope .pill--directFile {
border-color: rgba(52, 211, 153, 0.34);
background: rgba(52, 211, 153, 0.14);
color: rgba(209, 250, 229, 0.98);
}
.adminUiScope .pill--accent {
border-color: rgba(251, 191, 36, 0.32);
background: rgba(251, 191, 36, 0.12);