Compare commits

..

4 Commits

10 changed files with 333 additions and 38 deletions

View File

@@ -77,6 +77,22 @@ function mapTierListRow(row) {
} }
} }
function getUserDisplayName(row) {
if (!row) return ''
const nickname = (row.nickname || '').trim()
if (nickname) return nickname
const email = (row.email || '').trim()
if (!email) return ''
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,
@@ -537,7 +553,8 @@ async function listPublicTierLists(gameId) {
t.updated_at, t.updated_at,
t.author_id, t.author_id,
u.nickname, u.nickname,
u.email u.email,
u.avatar_src
FROM tierlists t FROM tierlists t
INNER JOIN users u ON u.id = t.author_id INNER JOIN users u ON u.id = t.author_id
${whereClause} ${whereClause}
@@ -554,16 +571,28 @@ async function listPublicTierLists(gameId) {
createdAt: Number(row.created_at), createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at), updatedAt: Number(row.updated_at),
authorId: row.author_id, authorId: row.author_id,
authorName: row.nickname || row.email, authorName: getUserDisplayName(row),
authorAccountName: getUserAccountName(row),
authorAvatarSrc: row.avatar_src || '',
})) }))
} }
async function listUserTierLists(userId) { async function listUserTierLists(userId) {
const rows = await query( const rows = await query(
` `
SELECT id, game_id, title, created_at, updated_at, is_public SELECT
FROM tierlists t.id,
WHERE author_id = ? t.game_id,
t.title,
t.created_at,
t.updated_at,
t.is_public,
u.nickname,
u.email,
u.avatar_src
FROM tierlists t
INNER JOIN users u ON u.id = t.author_id
WHERE t.author_id = ?
ORDER BY updated_at DESC ORDER BY updated_at DESC
`, `,
[userId] [userId]
@@ -576,6 +605,9 @@ async function listUserTierLists(userId) {
createdAt: Number(row.created_at), createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at), updatedAt: Number(row.updated_at),
isPublic: !!row.is_public, isPublic: !!row.is_public,
authorName: getUserDisplayName(row),
authorAccountName: getUserAccountName(row),
authorAvatarSrc: row.avatar_src || '',
})) }))
} }

View File

@@ -66,3 +66,14 @@
## 2026-03-19 v0.1.17 ## 2026-03-19 v0.1.17
- 작성자가 자기 티어표를 직접 삭제할 수 있어야 관리 흐름이 완결되므로, `내 티어표` 화면에 삭제 기능을 추가하기로 결정했다. - 작성자가 자기 티어표를 직접 삭제할 수 있어야 관리 흐름이 완결되므로, `내 티어표` 화면에 삭제 기능을 추가하기로 결정했다.
- 사용자 커스텀 이미지는 서버 용량을 계속 차지하므로, 참조가 하나도 남지 않은 이미지(미사용 항목)를 식별하고 관리자 화면에서 개별/일괄 정리할 수 있도록 하기로 결정했다. - 사용자 커스텀 이미지는 서버 용량을 계속 차지하므로, 참조가 하나도 남지 않은 이미지(미사용 항목)를 식별하고 관리자 화면에서 개별/일괄 정리할 수 있도록 하기로 결정했다.
## 2026-03-19 v0.1.19
- 티어표 공개 여부는 운영 기준상 대부분 공개 공유가 목적이므로, 신규 작성 시 기본값을 `공개 ON`으로 두기로 결정했다.
- 에디터에서 저장 관련 행동은 좌우로 역할을 나눠 `다운로드/삭제``공개/저장`으로 묶는 편이 더 빠르게 인지된다고 판단했다.
- 공개 목록과 내 목록에서 제목만으로는 식별이 어려우므로, 제목 옆에 작성자 아바타와 표시명을 함께 노출하기로 결정했다.
- 티어표 카드 메타 정보는 저장 시각과 업데이트 시각을 동시에 노출하는 대신, 최종 업데이트 시각만 남겨 단순화하기로 결정했다.
## 2026-03-26 v0.1.21
- 목록 썸네일 fallback 문자는 닉네임보다 계정 기준이 더 일관되므로, 아바타 이미지가 없을 때는 계정명 첫 글자를 사용하기로 결정했다.
- 저장 성공은 화면 이동보다 현 위치 유지가 더 중요하므로, 편집을 계속할 수 있는 확인형 모달로 피드백을 제공하기로 결정했다.
- PNG export는 가장자리 여백이 없는 결과보다 중앙 정렬된 카드형 결과물이 더 완성도 있게 보이므로, export 전용 보드에 충분한 바깥 패딩을 포함하기로 했다.

