릴리스: v0.1.6 MariaDB 개발 환경 및 저장소 설정 정리

This commit is contained in:
2026-03-19 14:48:03 +09:00
commit 0c30ae5cb3
52 changed files with 9346 additions and 0 deletions

View File

@@ -0,0 +1,543 @@
<script setup>
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import Sortable from 'sortablejs'
import * as htmlToImage from 'html-to-image'
import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
const route = useRoute()
const gameId = computed(() => route.params.gameId)
const tierListId = computed(() => route.params.tierListId)
const gameName = ref('')
const groups = ref([
{ id: 'gS', name: 'S', itemIds: [] },
{ id: 'gA', name: 'A', itemIds: [] },
{ id: 'gB', name: 'B', itemIds: [] },
{ id: 'gC', name: 'C', itemIds: [] },
{ id: 'gD', name: 'D', itemIds: [] },
])
const pool = ref([])
const itemsById = ref({})
const title = ref('')
const description = ref('')
const isPublic = ref(false)
const error = ref('')
const isSaving = ref(false)
const boardEl = ref(null)
const groupListEl = ref(null)
const poolEl = ref(null)
const groupDropEls = ref({})
const fileEl = ref(null)
function setGroupDropEl(groupId, el) {
if (!el) return
groupDropEls.value[groupId] = el
}
function getListByContainer(containerEl) {
if (!containerEl) return { type: null, groupId: null }
const t = containerEl.getAttribute('data-list-type')
if (t === 'pool') return { type: 'pool', groupId: null }
if (t === 'group') return { type: 'group', groupId: containerEl.getAttribute('data-group-id') }
return { type: null, groupId: null }
}
function normalizeSort(containerEl) {
const ids = Array.from(containerEl.querySelectorAll('[data-item-id]')).map((n) => n.getAttribute('data-item-id'))
const meta = getListByContainer(containerEl)
if (meta.type === 'pool') pool.value = ids
if (meta.type === 'group') {
const g = groups.value.find((x) => x.id === meta.groupId)
if (g) g.itemIds = ids
}
}
function resolveItemSrc(item) {
const src = item?.src || ''
if (!src) return ''
if (src.startsWith('blob:')) return src
return toApiUrl(src)
}
async function initSortables() {
if (!poolEl.value || !groupListEl.value) return
Sortable.create(groupListEl.value, {
animation: 160,
handle: '[data-group-handle]',
ghostClass: 'ghost',
chosenClass: 'chosen',
onEnd: (evt) => {
const next = [...groups.value]
const [moved] = next.splice(evt.oldIndex, 1)
next.splice(evt.newIndex, 0, moved)
groups.value = next
},
})
Sortable.create(poolEl.value, {
group: 'tier-items',
animation: 160,
draggable: '[data-item-id]',
ghostClass: 'ghost',
chosenClass: 'chosen',
onSort: () => normalizeSort(poolEl.value),
onAdd: () => normalizeSort(poolEl.value),
})
Object.entries(groupDropEls.value).forEach(([gid, el]) => {
Sortable.create(el, {
group: 'tier-items',
animation: 160,
draggable: '[data-item-id]',
ghostClass: 'ghost',
chosenClass: 'chosen',
onSort: () => normalizeSort(el),
onAdd: () => normalizeSort(el),
})
})
}
function addCustomImage(file) {
const url = URL.createObjectURL(file)
const id = `c-${Date.now()}-${Math.random().toString(16).slice(2)}`
itemsById.value = {
...itemsById.value,
[id]: { id, src: url, label: file.name || 'custom', origin: 'custom', pendingFile: file },
}
pool.value = [id, ...pool.value]
}
function openFile() {
fileEl.value?.click()
}
function onFileChange(e) {
const file = e.target.files && e.target.files[0]
if (!file) return
addCustomImage(file)
e.target.value = ''
}
async function downloadImage() {
if (!boardEl.value) return
const dataUrl = await htmlToImage.toPng(boardEl.value, { pixelRatio: 2, backgroundColor: '#0b1220' })
const a = document.createElement('a')
a.href = dataUrl
a.download = `${(title.value || gameName.value || 'tierlist').trim()}.png`
a.click()
}
async function uploadPendingCustomItems() {
const entries = Object.values(itemsById.value).filter((item) => item?.origin === 'custom' && item?.pendingFile)
for (const item of entries) {
const fd = new FormData()
fd.append('label', item.label || 'custom')
fd.append('image', item.pendingFile)
const res = await fetch(toApiUrl('/api/tierlists/custom-items'), {
method: 'POST',
credentials: 'include',
body: fd,
})
if (!res.ok) {
throw new Error('custom_upload_failed')
}
const data = await res.json()
const uploaded = data.item
const nextItemsById = { ...itemsById.value }
delete nextItemsById[item.id]
nextItemsById[uploaded.id] = {
id: uploaded.id,
src: uploaded.src,
label: uploaded.label,
origin: 'custom',
}
itemsById.value = nextItemsById
pool.value = pool.value.map((currentId) => (currentId === item.id ? uploaded.id : currentId))
groups.value = groups.value.map((group) => ({
...group,
itemIds: group.itemIds.map((currentId) => (currentId === item.id ? uploaded.id : currentId)),
}))
}
}
function buildPayload(existingId) {
const finalTitle = (title.value || '').trim() || `${(gameName.value || gameId.value).trim()} 티어표`
return {
id: existingId || undefined,
gameId: gameId.value,
title: finalTitle,
description: (description.value || '').trim(),
isPublic: !!isPublic.value,
groups: groups.value.map((g) => ({ id: g.id, name: g.name, itemIds: g.itemIds })),
pool: Object.values(itemsById.value),
}
}
async function save() {
error.value = ''
isSaving.value = true
try {
await uploadPendingCustomItems()
const payload = buildPayload(tierListId.value && tierListId.value !== 'new' ? tierListId.value : null)
const res = await api.saveTierList(payload)
if (tierListId.value === 'new') history.replaceState({}, '', `/editor/${gameId.value}/${res.tierList.id}`)
} catch (e) {
error.value = '저장 실패: 로그인 상태인지 확인해주세요.'
} finally {
isSaving.value = false
}
}
onMounted(() => {
;(async () => {
try {
const gameRes = await api.getGame(gameId.value)
gameName.value = gameRes.game?.name || gameId.value
const base = (gameRes.items || []).map((img) => ({
id: img.id,
src: img.src,
label: img.label,
origin: 'game',
}))
const map = {}
base.forEach((it) => (map[it.id] = it))
itemsById.value = map
pool.value = base.map((it) => it.id)
} catch (e) {
error.value = '게임 기본 이미지를 불러오지 못했어요.'
}
if (tierListId.value && tierListId.value !== 'new') {
try {
const res = await api.getTierList(tierListId.value)
const t = res.tierList
title.value = t.title
description.value = t.description || ''
isPublic.value = !!t.isPublic
groups.value = t.groups
const map = {}
;(t.pool || []).forEach((it) => (map[it.id] = it))
itemsById.value = map
const grouped = new Set()
groups.value.forEach((g) => g.itemIds.forEach((id) => grouped.add(id)))
pool.value = Object.keys(itemsById.value).filter((id) => !grouped.has(id))
} catch (e) {
error.value = '티어표를 불러오지 못했어요.'
}
}
await nextTick()
await initSortables()
})()
})
</script>
<template>
<section class="head">
<div>
<div class="kicker">{{ gameName || gameId }}</div>
<input v-model="title" class="titleInput" placeholder="티어표 이름을 입력하세요" />
<input
v-model="description"
class="descInput"
placeholder="설명(선택): 이 티어표의 기준/룰"
/>
<div class="hint">
그룹 이름/순서 변경과 아이템 드래그&드롭이 가능합니다. 저장하려면 로그인 <b>저장</b> 누르세요.
</div>
</div>
<div class="actions">
<label class="toggle">
<input v-model="isPublic" type="checkbox" />
<span>공개</span>
</label>
<button class="btn" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
<button class="btn btn--primary" @click="downloadImage">이미지로 다운로드</button>
</div>
</section>
<div v-if="error" class="error">{{ error }}</div>
<section class="layout">
<div ref="boardEl" class="board">
<div ref="groupListEl" class="rows">
<div v-for="g in groups" :key="g.id" class="row">
<div class="row__label">
<span class="grab" title="드래그로 순서 변경" data-group-handle></span>
<input v-model="g.name" class="groupName" />
</div>
<div
class="row__drop"
:data-list-type="'group'"
:data-group-id="g.id"
:ref="(el) => setGroupDropEl(g.id, el)"
>
<div 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">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
</div>
</div>
</div>
</div>
</div>
<div class="sidebar">
<div class="sidebar__title">아이템</div>
<div class="sidebar__hint">게임별 기본 이미지 + 커스텀 업로드를 여기에 모읍니다.</div>
<div ref="poolEl" class="pool" data-list-type="pool">
<div v-for="id in pool" :key="id" class="poolItem" :data-item-id="id">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
</div>
</div>
<input ref="fileEl" type="file" accept="image/*" class="hidden" @change="onFileChange" />
<button class="btn btn--ghost" @click="openFile">커스텀 이미지 추가</button>
</div>
</section>
</template>
<style scoped>
.head {
display: flex;
gap: 14px;
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
padding: 6px 2px 14px;
}
.kicker {
font-size: 12px;
opacity: 0.7;
margin-bottom: 6px;
}
.titleInput {
width: min(520px, 92vw);
font-size: 22px;
font-weight: 800;
letter-spacing: -0.02em;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.92);
outline: none;
}
.descInput {
margin-top: 10px;
width: min(520px, 92vw);
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.18);
color: rgba(255, 255, 255, 0.92);
outline: none;
box-sizing: border-box;
}
.hint {
opacity: 0.78;
margin-top: 8px;
font-size: 13px;
}
.actions {
display: flex;
gap: 10px;
align-items: center;
}
.toggle {
display: inline-flex;
gap: 8px;
align-items: center;
padding: 8px 10px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.12);
font-weight: 800;
cursor: pointer;
user-select: none;
}
.toggle input {
width: 16px;
height: 16px;
}
.btn {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.92);
cursor: pointer;
font-weight: 700;
}
.btn:hover {
background: rgba(255, 255, 255, 0.08);
}
.btn--primary {
background: rgba(110, 231, 183, 0.18);
}
.btn--primary:hover {
background: rgba(110, 231, 183, 0.24);
}
.btn--ghost {
width: 100%;
margin-top: 10px;
}
.layout {
display: grid;
grid-template-columns: 1fr 320px;
gap: 14px;
}
.error {
margin: 10px 0 14px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.3);
background: rgba(239, 68, 68, 0.12);
}
.board {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
border-radius: 16px;
padding: 12px;
}
.rows {
display: grid;
gap: 10px;
}
.row {
display: grid;
grid-template-columns: 180px 1fr;
gap: 10px;
align-items: stretch;
}
.row__label {
border-radius: 14px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-start;
padding: 10px 8px;
font-weight: 900;
overflow: hidden;
}
.grab {
cursor: grab;
opacity: 0.85;
width: 26px;
height: 26px;
display: grid;
place-items: center;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.16);
flex: 0 0 auto;
}
.groupName {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.12);
color: rgba(255, 255, 255, 0.92);
border-radius: 10px;
padding: 6px 8px;
font-weight: 900;
text-align: left;
outline: none;
min-width: 0;
}
.row__drop {
border-radius: 14px;
background: rgba(0, 0, 0, 0.18);
border: 1px solid rgba(255, 255, 255, 0.10);
min-height: 74px;
padding: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-content: flex-start;
overflow: hidden;
position: relative;
}
.row__empty {
opacity: 0.6;
font-size: 13px;
position: absolute;
inset: 0;
display: grid;
place-items: center;
pointer-events: none;
}
.cell {
display: inline-flex;
flex: 0 0 auto;
}
.thumb {
width: 48px;
height: 48px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.06);
object-fit: cover;
}
.sidebar {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
border-radius: 16px;
padding: 12px;
}
.sidebar__title {
font-weight: 900;
margin-bottom: 6px;
}
.sidebar__hint {
opacity: 0.78;
font-size: 13px;
margin-bottom: 10px;
}
.pool {
display: grid;
gap: 10px;
}
.poolItem {
display: grid;
grid-template-columns: 52px 1fr;
gap: 10px;
align-items: center;
padding: 10px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.10);
background: rgba(0, 0, 0, 0.16);
}
.poolItem__label {
font-weight: 800;
opacity: 0.9;
}
.hidden {
display: none;
}
.ghost {
opacity: 0.3;
}
.chosen {
outline: 2px solid rgba(110, 231, 183, 0.5);
border-radius: 14px;
}
@media (max-width: 980px) {
.layout {
grid-template-columns: 1fr;
}
.row {
grid-template-columns: 150px 1fr;
}
}
</style>