Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3f9f8e4d0 | |||
| 2374cd9272 |
@@ -86,6 +86,13 @@ function getUserDisplayName(row) {
|
|||||||
return email.split('@')[0] || email
|
return email.split('@')[0] || email
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getUserAccountName(row) {
|
||||||
|
if (!row) return ''
|
||||||
|
const email = (row.email || '').trim()
|
||||||
|
if (!email) return ''
|
||||||
|
return email.split('@')[0] || email
|
||||||
|
}
|
||||||
|
|
||||||
async function createPool() {
|
async function createPool() {
|
||||||
const rootConnection = await mysql.createConnection({
|
const rootConnection = await mysql.createConnection({
|
||||||
host: DB_HOST,
|
host: DB_HOST,
|
||||||
@@ -565,6 +572,7 @@ async function listPublicTierLists(gameId) {
|
|||||||
updatedAt: Number(row.updated_at),
|
updatedAt: Number(row.updated_at),
|
||||||
authorId: row.author_id,
|
authorId: row.author_id,
|
||||||
authorName: getUserDisplayName(row),
|
authorName: getUserDisplayName(row),
|
||||||
|
authorAccountName: getUserAccountName(row),
|
||||||
authorAvatarSrc: row.avatar_src || '',
|
authorAvatarSrc: row.avatar_src || '',
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -598,6 +606,7 @@ async function listUserTierLists(userId) {
|
|||||||
updatedAt: Number(row.updated_at),
|
updatedAt: Number(row.updated_at),
|
||||||
isPublic: !!row.is_public,
|
isPublic: !!row.is_public,
|
||||||
authorName: getUserDisplayName(row),
|
authorName: getUserDisplayName(row),
|
||||||
|
authorAccountName: getUserAccountName(row),
|
||||||
authorAvatarSrc: row.avatar_src || '',
|
authorAvatarSrc: row.avatar_src || '',
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,3 +72,8 @@
|
|||||||
- 에디터에서 저장 관련 행동은 좌우로 역할을 나눠 `다운로드/삭제`와 `공개/저장`으로 묶는 편이 더 빠르게 인지된다고 판단했다.
|
- 에디터에서 저장 관련 행동은 좌우로 역할을 나눠 `다운로드/삭제`와 `공개/저장`으로 묶는 편이 더 빠르게 인지된다고 판단했다.
|
||||||
- 공개 목록과 내 목록에서 제목만으로는 식별이 어려우므로, 제목 옆에 작성자 아바타와 표시명을 함께 노출하기로 결정했다.
|
- 공개 목록과 내 목록에서 제목만으로는 식별이 어려우므로, 제목 옆에 작성자 아바타와 표시명을 함께 노출하기로 결정했다.
|
||||||
- 티어표 카드 메타 정보는 저장 시각과 업데이트 시각을 동시에 노출하는 대신, 최종 업데이트 시각만 남겨 단순화하기로 결정했다.
|
- 티어표 카드 메타 정보는 저장 시각과 업데이트 시각을 동시에 노출하는 대신, 최종 업데이트 시각만 남겨 단순화하기로 결정했다.
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.21
|
||||||
|
- 목록 썸네일 fallback 문자는 닉네임보다 계정 기준이 더 일관되므로, 아바타 이미지가 없을 때는 계정명 첫 글자를 사용하기로 결정했다.
|
||||||
|
- 저장 성공은 화면 이동보다 현 위치 유지가 더 중요하므로, 편집을 계속할 수 있는 확인형 모달로 피드백을 제공하기로 결정했다.
|
||||||
|
- PNG export는 가장자리 여백이 없는 결과보다 중앙 정렬된 카드형 결과물이 더 완성도 있게 보이므로, export 전용 보드에 충분한 바깥 패딩을 포함하기로 했다.
|
||||||
|
|||||||
@@ -107,7 +107,9 @@
|
|||||||
- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
|
- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
|
||||||
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
||||||
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
||||||
|
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.
|
||||||
- 티어표 목록 메타 정보는 최종 업데이트 시각만 간략하게 표시한다.
|
- 티어표 목록 메타 정보는 최종 업데이트 시각만 간략하게 표시한다.
|
||||||
|
- 저장 성공 시에는 에디터 안에서 반투명 오버레이 기반 확인 모달을 띄우고, PNG export 이미지는 외곽 여백을 포함해 생성한다.
|
||||||
|
|
||||||
## 운영 환경 변수
|
## 운영 환경 변수
|
||||||
- 프런트엔드
|
- 프런트엔드
|
||||||
|
|||||||
@@ -16,5 +16,6 @@
|
|||||||
- 게임/이미지/티어표 삭제 후 복구 또는 수정 이력 관리 기능을 추가한다.
|
- 게임/이미지/티어표 삭제 후 복구 또는 수정 이력 관리 기능을 추가한다.
|
||||||
- 자동 테스트와 최소한의 배포 체크리스트를 만든다.
|
- 자동 테스트와 최소한의 배포 체크리스트를 만든다.
|
||||||
- 관리자용 커스텀 아이템 승인/복제, 아이템 정렬 UI를 추가한다.
|
- 관리자용 커스텀 아이템 승인/복제, 아이템 정렬 UI를 추가한다.
|
||||||
|
- 홈 화면 게임 카드와 `직접 티어표 만들기` 카드의 노출 순서를 관리자가 직접 조정할 수 있는 정렬 기능을 추가한다.
|
||||||
- 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다.
|
- 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다.
|
||||||
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
|
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.21
|
||||||
|
- **아바타 fallback 기준 통일**: 티어표 목록에서 작성자 아바타 이미지가 없을 때 닉네임이 아니라 계정명 기준 첫 글자를 표시하도록 정리
|
||||||
|
- **저장 완료 모달 추가**: 에디터에서 저장 성공 시 반투명 오버레이와 확인 버튼이 있는 피드백 모달을 표시하도록 추가
|
||||||
|
- **다운로드 이미지 여백 보강**: PNG export 전용 보드에 외곽 패딩과 배경 여백을 넣어 콘텐츠가 가장자리에 붙어 보이지 않도록 조정
|
||||||
|
|
||||||
|
## 2026-03-19 v0.1.20
|
||||||
|
- **게임 선택 카드 순서 조정**: 홈 화면에서 일반 게임 카드를 먼저 보여주고 `직접 티어표 만들기` 카드는 마지막에 배치
|
||||||
|
- **게임 카드 3열 레이아웃**: PC 기준 게임 선택 화면 카드를 3열로 재구성하고, 썸네일을 16:9 비율로 통일
|
||||||
|
- **공개 티어표 카드 3열 레이아웃**: 게임 허브의 공개 티어표 목록도 PC 기준 3열 카드형으로 재배치하고 태블릿/모바일에서는 자동 줄바꿈되도록 조정
|
||||||
|
|
||||||
## 2026-03-19 v0.1.19
|
## 2026-03-19 v0.1.19
|
||||||
- **에디터 저장 영역 재정렬**: 공개 기본값을 `ON`으로 바꾸고, 액션 영역을 `이미지 다운로드 / 삭제 / 공개 ON·OFF / 저장` 흐름으로 재배치
|
- **에디터 저장 영역 재정렬**: 공개 기본값을 `ON`으로 바꾸고, 액션 영역을 `이미지 다운로드 / 삭제 / 공개 ON·OFF / 저장` 흐름으로 재배치
|
||||||
- **에디터 삭제 진입점 추가**: 기존 티어표는 편집 화면에서 바로 삭제할 수 있도록 버튼을 추가
|
- **에디터 삭제 진입점 추가**: 기존 티어표는 편집 화면에서 바로 삭제할 수 있도록 버튼을 추가
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function avatarSrcOf(tierList) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function avatarFallbackOf(tierList) {
|
function avatarFallbackOf(tierList) {
|
||||||
return displayNameOf(tierList).trim().charAt(0).toUpperCase() || '?'
|
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -148,17 +148,22 @@ function openTierList(id) {
|
|||||||
}
|
}
|
||||||
.list {
|
.list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
}
|
}
|
||||||
.row {
|
.row {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 12px;
|
padding: 14px;
|
||||||
border-radius: 14px;
|
border-radius: 16px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
align-content: start;
|
||||||
|
min-height: 168px;
|
||||||
}
|
}
|
||||||
.row:hover {
|
.row:hover {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
@@ -166,13 +171,13 @@ function openTierList(id) {
|
|||||||
.row__title {
|
.row__title {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
.row__head {
|
.row__head {
|
||||||
display: flex;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-content: start;
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
.row__author {
|
.row__author {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -198,7 +203,17 @@ function openTierList(id) {
|
|||||||
}
|
}
|
||||||
.row__meta {
|
.row__meta {
|
||||||
opacity: 0.78;
|
opacity: 0.78;
|
||||||
margin-top: 6px;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.list {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const auth = useAuthStore()
|
|||||||
|
|
||||||
const items = ref([])
|
const items = ref([])
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const games = computed(() => items.value)
|
const games = computed(() => items.value.filter((item) => item.id !== 'freeform'))
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -50,13 +50,6 @@ function thumbUrl(g) {
|
|||||||
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
<section class="grid">
|
<section class="grid">
|
||||||
<button class="card card--freeform" @click="goFreeform">
|
|
||||||
<div class="thumbWrap thumbWrap--freeform">
|
|
||||||
<div class="thumbFallback">+</div>
|
|
||||||
</div>
|
|
||||||
<div class="card__eyebrow">{{ auth.user ? '템플릿 없이 시작' : '로그인 후 작성 가능' }}</div>
|
|
||||||
<div class="card__title">직접 티어표 만들기</div>
|
|
||||||
</button>
|
|
||||||
<button v-for="g in games" :key="g.id" class="card" @click="goGame(g.id)">
|
<button v-for="g in games" :key="g.id" class="card" @click="goGame(g.id)">
|
||||||
<div class="thumbWrap">
|
<div class="thumbWrap">
|
||||||
<img v-if="thumbUrl(g)" class="thumb" :src="thumbUrl(g)" :alt="g.name" />
|
<img v-if="thumbUrl(g)" class="thumb" :src="thumbUrl(g)" :alt="g.name" />
|
||||||
@@ -64,6 +57,13 @@ function thumbUrl(g) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="card__title">{{ g.name }}</div>
|
<div class="card__title">{{ g.name }}</div>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="card card--freeform" @click="goFreeform">
|
||||||
|
<div class="thumbWrap thumbWrap--freeform">
|
||||||
|
<div class="thumbFallback">+</div>
|
||||||
|
</div>
|
||||||
|
<div class="card__eyebrow">{{ auth.user ? '템플릿 없이 시작' : '로그인 후 작성 가능' }}</div>
|
||||||
|
<div class="card__title">직접 티어표 만들기</div>
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ function thumbUrl(g) {
|
|||||||
}
|
}
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@ function thumbUrl(g) {
|
|||||||
}
|
}
|
||||||
.thumbWrap {
|
.thumbWrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 140px;
|
aspect-ratio: 16 / 9;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(0, 0, 0, 0.18);
|
background: rgba(0, 0, 0, 0.18);
|
||||||
@@ -154,4 +154,9 @@ function thumbUrl(g) {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@media (min-width: 721px) and (max-width: 1100px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function avatarSrcOf(tierList) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function avatarFallbackOf(tierList) {
|
function avatarFallbackOf(tierList) {
|
||||||
return displayNameOf(tierList).trim().charAt(0).toUpperCase() || '?'
|
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const isPublic = ref(true)
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
const isExporting = ref(false)
|
const isExporting = ref(false)
|
||||||
|
const isSaveModalOpen = ref(false)
|
||||||
const ownerId = ref('')
|
const ownerId = ref('')
|
||||||
const isDragActive = ref(false)
|
const isDragActive = ref(false)
|
||||||
|
|
||||||
@@ -292,6 +293,7 @@ async function save() {
|
|||||||
const payload = buildPayload(tierListId.value && tierListId.value !== 'new' ? tierListId.value : null)
|
const payload = buildPayload(tierListId.value && tierListId.value !== 'new' ? tierListId.value : null)
|
||||||
const res = await api.saveTierList(payload)
|
const res = await api.saveTierList(payload)
|
||||||
if (tierListId.value === 'new') history.replaceState({}, '', `/editor/${gameId.value}/${res.tierList.id}`)
|
if (tierListId.value === 'new') history.replaceState({}, '', `/editor/${gameId.value}/${res.tierList.id}`)
|
||||||
|
isSaveModalOpen.value = true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '저장 실패: 로그인 상태인지 확인해주세요.'
|
error.value = '저장 실패: 로그인 상태인지 확인해주세요.'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -299,6 +301,10 @@ async function save() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeSaveModal() {
|
||||||
|
isSaveModalOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
async function removeTierList() {
|
async function removeTierList() {
|
||||||
if (!canEdit.value || isNewTierList.value) return
|
if (!canEdit.value || isNewTierList.value) return
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -407,6 +413,16 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
|
|
||||||
|
<div v-if="isSaveModalOpen" class="modalOverlay" @click.self="closeSaveModal">
|
||||||
|
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="saveModalTitle">
|
||||||
|
<div id="saveModalTitle" class="modalCard__title">저장 완료</div>
|
||||||
|
<div class="modalCard__desc">티어표가 저장되었어요. 이어서 더 수정한 뒤 다시 저장할 수도 있어요.</div>
|
||||||
|
<div class="modalCard__actions">
|
||||||
|
<button class="btn btn--save" @click="closeSaveModal">확인</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="layout">
|
<section class="layout">
|
||||||
<div ref="boardEl" class="board">
|
<div ref="boardEl" class="board">
|
||||||
<div v-if="canEdit && !isExporting" class="boardTools">
|
<div v-if="canEdit && !isExporting" class="boardTools">
|
||||||
@@ -609,6 +625,40 @@ onUnmounted(() => {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
align-self: start;
|
align-self: start;
|
||||||
}
|
}
|
||||||
|
.modalOverlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(4, 8, 16, 0.68);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
.modalCard {
|
||||||
|
width: min(100%, 420px);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
background: linear-gradient(180deg, rgba(17, 24, 39, 0.96), rgba(11, 18, 32, 0.96));
|
||||||
|
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.38);
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.modalCard__title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.modalCard__desc {
|
||||||
|
line-height: 1.6;
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
.modalCard__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
.boardTools {
|
.boardTools {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -617,6 +667,11 @@ onUnmounted(() => {
|
|||||||
.exportBoard--active {
|
.exportBoard--active {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
padding: 28px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(96, 165, 250, 0.14), transparent 38%),
|
||||||
|
rgba(11, 18, 32, 0.98);
|
||||||
}
|
}
|
||||||
.exportBoard__title {
|
.exportBoard__title {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
|
|||||||
Reference in New Issue
Block a user