View File

@@ -105,7 +105,11 @@
- 비로그인 사용자는 공개된 티어표를 열람만 할 수 있고, 편집 UI와 저장 동작은 비활성화된다. - 비로그인 사용자는 공개된 티어표를 열람만 할 수 있고, 편집 UI와 저장 동작은 비활성화된다.
- 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다. - 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다.
- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다. - 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
- 작성자는 `내 티어표` 목록에서 저장한 티어표를 직접 삭제할 수 있다. - 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.
- 티어표 목록 메타 정보는 최종 업데이트 시각만 간략하게 표시한다.
- 저장 성공 시에는 에디터 안에서 반투명 오버레이 기반 확인 모달을 띄우고, PNG export 이미지는 외곽 여백을 포함해 생성한다.
## 운영 환경 변수 ## 운영 환경 변수
- 프런트엔드 - 프런트엔드

View File

@@ -16,5 +16,6 @@
- 게임/이미지/티어표 삭제 후 복구 또는 수정 이력 관리 기능을 추가한다. - 게임/이미지/티어표 삭제 후 복구 또는 수정 이력 관리 기능을 추가한다.
- 자동 테스트와 최소한의 배포 체크리스트를 만든다. - 자동 테스트와 최소한의 배포 체크리스트를 만든다.
- 관리자용 커스텀 아이템 승인/복제, 아이템 정렬 UI를 추가한다. - 관리자용 커스텀 아이템 승인/복제, 아이템 정렬 UI를 추가한다.
- 홈 화면 게임 카드와 `직접 티어표 만들기` 카드의 노출 순서를 관리자가 직접 조정할 수 있는 정렬 기능을 추가한다.
- 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다. - 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다.
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다. - 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.

View File

@@ -1,5 +1,25 @@
# 업데이트 로그 # 업데이트 로그
## 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
- **에디터 저장 영역 재정렬**: 공개 기본값을 `ON`으로 바꾸고, 액션 영역을 `이미지 다운로드 / 삭제 / 공개 ON·OFF / 저장` 흐름으로 재배치
- **에디터 삭제 진입점 추가**: 기존 티어표는 편집 화면에서 바로 삭제할 수 있도록 버튼을 추가
- **목록 작성자 표시 개선**: 공개 티어표와 내 티어표 목록의 제목 옆에 원형 아바타와 `by 닉네임(없으면 계정명)`을 표시
- **목록 메타 단순화**: 티어표 카드 하단 정보는 게임 ID, 저장 시각, 라벨 문구를 제거하고 최종 업데이트 시각만 간략하게 노출
## 2026-03-19 v0.1.18
- **미사용 아이콘 필터 수정**: 관리자 아이템 관리의 `미사용 아이콘 보기` 체크 상태가 실제 API 요청의 `orphanOnly` 파라미터로 전달되도록 수정
- **삭제 활성화 흐름 정상화**: 미사용 아이콘만 조회했을 때 `usageCount = 0` 항목의 개별 삭제 버튼이 의도대로 활성화되도록 정리
## 2026-03-19 v0.1.0 ## 2026-03-19 v0.1.0
- **초기 스캐폴딩**: `frontend/`에 Vue3(Vite, JavaScript) 프로젝트 생성 - **초기 스캐폴딩**: `frontend/`에 Vue3(Vite, JavaScript) 프로젝트 생성
- **라우팅/화면 골격**: 게임 선택(`/`), 게임 허브(`/games/:gameId`), 에디터(`/editor/:gameId/...`), 로그인(`/login`), 내 티어표(`/me`), 관리자(`/admin`) 라우트 추가 - **라우팅/화면 골격**: 게임 선택(`/`), 게임 허브(`/games/:gameId`), 에디터(`/editor/:gameId/...`), 로그인(`/login`), 내 티어표(`/me`), 관리자(`/admin`) 라우트 추가

View File

