Compare commits

...

7 Commits

7 changed files with 180 additions and 131 deletions

View File

@@ -403,6 +403,22 @@ async function ensureSchema() {
if (templateRequestSourceTierListColumns[0]?.Null !== 'YES') { if (templateRequestSourceTierListColumns[0]?.Null !== 'YES') {
await query('ALTER TABLE template_requests MODIFY source_tierlist_id VARCHAR(64) NULL') await query('ALTER TABLE template_requests MODIFY source_tierlist_id VARCHAR(64) NULL')
} }
const templateRequestTypeColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'request_type'")
if (!templateRequestTypeColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN request_type VARCHAR(20) NOT NULL DEFAULT 'create' AFTER id")
}
const templateRequestSourceGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'source_game_id'")
if (!templateRequestSourceGameColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN source_game_id VARCHAR(120) NOT NULL DEFAULT 'freeform' AFTER source_tierlist_id")
}
const templateRequestTargetGameColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'target_game_id'")
if (!templateRequestTargetGameColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN target_game_id VARCHAR(120) NOT NULL DEFAULT '' AFTER source_game_id")
}
const templateRequestStatusColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'status'")
if (!templateRequestStatusColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'pending' AFTER target_game_id")
}
const templateRequestGroupsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'groups_json'") const templateRequestGroupsColumns = await query("SHOW COLUMNS FROM template_requests LIKE 'groups_json'")
if (!templateRequestGroupsColumns.length) { if (!templateRequestGroupsColumns.length) {
await query("ALTER TABLE template_requests ADD COLUMN groups_json LONGTEXT NOT NULL AFTER items_json") await query("ALTER TABLE template_requests ADD COLUMN groups_json LONGTEXT NOT NULL AFTER items_json")
@@ -427,6 +443,8 @@ async function ensureSchema() {
const tierListSourceIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_tierlist_id'") const tierListSourceIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_tierlist_id'")
if (!tierListSourceIdColumns.length) { if (!tierListSourceIdColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN source_tierlist_id VARCHAR(64) NULL DEFAULT NULL AFTER show_character_names") await query("ALTER TABLE tierlists ADD COLUMN source_tierlist_id VARCHAR(64) NULL DEFAULT NULL AFTER show_character_names")
} else if (tierListSourceIdColumns[0]?.Null !== 'YES') {
await query('ALTER TABLE tierlists MODIFY source_tierlist_id VARCHAR(64) NULL DEFAULT NULL')
} }
const tierListSourceTitleColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_title'") const tierListSourceTitleColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_title'")
if (!tierListSourceTitleColumns.length) { if (!tierListSourceTitleColumns.length) {
@@ -2003,6 +2021,7 @@ async function saveTierList({
return findTierListById(existing.id, authorId) return findTierListById(existing.id, authorId)
} }
const nextId = id || nanoid()
const createdAt = now() const createdAt = now()
await query( await query(
` `
@@ -2011,9 +2030,9 @@ async function saveTierList({
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
[id, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt] [nextId, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt]
) )
return findTierListById(id, authorId) return findTierListById(nextId, authorId)
} }
async function duplicateTierListForUser({ tierList, targetUserId }) { async function duplicateTierListForUser({ tierList, targetUserId }) {

View File

@@ -1,5 +1,21 @@
# 의사결정 이력 # 의사결정 이력
## 2026-04-01 v1.3.47
- 관리자 `사용자 템플릿 요청`도 결국 검수용 카드이므로, 요청 전용 카드 문법을 따로 두기보다 `전체 티어표 관리`와 같은 카드 구조를 재사용하는 편이 더 직관적이라고 정리했다.
- 새 템플릿 생성 요청의 기본 게임 ID는 사람이 읽기 어려운 난수보다 요청 단위에서 유일한 임시값을 먼저 채워두고, 승인 전에 관리자가 수정하는 흐름이 더 현실적이라고 판단했다.
## 2026-04-01 v1.3.46
- 관리자 전체 티어표 카드에서는 좌측 영역 전체를 버튼처럼 만드는 것보다, 실제 썸네일 이미지만 미리보기 진입점으로 읽히게 두는 편이 카드 정보 구조가 덜 흔들린다고 정리했다.
- 템플릿 요청 미리보기는 일반 티어표 보기와 다른 요약 레이아웃을 새로 두기보다, 같은 내부 프레임 문법 안에서 보드 자체를 먼저 보여주는 편이 사용자가 더 자연스럽게 이해한다고 판단했다.
## 2026-04-01 v1.3.45
- 템플릿 요청에서 `내 티어리스트에도 저장`은 별도 부가 기능이 아니라 실제 저장본 생성 경로를 타므로, 새 저장본 ID는 호출자에 기대지 말고 저장 함수 내부에서 항상 보장하는 편이 더 안전하다고 정리했다.
- 개발 단계의 내부 조치 문구인 `백엔드 재시작` 같은 표현은 사용자 토스트에 직접 노출하지 않고, 운영형 재시도 안내로 낮추는 편이 맞다고 판단했다.
## 2026-04-01 v1.3.44
- 관리자 티어표 목록에서는 `보기` 버튼을 없애더라도 완성본 확인 기능 자체는 유지해야 하므로, 별도 액션 버튼보다 카드 썸네일 클릭을 미리보기 진입점으로 쓰는 편이 더 자연스럽다고 정리했다.
- 템플릿 요청 미리보기도 별도 요약 카드보다 실제 보드 구조를 우선 보여주는 쪽이 관리자 검수 흐름에 더 맞으므로, 일반 티어표 미리보기와 가까운 방향으로 통일하기로 했다.
## 2026-03-30 v1.2.25 ## 2026-03-30 v1.2.25
- 홈 게임 카드는 메인 썸네일까지 없애는 것보다, 큰 썸네일은 유지하고 ID 옆의 작은 보조 표시만 제거하는 편이 원래 의도와 맞다고 정리했다. - 홈 게임 카드는 메인 썸네일까지 없애는 것보다, 큰 썸네일은 유지하고 ID 옆의 작은 보조 표시만 제거하는 편이 원래 의도와 맞다고 정리했다.
- 좌우 하단 액션 여백은 `margin`으로 푸터 전체를 밀기보다, 푸터 내부 `padding-bottom`으로 확보해야 버튼 자체는 항상 보이고 여백만 남는다고 판단했다. - 좌우 하단 액션 여백은 `margin`으로 푸터 전체를 밀기보다, 푸터 내부 `padding-bottom`으로 확보해야 버튼 자체는 항상 보이고 여백만 남는다고 판단했다.

View File

@@ -20,3 +20,6 @@
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다. - 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다. - 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.
- 관리자 템플릿 요청 미리보기는 실제 완성본 모달과 더 가까운 체감이 되도록, 이후에도 보드 여백·행/열 헤더·남은 아이템 밀도를 한 번 더 비교 QA한다.
- 관리자 템플릿 요청 미리보기는 일반 완성본 보기와 거의 같은 구조로 맞췄으므로, 이후 실제 데이터로 row/column 정렬감과 비어 있는 셀 높이를 한 번 더 비교 QA한다.
- 관리자 템플릿 요청 카드와 전체 티어표 카드가 같은 문법으로 맞춰졌으므로, 이후에는 라이트모드까지 포함해 두 카드의 썸네일 높이·입력창 밀도·아이템 그리드를 한 번 더 비교 QA한다.

View File

@@ -1,5 +1,35 @@
# 업데이트 로그 # 업데이트 로그
## 2026-04-01 v1.3.47
- 관리자 `사용자 템플릿 요청` 카드는 별도 요청 전용 레이아웃 대신 `전체 티어표 관리`와 같은 카드 문법으로 맞추고, 왼쪽 썸네일 클릭으로 같은 미리보기 모달이 열리도록 정리함.
- 새 템플릿 요청에는 썸네일 아래에 `게임 이름 / 게임 ID` 입력을 두고, 초기 `게임 ID``new-template` 대신 요청 ID 기반의 임시 고유값으로 채워 나중에 수정하기 쉽게 바꿈.
- 요청 카드 오른쪽에는 제목, 설명, 요청 메타, 추가 아이템 목록, 승인/반려 버튼을 같은 정보 계층으로 배치해 전체 티어표 관리와 읽는 흐름을 통일함.
## 2026-04-01 v1.3.46
- 관리자 `전체 티어표 관리`의 썸네일 영역은 카드 좌측 전체가 눌리는 버튼처럼 보이지 않도록 이미지 영역만 상단에 붙여 클릭 진입점으로 유지하고, 카드 본문과의 시각적 분리를 다시 다듬음.
- `템플릿 요청 관리` 미리보기는 별도 썸네일 요약형이 아니라, 제목·설명·행/열 보드·남은 아이템이 하나의 내부 프레임 안에서 이어지는 실제 티어표 완성본형 레이아웃으로 다시 정리함.
## 2026-04-01 v1.3.45
- 템플릿 요청에서 `내 티어리스트에도 저장`이 켜져 있을 때 발생하던 500 오류는 새 저장본 생성 시 `tierlists.id``undefined`가 들어가던 문제였고, 이제 `saveTierList()`가 생성 시 자동으로 `nanoid()`를 부여하도록 고쳐 저장 분기 자체를 안정화함.
- 사용자에게 노출되던 `백엔드를 재시작해주세요` 문구는 제거하고, 저장 분기 실패 시에도 일반적인 재시도 안내만 보이도록 조정함.
- 루트에 잘못 남아 있던 `update.md` 진입점 파일은 제거하고, 업데이트 기록은 다시 `docs/update.md` 한 곳으로 정리함.
## 2026-04-01 v1.3.44
- 관리자 `전체 티어표 관리`에서는 별도 `완성본 보기` 버튼은 다시 두지 않되, 카드 썸네일 자체를 눌러 기존처럼 완성본 미리보기 모달을 열 수 있게 복구함.
- `템플릿 요청 관리`의 요청 미리보기는 요약 썸네일 중심 레이아웃을 줄이고, 실제 보드 구조를 먼저 읽는 방향으로 정리해 일반 티어표 완성본을 보는 흐름과 더 비슷하게 맞춤.
## 2026-04-01 v1.3.43
- 템플릿 요청 모달은 `내 티어리스트에도 저장` 토글 상태를 요청 직전에 별도로 고정해 사용하도록 바꿔, 모달이 닫히며 draft가 초기화된 뒤 성공 토스트가 반대로 나오던 문제를 바로잡음.
- 따라서 저장을 끈 상태에서는 `요청만 보냈어요` 문구가 정확히 유지되고, 저장을 켠 상태에서 500이 나는 경우에는 저장 단계에서 실패했다는 안내를 더 분명하게 보여주도록 보강함.
## 2026-04-01 v1.3.42
- 템플릿 요청 시 `내 티어리스트에도 저장`이 켜져 있을 때만 500 오류가 날 수 있던 레거시 `tierlists.source_tierlist_id` nullability 문제도 함께 보강해, 오래된 DB 스키마에서도 요청 전 저장 흐름이 막히지 않도록 정리함.
- 따라서 템플릿 요청 관련 레거시 호환 보정은 `template_requests``tierlists` 양쪽에 모두 반영됐고, 실제 적용을 위해서는 백엔드 재시작 후 재확인이 필요함.
## 2026-04-01 v1.3.41
- 템플릿 요청 등록 시 500 오류가 날 수 있던 레거시 DB 호환 문제를 보강해, 기존 `template_requests` 테이블에 `request_type`, `source_game_id`, `target_game_id`, `status` 컬럼이 빠져 있어도 서버 시작 시 자동으로 마이그레이션되도록 함.
- 따라서 저장 여부와 무관하게 템플릿 요청 흐름은 유지되고, 구버전 DB를 사용 중이더라도 백엔드 재시작 후 같은 요청이 정상 저장되도록 안정성을 높임.
## 2026-04-01 v1.3.40 ## 2026-04-01 v1.3.40
- 관리자 아이템 상세 모달은 내부 스크롤바를 숨기고 본문 스크롤이 배경으로 전파되지 않도록 body scroll lock과 ESC 닫기를 추가해, 검수 중 배경 화면이 함께 움직이던 불편을 줄임. - 관리자 아이템 상세 모달은 내부 스크롤바를 숨기고 본문 스크롤이 배경으로 전파되지 않도록 body scroll lock과 ESC 닫기를 추가해, 검수 중 배경 화면이 함께 움직이던 불편을 줄임.
- 관리자 티어표 관리에서는 `완성본 보기` 흐름을 제거하고, 전체 티어표의 추가 아이템을 클릭하면 같은 아이템 관리 모달로 열어 게임 검색·템플릿 추가·새 템플릿 생성까지 같은 문법으로 처리할 수 있게 통일함. - 관리자 티어표 관리에서는 `완성본 보기` 흐름을 제거하고, 전체 티어표의 추가 아이템을 클릭하면 같은 아이템 관리 모달로 열어 게임 검색·템플릿 추가·새 템플릿 생성까지 같은 문법으로 처리할 수 있게 통일함.

View File

@@ -654,11 +654,7 @@ async function refreshTemplateRequests() {
...request, ...request,
draftGameId: draftGameId:
request.type === 'create' request.type === 'create'
? (request.sourceTierListTitle || '') ? ('tmpl-' + String(request.id || 'request').replace(/[^a-zA-Z0-9]+/g, '').slice(0, 12).toLowerCase())
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'new-template'
: request.targetGameId || request.sourceGameId || '', : request.targetGameId || request.sourceGameId || '',
draftGameName: draftGameName:
request.type === 'create' request.type === 'create'
@@ -1768,45 +1764,64 @@ async function saveFeaturedOrder() {
</div> </div>
<div v-if="!templateRequests.length" class="hint">현재 처리 대기 중인 템플릿 요청이 없어요.</div> <div v-if="!templateRequests.length" class="hint">현재 처리 대기 중인 템플릿 요청이 없어요.</div>
<div v-else class="templateRequestList"> <div v-else class="templateRequestList">
<article v-for="request in templateRequests" :key="request.id" class="templateRequestCard"> <article v-for="request in templateRequests" :key="request.id" class="tierAdminCard templateRequestCard templateRequestCard--aligned">
<div class="templateRequestCard__head"> <div class="templateRequestCard__side">
<div> <button class="tierAdminCard__preview templateRequestCard__preview" type="button" @click="openTemplateRequestPreview(request)">
<div class="templateRequestCard__title">{{ request.sourceTierListTitle }}</div> <img v-if="request.thumbnailSrc" class="tierAdminCard__thumb" :src="toApiUrl(request.thumbnailSrc)" :alt="request.sourceTierListTitle" />
<div v-if="request.sourceDescription" class="templateRequestCard__desc">{{ request.sourceDescription }}</div> <div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
<div class="templateRequestCard__meta"> </button>
{{ templateRequestTypeLabel(request) }} · {{ request.requesterName }} · {{ fmt(request.createdAt) }}
<div class="templateRequestCard__thumbMeta">
<template v-if="request.type === 'create'">
<label class="templateRequestField">
<span class="templateRequestField__label">게임 이름</span>
<input v-model="request.draftGameName" class="input" placeholder="새 게임 이름" />
</label>
<label class="templateRequestField">
<span class="templateRequestField__label">게임 ID</span>
<input v-model="request.draftGameId" class="input" placeholder="임시 게임 ID" />
</label>
</template>
<template v-else>
<div class="templateRequestCard__thumbLabel">게임 이름</div>
<div class="templateRequestCard__thumbValue">{{ request.draftGameName || request.sourceGameName || '-' }}</div>
<div class="templateRequestCard__thumbLabel">게임 ID</div>
<div class="templateRequestCard__thumbValue">{{ request.draftGameId || request.sourceGameId || '-' }}</div>
</template>
</div>
</div>
<div class="tierAdminCard__body">
<div class="tierAdminCard__head">
<div>
<div class="tierAdminCard__title">{{ request.sourceTierListTitle }}</div>
<div v-if="request.sourceDescription" class="tierAdminCard__desc">{{ request.sourceDescription }}</div>
<div class="tierAdminCard__meta">
{{ templateRequestTypeLabel(request) }} · {{ request.requesterName }} · {{ fmt(request.createdAt) }}
</div>
<div class="tierAdminCard__meta">{{ templateRequestTargetLabel(request) }}</div>
</div> </div>
<div class="templateRequestCard__meta">{{ templateRequestTargetLabel(request) }}</div>
</div> </div>
<button class="btn btn--ghost btn--small" @click="openTemplateRequestPreview(request)">
요청 미리보기
</button>
</div>
<div v-if="request.items?.length" class="templateRequestItems"> <div class="tierAdminCard__stats">
<div v-for="item in request.items" :key="item.id" class="templateRequestItem"> <span class="pill">추가 아이템 {{ request.items?.length || 0 }}</span>
<img class="templateRequestItem__thumb" :src="toApiUrl(item.src)" :alt="item.label" /> <span class="pill">{{ request.type === 'create' ? '새 템플릿' : '기존 템플릿 업데이트' }}</span>
<div class="templateRequestItem__label">{{ item.label }}</div>
</div> </div>
</div>
<div v-if="request.type === 'create'" class="templateRequestCard__form"> <div v-if="request.items?.length" class="tierAdminItemList templateRequestCard__items">
<label class="templateRequestField"> <button v-for="item in request.items" :key="item.id" class="tierAdminItem" type="button">
<span class="templateRequestField__label">게임 이름</span> <img class="tierAdminItem__thumb" :src="toApiUrl(item.src)" :alt="item.label" />
<input v-model="request.draftGameName" class="input" placeholder="새 게임 이름" /> <div class="tierAdminItem__title">{{ item.label }}</div>
</label> </button>
<label class="templateRequestField"> </div>
<span class="templateRequestField__label">게임 ID</span>
<input v-model="request.draftGameId" class="input" placeholder="새 게임 ID" />
</label>
</div>
<div class="templateRequestCard__actions"> <div class="templateRequestCard__actions">
<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> </div>
</article> </article>
</div> </div>
@@ -1823,10 +1838,10 @@ async function saveFeaturedOrder() {
<div v-if="!adminTierLists.length" class="hint">조건에 맞는 티어표가 없어요.</div> <div v-if="!adminTierLists.length" class="hint">조건에 맞는 티어표가 없어요.</div>
<div v-else class="tierAdminList"> <div v-else class="tierAdminList">
<article v-for="tierList in adminTierLists" :key="tierList.id" class="tierAdminCard"> <article v-for="tierList in adminTierLists" :key="tierList.id" class="tierAdminCard">
<div class="tierAdminCard__preview"> <button class="tierAdminCard__preview" type="button" @click="openAdminTierList(tierList)">
<img v-if="tierListThumbUrl(tierList)" class="tierAdminCard__thumb" :src="tierListThumbUrl(tierList)" :alt="tierList.title" /> <img v-if="tierListThumbUrl(tierList)" class="tierAdminCard__thumb" :src="tierListThumbUrl(tierList)" :alt="tierList.title" />
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div> <div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
</div> </button>
<div class="tierAdminCard__body"> <div class="tierAdminCard__body">
<div class="tierAdminCard__head"> <div class="tierAdminCard__head">
@@ -2226,8 +2241,9 @@ async function saveFeaturedOrder() {
</div> </div>
<div v-if="previewTierList?.requestPreview" class="requestPreview"> <div v-if="previewTierList?.requestPreview" class="requestPreview">
<div class="requestPreview__summary"> <div class="requestPreview__frame">
<div class="requestPreview__summaryBody"> <div class="requestPreview__header">
<div class="requestPreview__title">{{ previewTierList.title || '티어표 미리보기' }}</div>
<div v-if="previewTierList.description" class="requestPreview__desc">{{ previewTierList.description }}</div> <div v-if="previewTierList.description" class="requestPreview__desc">{{ previewTierList.description }}</div>
<div class="requestPreview__meta"> <div class="requestPreview__meta">
{{ previewRequestHasColumns(previewTierList) ? (previewRequestColumns(previewTierList).length + '열 구성') : '단일 열 구성' }} · {{ previewRequestHasColumns(previewTierList) ? (previewRequestColumns(previewTierList).length + '열 구성') : '단일 열 구성' }} ·
@@ -2235,14 +2251,7 @@ async function saveFeaturedOrder() {
{{ previewTierList.snapshotItems?.length || 0 }} 아이템 {{ previewTierList.snapshotItems?.length || 0 }} 아이템
</div> </div>
</div> </div>
<img <div class="requestPreview__board requestPreview__board--full">
v-if="previewTierList.thumbnailSrc"
class="requestPreview__summaryThumb"
:src="toApiUrl(previewTierList.thumbnailSrc)"
:alt="previewTierList.title"
/>
</div>
<div class="requestPreview__board">
<div v-if="previewRequestHasColumns(previewTierList)" class="requestPreview__boardHead"> <div v-if="previewRequestHasColumns(previewTierList)" class="requestPreview__boardHead">
<div class="requestPreview__rowLabel requestPreview__rowLabel--head"></div> <div class="requestPreview__rowLabel requestPreview__rowLabel--head"></div>
<div class="requestPreview__columnLabels" :style="previewRequestGridStyle(previewTierList)"> <div class="requestPreview__columnLabels" :style="previewRequestGridStyle(previewTierList)">
@@ -2279,16 +2288,17 @@ async function saveFeaturedOrder() {
</div> </div>
</div> </div>
</div> </div>
<div v-if="previewRequestPoolItems(previewTierList).length" class="requestPreview__pool"> <div v-if="previewRequestPoolItems(previewTierList).length" class="requestPreview__pool">
<div class="requestPreview__poolLabel">남은 아이템</div> <div class="requestPreview__poolLabel">남은 아이템</div>
<div class="requestPreview__rowItems requestPreview__rowItems--pool"> <div class="requestPreview__rowItems requestPreview__rowItems--pool">
<div <div
v-for="item in previewRequestPoolItems(previewTierList)" v-for="item in previewRequestPoolItems(previewTierList)"
:key="item.id" :key="item.id"
class="requestPreview__item requestPreview__item--muted" class="requestPreview__item requestPreview__item--muted"
> >
<img class="requestPreview__itemThumb" :src="toApiUrl(item.src)" :alt="item.label" /> <img class="requestPreview__itemThumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div v-if="previewTierList.snapshotShowCharacterNames" class="requestPreview__itemLabel">{{ item.label }}</div> <div v-if="previewTierList.snapshotShowCharacterNames" class="requestPreview__itemLabel">{{ item.label }}</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -3831,72 +3841,35 @@ async function saveFeaturedOrder() {
gap: 14px; gap: 14px;
} }
.templateRequestCard { .templateRequestCard {
display: grid;
gap: 14px;
padding: 18px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: var(--theme-pill-bg);
} }
.templateRequestCard__head { .templateRequestCard--aligned {
display: flex; align-items: start;
}
.templateRequestCard__side {
display: grid;
gap: 12px; gap: 12px;
justify-content: space-between; align-self: start;
align-items: flex-start;
flex-wrap: wrap;
} }
.templateRequestCard__title { .templateRequestCard__preview {
font-weight: 900; align-self: start;
font-size: 18px;
} }
.templateRequestCard__desc { .templateRequestCard__thumbMeta {
margin-top: 6px;
color: var(--theme-text-muted);
line-height: 1.55;
white-space: pre-line;
}
.templateRequestCard__meta {
margin-top: 4px;
font-size: 13px;
opacity: 0.72;
}
.templateRequestItems {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 10px; gap: 10px;
} }
.templateRequestItem { .templateRequestCard__thumbLabel {
display: grid;
gap: 8px;
}
.templateRequestItem__thumb {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: var(--theme-surface-soft);
}
.templateRequestItem__label {
font-size: 12px;
font-weight: 800;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.templateRequestCard__form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.templateRequestField {
display: grid;
gap: 6px;
}
.templateRequestField__label {
font-size: 11px; font-size: 11px;
color: var(--theme-text-faint); color: var(--theme-text-faint);
} }
.templateRequestCard__thumbValue {
font-size: 14px;
font-weight: 800;
color: var(--theme-text);
word-break: break-word;
}
.templateRequestCard__items {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
}
.templateRequestCard__actions { .templateRequestCard__actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -4028,7 +4001,15 @@ async function saveFeaturedOrder() {
padding: 16px; padding: 16px;
} }
.tierAdminCard__preview { .tierAdminCard__preview {
cursor: default; cursor: pointer;
appearance: none;
border: 0;
padding: 0;
background: transparent;
text-align: left;
align-self: start;
display: block;
width: 100%;
} }
.tierAdminCard__thumb { .tierAdminCard__thumb {
width: 100%; width: 100%;
@@ -4238,6 +4219,10 @@ async function saveFeaturedOrder() {
min-height: auto; min-height: auto;
} }
.requestPreview__summary, .requestPreview__summary,
.requestPreview__frame {
padding: 18px;
gap: 18px;
}
.requestPreview__boardHead, .requestPreview__boardHead,
.requestPreview__row { .requestPreview__row {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -762,6 +762,7 @@ async function toggleFavorite() {
} }
async function requestTemplate(type) { async function requestTemplate(type) {
const shouldSaveToMyTierList = !!templateRequestSaveToMyTierList.value
try { try {
isRequestingTemplate.value = true isRequestingTemplate.value = true
await uploadPendingCustomItems() await uploadPendingCustomItems()
@@ -775,7 +776,7 @@ async function requestTemplate(type) {
thumbnailSrc: uploadedThumbnailSrc || thumbnailSrc.value || '', thumbnailSrc: uploadedThumbnailSrc || thumbnailSrc.value || '',
isPublic: !!isPublic.value, isPublic: !!isPublic.value,
showCharacterNames: !!showCharacterNames.value, showCharacterNames: !!showCharacterNames.value,
saveToMyTierList: !!templateRequestSaveToMyTierList.value, saveToMyTierList: shouldSaveToMyTierList,
groups: buildGroupPayload(), groups: buildGroupPayload(),
boardItems: Object.values(itemsById.value), boardItems: Object.values(itemsById.value),
}) })
@@ -798,10 +799,10 @@ async function requestTemplate(type) {
if (type === 'update') closeTemplateUpdateModal() if (type === 'update') closeTemplateUpdateModal()
toast.success( toast.success(
type === 'create' type === 'create'
? templateRequestSaveToMyTierList.value ? shouldSaveToMyTierList
? '템플릿 등록 요청과 내 티어표 저장을 함께 완료했어요.' ? '템플릿 등록 요청과 내 티어표 저장을 함께 완료했어요.'
: '템플릿 등록 요청을 보냈어요.' : '템플릿 등록 요청을 보냈어요.'
: templateRequestSaveToMyTierList.value : shouldSaveToMyTierList
? '템플릿 업데이트 요청과 내 티어표 저장을 함께 완료했어요.' ? '템플릿 업데이트 요청과 내 티어표 저장을 함께 완료했어요.'
: '템플릿 업데이트 요청을 보냈어요.' : '템플릿 업데이트 요청을 보냈어요.'
) )
@@ -822,6 +823,10 @@ async function requestTemplate(type) {
toast.error('요청 제목, 설명, 아이템 이름 중 길이 제한을 넘긴 값이 없는지 확인해주세요.') toast.error('요청 제목, 설명, 아이템 이름 중 길이 제한을 넘긴 값이 없는지 확인해주세요.')
return return
} }
if (e?.status === 500 && shouldSaveToMyTierList) {
toast.error('템플릿 요청 중 내 티어리스트 저장에 실패했어요. 잠시 후 다시 시도해주세요.')
return
}
toast.error(type === 'create' ? '템플릿 등록 요청에 실패했어요.' : '템플릿 업데이트 요청에 실패했어요.') toast.error(type === 'create' ? '템플릿 등록 요청에 실패했어요.' : '템플릿 업데이트 요청에 실패했어요.')
} finally { } finally {
isRequestingTemplate.value = false isRequestingTemplate.value = false

View File

@@ -1,9 +0,0 @@
# Update Log Entry Point
이 프로젝트의 상세 업데이트 로그는 [docs/update.md](/Users/bicute/Desktop/zenn.dev/tier-cursor/docs/update.md)에 계속 누적됩니다.
## 2026-03-30
- 루트 `package.json`에 공용 실행 스크립트(`dev:frontend`, `dev:backend`, `build`, `start`)를 추가했습니다.
- 루트에서도 바로 `npm run build` 같은 공용 명령을 사용할 수 있게 정리했습니다.
- 업데이트 로그 진입점을 루트 `update.md`로 추가해, 이후 작업 시 파일 위치를 바로 찾을 수 있게 했습니다.