릴리스: v0.1.24 드래그 정렬과 export 크기 조정

This commit is contained in:
2026-03-26 15:05:43 +09:00
parent c1575783f0
commit b1d5355123
6 changed files with 71 additions and 8 deletions

View File

@@ -1,5 +1,6 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import Sortable from 'sortablejs'
import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth'
@@ -37,6 +38,8 @@ const itemPreviewUrl = ref('')
const thumbPreviewUrl = ref('')
const itemFileInput = ref(null)
const thumbFileInput = ref(null)
const featuredListEl = ref(null)
const featuredSortable = ref(null)
const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id)
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value)
@@ -52,11 +55,13 @@ const availableGamesForFeatured = computed(() => games.value.filter((game) => !f
onMounted(async () => {
await auth.refresh()
await Promise.all([refreshGames(), refreshCustomItems(), refreshUsers()])
await syncFeaturedSortable()
})
onUnmounted(() => {
clearPreviewUrl('item')
clearPreviewUrl('thumb')
destroyFeaturedSortable()
})
function resetMessages() {
@@ -77,11 +82,40 @@ async function refreshGames() {
.filter((game) => game.displayRank != null)
.sort((a, b) => a.displayRank - b.displayRank)
.map((game) => game.id)
await syncFeaturedSortable()
} catch (e) {
error.value = '게임 목록을 불러오지 못했어요.'
}
}
function destroyFeaturedSortable() {
if (featuredSortable.value) {
featuredSortable.value.destroy()
featuredSortable.value = null
}
}
async function syncFeaturedSortable() {
await nextTick()
destroyFeaturedSortable()
if (!featuredListEl.value) return
featuredSortable.value = Sortable.create(featuredListEl.value, {
animation: 160,
draggable: '[data-featured-id]',
handle: '[data-featured-handle]',
ghostClass: 'ghost',
chosenClass: 'chosen',
onEnd: (evt) => {
if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return
const nextIds = [...featuredGameIds.value]
const [moved] = nextIds.splice(evt.oldIndex, 1)
nextIds.splice(evt.newIndex, 0, moved)
featuredGameIds.value = nextIds
},
})
}
async function refreshCustomItems() {
if (!auth.user?.isAdmin) return
try {
@@ -436,11 +470,13 @@ function addFeaturedGame(gameId) {
return
}
featuredGameIds.value = [...featuredGameIds.value, gameId]
syncFeaturedSortable()
}
function removeFeaturedGame(gameId) {
resetMessages()
featuredGameIds.value = featuredGameIds.value.filter((id) => id !== gameId)
syncFeaturedSortable()
}
function moveFeaturedGame(gameId, direction) {
@@ -451,6 +487,7 @@ function moveFeaturedGame(gameId, direction) {
const [moved] = nextIds.splice(currentIndex, 1)
nextIds.splice(nextIndex, 0, moved)
featuredGameIds.value = nextIds
syncFeaturedSortable()
}
async function saveFeaturedOrder() {
@@ -501,8 +538,8 @@ async function saveFeaturedOrder() {
<div class="featuredOrderPanel__list">
<div class="section__title">상단 고정 목록</div>
<div v-if="!featuredGames.length" class="hint">아직 상단 고정 게임이 없어요.</div>
<div v-else class="featuredList">
<article v-for="(game, index) in featuredGames" :key="game.id" class="featuredCard">
<div v-else ref="featuredListEl" class="featuredList">
<article v-for="(game, index) in featuredGames" :key="game.id" class="featuredCard" :data-featured-id="game.id">
<div class="featuredCard__meta">
<span class="featuredCard__rank">{{ index + 1 }}</span>
<div>
@@ -511,6 +548,7 @@ async function saveFeaturedOrder() {
</div>
</div>
<div class="featuredCard__actions">
<button class="btn btn--ghost btn--small" data-featured-handle>드래그</button>
<button class="btn btn--ghost btn--small" :disabled="index === 0" @click="moveFeaturedGame(game.id, -1)">위로</button>
<button class="btn btn--ghost btn--small" :disabled="index === featuredGames.length - 1" @click="moveFeaturedGame(game.id, 1)">아래로</button>
<button class="btn btn--danger btn--small" @click="removeFeaturedGame(game.id)">제외</button>
@@ -856,6 +894,9 @@ async function saveFeaturedOrder() {
flex-wrap: wrap;
justify-content: flex-end;
}
.featuredCard [data-featured-handle] {
cursor: grab;
}
.featuredPickerItem {
width: 100%;
display: flex;
@@ -983,6 +1024,12 @@ async function saveFeaturedOrder() {
padding: 8px 10px;
font-size: 11px;
}
.ghost {
opacity: 0.4;
}
.chosen {
outline: 2px solid rgba(96, 165, 250, 0.45);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.45;

View File

@@ -263,7 +263,7 @@ async function downloadImage() {
try {
const targetEl = exportBoardEl.value || boardEl.value
const blob = await htmlToImage.toBlob(targetEl, { pixelRatio: 2, backgroundColor: '#0b1220' })
const blob = await htmlToImage.toBlob(targetEl, { pixelRatio: 1.5, backgroundColor: '#0b1220' })
if (!blob) throw new Error('image_export_failed')
const url = URL.createObjectURL(blob)
@@ -729,10 +729,10 @@ onUnmounted(() => {
.exportBoard--active {
display: grid;
gap: 12px;
width: 1600px;
width: 1360px;
max-width: none;
box-sizing: border-box;
padding: 48px 56px;
padding: 44px 52px;
border-radius: 28px;
background:
radial-gradient(circle at top, rgba(96, 165, 250, 0.14), transparent 38%),