Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b58a641453 |
@@ -66,6 +66,9 @@ function mapTierListRow(row) {
|
|||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
authorId: row.author_id,
|
authorId: row.author_id,
|
||||||
|
authorName: getUserDisplayName(row),
|
||||||
|
authorAccountName: getUserAccountName(row),
|
||||||
|
authorAvatarSrc: row.avatar_src || '',
|
||||||
gameId: row.game_id,
|
gameId: row.game_id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
description: row.description || '',
|
description: row.description || '',
|
||||||
@@ -614,9 +617,23 @@ async function listUserTierLists(userId) {
|
|||||||
async function findTierListById(id) {
|
async function findTierListById(id) {
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
`
|
`
|
||||||
SELECT id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at
|
SELECT
|
||||||
FROM tierlists
|
t.id,
|
||||||
WHERE id = ?
|
t.author_id,
|
||||||
|
t.game_id,
|
||||||
|
t.title,
|
||||||
|
t.description,
|
||||||
|
t.is_public,
|
||||||
|
t.groups_json,
|
||||||
|
t.pool_json,
|
||||||
|
t.created_at,
|
||||||
|
t.updated_at,
|
||||||
|
u.nickname,
|
||||||
|
u.email,
|
||||||
|
u.avatar_src
|
||||||
|
FROM tierlists t
|
||||||
|
INNER JOIN users u ON u.id = t.author_id
|
||||||
|
WHERE t.id = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`,
|
`,
|
||||||
[id]
|
[id]
|
||||||
|
|||||||
@@ -77,3 +77,8 @@
|
|||||||
- 목록 썸네일 fallback 문자는 닉네임보다 계정 기준이 더 일관되므로, 아바타 이미지가 없을 때는 계정명 첫 글자를 사용하기로 결정했다.
|
- 목록 썸네일 fallback 문자는 닉네임보다 계정 기준이 더 일관되므로, 아바타 이미지가 없을 때는 계정명 첫 글자를 사용하기로 결정했다.
|
||||||
- 저장 성공은 화면 이동보다 현 위치 유지가 더 중요하므로, 편집을 계속할 수 있는 확인형 모달로 피드백을 제공하기로 결정했다.
|
- 저장 성공은 화면 이동보다 현 위치 유지가 더 중요하므로, 편집을 계속할 수 있는 확인형 모달로 피드백을 제공하기로 결정했다.
|
||||||
- PNG export는 가장자리 여백이 없는 결과보다 중앙 정렬된 카드형 결과물이 더 완성도 있게 보이므로, export 전용 보드에 충분한 바깥 패딩을 포함하기로 했다.
|
- PNG export는 가장자리 여백이 없는 결과보다 중앙 정렬된 카드형 결과물이 더 완성도 있게 보이므로, export 전용 보드에 충분한 바깥 패딩을 포함하기로 했다.
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.22
|
||||||
|
- 무제목 저장은 게임 이름 기본값보다 `이름 없음 + 날짜`가 더 명확하다고 판단해 자동 제목 규칙을 변경했다.
|
||||||
|
- 제목이 비어 있는 티어표는 품질 관리 대상이 될 수 있으므로, 작성 중 단계에서 관리자 임의 삭제 가능성을 미리 안내하기로 결정했다.
|
||||||
|
- 다운로드 이미지에는 편집용 빈 칸 안내 문구를 제외하고, 더 넓은 보드 폭과 하단 작성자/날짜 메타를 포함해 공유용 완성도를 높이기로 결정했다.
|
||||||
|
|||||||
@@ -106,10 +106,12 @@
|
|||||||
- 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다.
|
- 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다.
|
||||||
- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
|
- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
|
||||||
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
||||||
|
- 제목이 비어 있는 상태로 저장하면 내부 제목은 `이름 없음 + 날짜` 형식으로 자동 생성한다.
|
||||||
|
- 제목 입력이 비어 있는 동안에는 무분별한 도배 방지를 위한 관리자 임의 삭제 가능성 안내 문구를 표시한다.
|
||||||
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
||||||
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.
|
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.
|
||||||
- 티어표 목록 메타 정보는 최종 업데이트 시각만 간략하게 표시한다.
|
- 티어표 목록 메타 정보는 최종 업데이트 시각만 간략하게 표시한다.
|
||||||
- 저장 성공 시에는 에디터 안에서 반투명 오버레이 기반 확인 모달을 띄우고, PNG export 이미지는 외곽 여백을 포함해 생성한다.
|
- 저장 성공 시에는 에디터 안에서 반투명 오버레이 기반 확인 모달을 띄우고, PNG export 이미지는 약 `1600px` 폭과 외곽 여백, 작성자/날짜 하단 메타를 포함해 생성한다.
|
||||||
|
|
||||||
## 운영 환경 변수
|
## 운영 환경 변수
|
||||||
- 프런트엔드
|
- 프런트엔드
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-03-26 v0.1.22
|
||||||
|
- **무제목 저장 규칙 변경**: 제목을 비워두고 저장하면 내부 저장 제목을 `이름 없음 + 날짜` 형식으로 생성하도록 변경
|
||||||
|
- **무제목 안내 문구 추가**: 제목 입력이 비어 있는 동안 관리자 임의 삭제 가능성을 알리는 경고 문구를 제목 입력 아래에 표시
|
||||||
|
- **export 보드 확장**: 다운로드용 티어표 이미지는 빈 칸 안내 문구를 숨기고, 약 `1600px` 폭과 더 넉넉한 여백, 하단 작성자/날짜 메타 정보를 포함하도록 조정
|
||||||
|
|
||||||
## 2026-03-26 v0.1.21
|
## 2026-03-26 v0.1.21
|
||||||
- **아바타 fallback 기준 통일**: 티어표 목록에서 작성자 아바타 이미지가 없을 때 닉네임이 아니라 계정명 기준 첫 글자를 표시하도록 정리
|
- **아바타 fallback 기준 통일**: 티어표 목록에서 작성자 아바타 이미지가 없을 때 닉네임이 아니라 계정명 기준 첫 글자를 표시하도록 정리
|
||||||
- **저장 완료 모달 추가**: 에디터에서 저장 성공 시 반투명 오버레이와 확인 버튼이 있는 피드백 모달을 표시하도록 추가
|
- **저장 완료 모달 추가**: 에디터에서 저장 성공 시 반투명 오버레이와 확인 버튼이 있는 피드백 모달을 표시하도록 추가
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ const isSaving = ref(false)
|
|||||||
const isExporting = ref(false)
|
const isExporting = ref(false)
|
||||||
const isSaveModalOpen = ref(false)
|
const isSaveModalOpen = ref(false)
|
||||||
const ownerId = ref('')
|
const ownerId = ref('')
|
||||||
|
const authorName = ref('')
|
||||||
|
const authorAccountName = ref('')
|
||||||
|
const updatedAt = ref(0)
|
||||||
const isDragActive = ref(false)
|
const isDragActive = ref(false)
|
||||||
|
|
||||||
const boardEl = ref(null)
|
const boardEl = ref(null)
|
||||||
@@ -47,6 +50,47 @@ const dropSortables = ref([])
|
|||||||
|
|
||||||
const isNewTierList = computed(() => tierListId.value === 'new')
|
const isNewTierList = computed(() => tierListId.value === 'new')
|
||||||
const canEdit = computed(() => !!auth.user && (!ownerId.value || ownerId.value === auth.user.id))
|
const canEdit = computed(() => !!auth.user && (!ownerId.value || ownerId.value === auth.user.id))
|
||||||
|
const hasCustomTitle = computed(() => !!(title.value || '').trim())
|
||||||
|
const fallbackTimestamp = computed(() => (updatedAt.value ? updatedAt.value : Date.now()))
|
||||||
|
const effectiveAuthorName = computed(() => {
|
||||||
|
const currentNickname = (auth.user?.nickname || '').trim()
|
||||||
|
if (currentNickname) return currentNickname
|
||||||
|
if ((authorName.value || '').trim()) return authorName.value.trim()
|
||||||
|
const currentEmail = (auth.user?.email || '').trim()
|
||||||
|
if (currentEmail) return currentEmail.split('@')[0] || currentEmail
|
||||||
|
return (authorAccountName.value || '').trim() || 'unknown'
|
||||||
|
})
|
||||||
|
const effectiveTitle = computed(() => {
|
||||||
|
const customTitle = (title.value || '').trim()
|
||||||
|
if (customTitle) return customTitle
|
||||||
|
return `이름 없음 ${formatTitleDate(fallbackTimestamp.value)}`
|
||||||
|
})
|
||||||
|
const untitledWarning = computed(
|
||||||
|
() =>
|
||||||
|
canEdit.value &&
|
||||||
|
!hasCustomTitle.value &&
|
||||||
|
'제목 없이 저장된 티어표는 무분별한 도배 방지를 위해 관리자에 의해 임의 삭제될 수 있어요.'
|
||||||
|
)
|
||||||
|
|
||||||
|
function formatTitleDate(ts) {
|
||||||
|
const date = new Date(ts)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExportDate(ts) {
|
||||||
|
return new Date(ts).toLocaleString('ko-KR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function setGroupDropEl(groupId, el) {
|
function setGroupDropEl(groupId, el) {
|
||||||
if (!el) {
|
if (!el) {
|
||||||
@@ -225,7 +269,7 @@ async function downloadImage() {
|
|||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = `${(title.value || gameName.value || 'tierlist').trim()}.png`
|
a.download = `${effectiveTitle.value.trim()}.png`
|
||||||
document.body.appendChild(a)
|
document.body.appendChild(a)
|
||||||
a.click()
|
a.click()
|
||||||
a.remove()
|
a.remove()
|
||||||
@@ -273,7 +317,7 @@ async function uploadPendingCustomItems() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildPayload(existingId) {
|
function buildPayload(existingId) {
|
||||||
const finalTitle = (title.value || '').trim() || `${(gameName.value || gameId.value).trim()} 티어표`
|
const finalTitle = effectiveTitle.value
|
||||||
return {
|
return {
|
||||||
id: existingId || undefined,
|
id: existingId || undefined,
|
||||||
gameId: gameId.value,
|
gameId: gameId.value,
|
||||||
@@ -293,6 +337,9 @@ 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}`)
|
||||||
|
updatedAt.value = Number(res.tierList?.updatedAt || Date.now())
|
||||||
|
authorName.value = res.tierList?.authorName || effectiveAuthorName.value
|
||||||
|
authorAccountName.value = res.tierList?.authorAccountName || authorAccountName.value
|
||||||
isSaveModalOpen.value = true
|
isSaveModalOpen.value = true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '저장 실패: 로그인 상태인지 확인해주세요.'
|
error.value = '저장 실패: 로그인 상태인지 확인해주세요.'
|
||||||
@@ -321,6 +368,8 @@ async function removeTierList() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
;(async () => {
|
;(async () => {
|
||||||
await auth.refresh()
|
await auth.refresh()
|
||||||
|
authorName.value = (auth.user?.nickname || '').trim()
|
||||||
|
authorAccountName.value = ((auth.user?.email || '').trim().split('@')[0] || '').trim()
|
||||||
|
|
||||||
if (isNewTierList.value && !auth.user) {
|
if (isNewTierList.value && !auth.user) {
|
||||||
router.replace(`/login?redirect=/editor/${gameId.value}/new`)
|
router.replace(`/login?redirect=/editor/${gameId.value}/new`)
|
||||||
@@ -352,6 +401,9 @@ onMounted(() => {
|
|||||||
title.value = t.title
|
title.value = t.title
|
||||||
description.value = t.description || ''
|
description.value = t.description || ''
|
||||||
isPublic.value = !!t.isPublic
|
isPublic.value = !!t.isPublic
|
||||||
|
authorName.value = t.authorName || ''
|
||||||
|
authorAccountName.value = t.authorAccountName || ''
|
||||||
|
updatedAt.value = Number(t.updatedAt || 0)
|
||||||
groups.value = t.groups
|
groups.value = t.groups
|
||||||
const map = {}
|
const map = {}
|
||||||
;(t.pool || []).forEach((it) => (map[it.id] = it))
|
;(t.pool || []).forEach((it) => (map[it.id] = it))
|
||||||
@@ -381,6 +433,7 @@ onUnmounted(() => {
|
|||||||
<div class="head__meta">
|
<div class="head__meta">
|
||||||
<div class="kicker">{{ gameName || gameId }}</div>
|
<div class="kicker">{{ gameName || gameId }}</div>
|
||||||
<input v-model="title" class="titleInput" placeholder="티어표 이름을 입력하세요" :readonly="!canEdit" />
|
<input v-model="title" class="titleInput" placeholder="티어표 이름을 입력하세요" :readonly="!canEdit" />
|
||||||
|
<div v-if="untitledWarning" class="titleNotice">{{ untitledWarning }}</div>
|
||||||
<input
|
<input
|
||||||
v-model="description"
|
v-model="description"
|
||||||
class="descInput"
|
class="descInput"
|
||||||
@@ -429,7 +482,7 @@ onUnmounted(() => {
|
|||||||
<button class="btn btn--ghost" @click="addGroup">티어 추가</button>
|
<button class="btn btn--ghost" @click="addGroup">티어 추가</button>
|
||||||
</div>
|
</div>
|
||||||
<div ref="exportBoardEl" class="exportBoard" :class="{ 'exportBoard--active': isExporting }">
|
<div ref="exportBoardEl" class="exportBoard" :class="{ 'exportBoard--active': isExporting }">
|
||||||
<div v-if="isExporting" class="exportBoard__title">{{ title || gameName || gameId }}</div>
|
<div v-if="isExporting" class="exportBoard__title">{{ effectiveTitle }}</div>
|
||||||
<div v-if="isExporting && description" class="exportBoard__description">{{ description }}</div>
|
<div v-if="isExporting && description" class="exportBoard__description">{{ description }}</div>
|
||||||
<div ref="groupListEl" class="rows">
|
<div ref="groupListEl" class="rows">
|
||||||
<div v-for="g in groups" :key="g.id" class="row">
|
<div v-for="g in groups" :key="g.id" class="row">
|
||||||
@@ -449,13 +502,17 @@ onUnmounted(() => {
|
|||||||
:data-group-id="g.id"
|
:data-group-id="g.id"
|
||||||
:ref="(el) => setGroupDropEl(g.id, el)"
|
:ref="(el) => setGroupDropEl(g.id, el)"
|
||||||
>
|
>
|
||||||
<div class="row__empty" v-show="g.itemIds.length === 0">여기로 드래그해서 배치</div>
|
<div v-if="!isExporting" class="row__empty" v-show="g.itemIds.length === 0">여기로 드래그해서 배치</div>
|
||||||
<div v-for="id in g.itemIds" :key="id" class="cell" :data-item-id="id">
|
<div v-for="id in g.itemIds" :key="id" class="cell" :data-item-id="id">
|
||||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="isExporting" class="exportBoard__footer">
|
||||||
|
<span>{{ effectiveAuthorName }}</span>
|
||||||
|
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -528,6 +585,11 @@ onUnmounted(() => {
|
|||||||
opacity: 0.78;
|
opacity: 0.78;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
.titleNotice {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: rgba(251, 191, 36, 0.94);
|
||||||
|
}
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
@@ -667,8 +729,11 @@ onUnmounted(() => {
|
|||||||
.exportBoard--active {
|
.exportBoard--active {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 28px;
|
width: 1600px;
|
||||||
border-radius: 24px;
|
max-width: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 48px 56px;
|
||||||
|
border-radius: 28px;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top, rgba(96, 165, 250, 0.14), transparent 38%),
|
radial-gradient(circle at top, rgba(96, 165, 250, 0.14), transparent 38%),
|
||||||
rgba(11, 18, 32, 0.98);
|
rgba(11, 18, 32, 0.98);
|
||||||
@@ -686,6 +751,16 @@ onUnmounted(() => {
|
|||||||
opacity: 0.74;
|
opacity: 0.74;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
.exportBoard__footer {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 18px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
.rows {
|
.rows {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
Reference in New Issue
Block a user