@@ -32,8 +32,10 @@ export const api = {
listGames: () => request('/api/games'), listGames: () => request('/api/games'),
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`), getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`),
listAdminCustomItems: ({ q = '', page = 1, limit = 50 } = {}) => listAdminCustomItems: ({ q = '', page = 1, limit = 50, orphanOnly = false } = {}) =>
request(`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`), request(
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&orphanOnly=${encodeURIComponent(orphanOnly)}`
),
listAdminUsers: () => request('/api/admin/users'), listAdminUsers: () => request('/api/admin/users'),
updateAdminUser: (userId, payload) => updateAdminUser: (userId, payload) =>
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }), request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),

View File

@@ -2,6 +2,7 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api' import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
const route = useRoute() const route = useRoute()
@@ -21,10 +22,21 @@ function fmt(ts) {
day: '2-digit', day: '2-digit',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
second: '2-digit',
}) })
} }
function displayNameOf(tierList) {
return tierList.authorName || '알 수 없음'
}
function avatarSrcOf(tierList) {
return tierList.authorAvatarSrc ? toApiUrl(tierList.authorAvatarSrc) : ''
}
function avatarFallbackOf(tierList) {
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
}
onMounted(async () => { onMounted(async () => {
try { try {
const [gameRes, listRes] = await Promise.all([api.getGame(gameId.value), api.listPublicTierLists(gameId.value)]) const [gameRes, listRes] = await Promise.all([api.getGame(gameId.value), api.listPublicTierLists(gameId.value)])
@@ -66,10 +78,15 @@ function openTierList(id) {
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div> <div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div>
<div v-else class="list"> <div v-else class="list">
<button v-for="t in tierLists" :key="t.id" class="row" @click="openTierList(t.id)"> <button v-for="t in tierLists" :key="t.id" class="row" @click="openTierList(t.id)">
<div class="row__title">{{ t.title }}</div> <div class="row__head">
<div class="row__meta"> <div class="row__title">{{ t.title }}</div>
작성자: {{ t.authorName || '알 수 없음' }} · 저장: {{ fmt(t.createdAt || t.updatedAt) }} · 업데이트: {{ fmt(t.updatedAt) }} <div class="row__author">
<img v-if="avatarSrcOf(t)" class="row__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="row__avatar row__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span>by {{ displayNameOf(t) }}</span>
</div>
</div> </div>
<div class="row__meta">{{ fmt(t.updatedAt) }}</div>
</button> </button>
</div> </div>
</section> </section>
@@ -131,26 +148,72 @@ 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%;
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);
} }
.row__title { .row__title {
font-weight: 800; font-weight: 800;
min-width: 0;
font-size: 18px;
line-height: 1.35;
}
.row__head {
display: grid;
gap: 12px;
align-content: start;
}
.row__author {
display: inline-flex;
gap: 8px;
align-items: center;
font-size: 13px;
opacity: 0.86;
flex: 0 0 auto;
}
.row__avatar {
width: 28px;
height: 28px;
border-radius: 999px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
}
.row__avatar--fallback {
display: grid;
place-items: center;
font-size: 12px;
font-weight: 900;
} }
.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>

View File

@@ -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>

View File

@@ -2,6 +2,7 @@
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { api } from '../lib/api' import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
const router = useRouter() const router = useRouter()
const myLists = ref([]) const myLists = ref([])
@@ -14,10 +15,21 @@ function fmt(ts) {
day: '2-digit', day: '2-digit',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
second: '2-digit',
}) })
} }
function displayNameOf(tierList) {
return tierList.authorName || '알 수 없음'
}
function avatarSrcOf(tierList) {
return tierList.authorAvatarSrc ? toApiUrl(tierList.authorAvatarSrc) : ''
}
function avatarFallbackOf(tierList) {
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
}
onMounted(async () => { onMounted(async () => {
try { try {
const data = await api.listMyTierLists() const data = await api.listMyTierLists()
@@ -56,10 +68,15 @@ async function removeList(t) {
<div v-else class="list"> <div v-else class="list">
<article v-for="t in myLists" :key="t.id" class="row"> <article v-for="t in myLists" :key="t.id" class="row">
<button class="row__body" @click="openList(t)"> <button class="row__body" @click="openList(t)">
<div class="row__title">{{ t.title }}</div> <div class="row__head">
<div class="row__meta"> <div class="row__title">{{ t.title }}</div>
{{ t.gameId }} · 저장: {{ fmt(t.createdAt || t.updatedAt) }} · 업데이트: {{ fmt(t.updatedAt) }} <div class="row__author">
<img v-if="avatarSrcOf(t)" class="row__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="row__avatar row__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span>by {{ displayNameOf(t) }}</span>
</div>
</div> </div>
<div class="row__meta">{{ fmt(t.updatedAt) }}</div>
</button> </button>
<button class="link link--danger" @click="removeList(t)">삭제</button> <button class="link link--danger" @click="removeList(t)">삭제</button>
</article> </article>
@@ -133,6 +150,35 @@ async function removeList(t) {
} }
.row__title { .row__title {
font-weight: 900; font-weight: 900;
min-width: 0;
}
.row__head {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.row__author {
display: inline-flex;
gap: 8px;
align-items: center;
font-size: 13px;
opacity: 0.84;
}
.row__avatar {
width: 28px;
height: 28px;
border-radius: 999px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
}
.row__avatar--fallback {
display: grid;
place-items: center;
font-size: 12px;
font-weight: 900;
} }
.row__meta { .row__meta {
margin-top: 6px; margin-top: 6px;

View File

@@ -27,10 +27,11 @@ const itemsById = ref({})
const title = ref('') const title = ref('')
const description = ref('') const description = ref('')
const isPublic = ref(false) 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,23 @@ async function save() {
} }
} }
function closeSaveModal() {
isSaveModalOpen.value = false
}
async function removeTierList() {
if (!canEdit.value || isNewTierList.value) return
error.value = ''
try {
const ok = window.confirm(`"${title.value || gameName.value || '이 티어표'}"를 삭제할까요?`)
if (!ok) return
await api.deleteTierList(tierListId.value)
router.push(gameId.value === 'freeform' ? '/me' : `/games/${gameId.value}`)
} catch (e) {
error.value = '티어표 삭제에 실패했어요.'
}
}
onMounted(() => { onMounted(() => {
;(async () => { ;(async () => {
await auth.refresh() await auth.refresh()
@@ -378,17 +397,32 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<div class="actions"> <div class="actions">
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }"> <div class="actions__left">
<button class="btn btn--download" @click="downloadImage">이미지 다운로드</button>
<button v-if="canEdit && !isNewTierList" class="btn btn--danger" @click="removeTierList">삭제</button>
</div>
<div class="actions__right">
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }">
<input v-model="isPublic" type="checkbox" :disabled="!canEdit" /> <input v-model="isPublic" type="checkbox" :disabled="!canEdit" />
<span>공개</span> <span>{{ isPublic ? '공개 ON' : '공개 OFF' }}</span>
</label> </label>
<button v-if="canEdit" class="btn" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button> <button v-if="canEdit" class="btn btn--save" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
<button class="btn btn--primary" @click="downloadImage">이미지로 다운로드</button> </div>
</div> </div>
</section> </section>
<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">
@@ -495,10 +529,17 @@ onUnmounted(() => {
font-size: 13px; font-size: 13px;
} }
.actions { .actions {
display: flex;
gap: 14px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.actions__left,
.actions__right {
display: flex; display: flex;
gap: 10px; gap: 10px;
align-items: center; align-items: center;
justify-content: flex-end;
flex-wrap: wrap; flex-wrap: wrap;
} }
.toggle { .toggle {
@@ -539,6 +580,27 @@ onUnmounted(() => {
.btn--primary:hover { .btn--primary:hover {
background: rgba(110, 231, 183, 0.24); background: rgba(110, 231, 183, 0.24);
} }
.btn--download {
justify-self: flex-start;
}
.btn--save {
min-width: 112px;
padding: 12px 18px;
font-size: 15px;
font-weight: 900;
background: rgba(96, 165, 250, 0.22);
border-color: rgba(96, 165, 250, 0.36);
}
.btn--save:hover {
background: rgba(96, 165, 250, 0.3);
}
.btn--danger {
background: rgba(239, 68, 68, 0.14);
border-color: rgba(239, 68, 68, 0.28);
}
.btn--danger:hover {
background: rgba(239, 68, 68, 0.22);
}
.btn--ghost { .btn--ghost {
width: 100%; width: 100%;
margin-top: 10px; margin-top: 10px;
@@ -563,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;
@@ -571,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;
@@ -753,6 +854,16 @@ onUnmounted(() => {
.layout { .layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.actions {
justify-content: stretch;
}
.actions__left,
.actions__right {
width: 100%;
}
.actions__right {
justify-content: flex-end;
}
.row { .row {
grid-template-columns: 150px 1fr; grid-template-columns: 150px 1fr;
} }