Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6a031cfe4 |
@@ -279,6 +279,27 @@ async function updateUserProfile({ id, nickname, avatarSrc }) {
|
||||
return findUserById(id)
|
||||
}
|
||||
|
||||
async function listUsers() {
|
||||
const rows = await query(
|
||||
'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users ORDER BY created_at ASC, email ASC'
|
||||
)
|
||||
return rows.map(mapUserRow)
|
||||
}
|
||||
|
||||
async function adminUpdateUser({ id, email, nickname, isAdmin }) {
|
||||
await query('UPDATE users SET email = ?, nickname = ?, is_admin = ? WHERE id = ?', [
|
||||
email,
|
||||
nickname || '',
|
||||
isAdmin ? 1 : 0,
|
||||
id,
|
||||
])
|
||||
return findUserById(id)
|
||||
}
|
||||
|
||||
async function adminDeleteUser(id) {
|
||||
await query('DELETE FROM users WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
async function listGames() {
|
||||
const rows = await query(
|
||||
'SELECT id, name, thumbnail_src, created_at FROM games WHERE id <> ? ORDER BY created_at ASC, name ASC',
|
||||
@@ -516,6 +537,9 @@ module.exports = {
|
||||
findUserById,
|
||||
createUser,
|
||||
updateUserProfile,
|
||||
listUsers,
|
||||
adminUpdateUser,
|
||||
adminDeleteUser,
|
||||
listGames,
|
||||
findGameById,
|
||||
listGameItems,
|
||||
|
||||
@@ -4,6 +4,7 @@ const multer = require('multer')
|
||||
const { z } = require('zod')
|
||||
const { nanoid } = require('nanoid')
|
||||
const {
|
||||
findUserById,
|
||||
findGameById,
|
||||
createGame,
|
||||
updateGameThumbnail,
|
||||
@@ -11,6 +12,9 @@ const {
|
||||
deleteGameItem,
|
||||
deleteGame,
|
||||
listCustomItems,
|
||||
listUsers,
|
||||
adminUpdateUser,
|
||||
adminDeleteUser,
|
||||
} = require('../db')
|
||||
const { requireAdmin } = require('../middleware/auth')
|
||||
|
||||
@@ -83,4 +87,53 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
||||
res.json({ items })
|
||||
})
|
||||
|
||||
router.get('/users', requireAdmin, async (req, res) => {
|
||||
const users = await listUsers()
|
||||
res.json({ users })
|
||||
})
|
||||
|
||||
router.patch('/users/:userId', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
nickname: z.string().trim().max(40).default(''),
|
||||
isAdmin: z.boolean(),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
if (req.params.userId === req.session.userId && !parsed.data.isAdmin) {
|
||||
return res.status(400).json({ error: 'self_admin_required' })
|
||||
}
|
||||
|
||||
const user = await findUserById(req.params.userId)
|
||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
try {
|
||||
const updated = await adminUpdateUser({
|
||||
id: user.id,
|
||||
email: parsed.data.email,
|
||||
nickname: parsed.data.nickname,
|
||||
isAdmin: parsed.data.isAdmin,
|
||||
})
|
||||
res.json({ user: updated })
|
||||
} catch (e) {
|
||||
if (e && e.code === 'ER_DUP_ENTRY') {
|
||||
return res.status(409).json({ error: 'email_taken' })
|
||||
}
|
||||
throw e
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/users/:userId', requireAdmin, async (req, res) => {
|
||||
if (req.params.userId === req.session.userId) {
|
||||
return res.status(400).json({ error: 'cannot_delete_self' })
|
||||
}
|
||||
|
||||
const user = await findUserById(req.params.userId)
|
||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
await adminDeleteUser(user.id)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
|
||||
@@ -50,3 +50,9 @@
|
||||
- 관리자 화면은 좌우 여백이 크게 남는 구조보다, 상단 2열 작업 카드와 하단 목록 영역으로 나누는 편이 더 안정적이라고 판단해 레이아웃을 재정리했다.
|
||||
- 게임 목록에 없는 주제로도 바로 작업할 수 있도록, 시스템 전용 `freeform` 게임을 내부적으로 유지하고 홈 화면에서는 `직접 티어표 만들기` 카드로 노출하기로 결정했다.
|
||||
- 게임 제안은 현재 운영 흐름과 맞지 않아 사용자 진입점과 프런트 API에서 제거하고, 대신 관리자에게는 사용자 커스텀 아이템 검토 기능을 제공하기로 했다.
|
||||
|
||||
## 2026-03-19 v0.1.12
|
||||
- 앱 전체 배경은 화면 폭 전체를 사용하고, 개별 콘텐츠만 필요한 만큼 정렬하는 방향이 더 자연스럽다고 판단해 전역 최대 폭 제한을 제거했다.
|
||||
- 비로그인 사용자가 새 티어표를 편집하다 저장 단계에서 막히는 경험은 손실 위험이 크므로, 작성 시작 자체를 로그인 사용자로 제한하고 공개 티어표는 읽기 전용으로 보여주기로 결정했다.
|
||||
- 커스텀 이미지 업로드는 단일 파일 선택만으로는 불편하므로, 다중 선택과 드래그 앤 드롭을 기본 흐름으로 보강했다.
|
||||
- 관리자에게는 게임 관리뿐 아니라 회원 관리 책임도 필요하므로, 회원 목록 조회/수정/삭제 기능을 추가하기로 결정했다.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
|
||||
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
||||
- 역할: 티어 그룹 편집, 관리자 아이템/커스텀 아이템 드래그 앤 드롭, 저장, 공개 여부 설정, PNG 다운로드
|
||||
- 역할: 티어 그룹 편집, 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 작성 권한 제어, 저장, 공개 여부 설정, PNG 다운로드
|
||||
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`
|
||||
|
||||
## `/login`
|
||||
@@ -27,8 +27,8 @@
|
||||
|
||||
## `/admin`
|
||||
- 화면 파일: `frontend/src/views/AdminView.vue`
|
||||
- 역할: 작업 모드 선택, 기존 게임 선택 또는 새 게임 생성, 선택된 게임의 썸네일/아이템 관리, 파일 선택 즉시 미리보기, 사용자 커스텀 아이템 검토/다운로드, 파일 입력 초기화, 아이템 삭제, 게임 삭제
|
||||
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `GET /api/admin/custom-items`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
|
||||
- 역할: 작업 모드 선택, 기존 게임 선택 또는 새 게임 생성, 선택된 게임의 썸네일/아이템 관리, 사용자 커스텀 아이템 검토/다운로드, 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
|
||||
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `GET /api/admin/custom-items`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
|
||||
|
||||
## `/profile`
|
||||
- 화면 파일: `frontend/src/views/ProfileView.vue`
|
||||
|
||||
@@ -81,6 +81,9 @@
|
||||
- `POST /api/admin/games/:gameId/thumbnail`
|
||||
- `POST /api/admin/games/:gameId/images`
|
||||
- `GET /api/admin/custom-items`
|
||||
- `GET /api/admin/users`
|
||||
- `PATCH /api/admin/users/:userId`
|
||||
- `DELETE /api/admin/users/:userId`
|
||||
- `DELETE /api/admin/games/:gameId/items/:itemId`
|
||||
- `DELETE /api/admin/games/:gameId`
|
||||
|
||||
@@ -90,6 +93,12 @@
|
||||
- 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다.
|
||||
- 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다.
|
||||
- 사용자 업로드 커스텀 아이템은 관리자 화면 하단 검토 영역에서 목록/다운로드할 수 있다.
|
||||
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정과 계정 삭제가 가능하다.
|
||||
|
||||
## 티어표 접근 메모
|
||||
- `new` 작성 경로는 로그인한 사용자만 진입할 수 있다.
|
||||
- 비로그인 사용자는 공개된 티어표를 열람만 할 수 있고, 편집 UI와 저장 동작은 비활성화된다.
|
||||
- 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다.
|
||||
|
||||
## 운영 환경 변수
|
||||
- 프런트엔드
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
## 즉시 확인 필요
|
||||
- 사용자 커스텀 아이템을 관리자 기본 템플릿으로 승격하는 승인/복제 흐름은 아직 없다.
|
||||
- 회원 관리에서 아바타/작성 티어표 수/최근 활동 같은 보조 정보는 아직 표시하지 않는다.
|
||||
|
||||
## 배포 전 작업
|
||||
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.
|
||||
@@ -14,3 +15,4 @@
|
||||
- 게임/이미지/티어표 삭제 후 복구 또는 수정 이력 관리 기능을 추가한다.
|
||||
- 자동 테스트와 최소한의 배포 체크리스트를 만든다.
|
||||
- 관리자용 커스텀 아이템 승인/복제, 아이템 정렬 UI를 추가한다.
|
||||
- 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다.
|
||||
|
||||
@@ -79,3 +79,9 @@
|
||||
- **직접 티어표 만들기 추가**: 홈 화면에 게임 카드와 동일한 형태의 `직접 티어표 만들기` 진입점을 추가하고, 내부 전용 `freeform` 게임 레코드로 1회성 빈 티어표 저장 흐름을 지원
|
||||
- **게임 제안 흐름 제거**: 홈 화면의 `새로운 게임 제안` 버튼/모달과 관련 프런트 API를 제거해 현재 운영 흐름에 맞게 단순화
|
||||
- **커스텀 아이템 검토 영역 추가**: 관리자 페이지에서 사용자 업로드 커스텀 아이템을 목록으로 보고 다운로드할 수 있는 검토 영역과 조회 API를 추가
|
||||
|
||||
## 2026-03-19 v0.1.12
|
||||
- **전역 레이아웃 폭 정리**: 앱 메인 영역의 고정 최대 너비를 제거해 배경과 페이지 폭이 잘린 듯 보이지 않도록 조정
|
||||
- **작성 권한 제한**: 비로그인 사용자는 새 티어표 작성 화면으로 직접 진입할 수 없도록 하고, 공개된 티어표는 읽기 전용으로만 보이게 조정
|
||||
- **커스텀 이미지 업로드 개선**: 에디터의 커스텀 이미지 추가 영역에 다중 파일 선택과 드래그 앤 드롭 업로드를 추가
|
||||
- **회원 관리 추가**: 관리자 페이지에서 가입 회원 목록 조회, 이메일/닉네임/권한 수정, 계정 삭제가 가능한 관리 영역과 API를 추가
|
||||
|
||||
@@ -143,8 +143,8 @@ async function logout() {
|
||||
}
|
||||
.app-main {
|
||||
padding: 20px 18px 60px;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.user {
|
||||
|
||||
@@ -33,6 +33,10 @@ export const api = {
|
||||
listGames: () => request('/api/games'),
|
||||
getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`),
|
||||
listAdminCustomItems: () => request('/api/admin/custom-items'),
|
||||
listAdminUsers: () => request('/api/admin/users'),
|
||||
updateAdminUser: (userId, payload) =>
|
||||
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),
|
||||
deleteAdminUser: (userId) => request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
|
||||
|
||||
listPublicTierLists: (gameId) =>
|
||||
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`),
|
||||
|
||||
@@ -9,6 +9,7 @@ const isAdmin = computed(() => !!auth.user?.isAdmin)
|
||||
|
||||
const games = ref([])
|
||||
const customItems = ref([])
|
||||
const users = ref([])
|
||||
const adminMode = ref('existing')
|
||||
const selectedGameId = ref('')
|
||||
const selectedGame = ref(null)
|
||||
@@ -33,7 +34,7 @@ const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value)
|
||||
|
||||
onMounted(async () => {
|
||||
await auth.refresh()
|
||||
await Promise.all([refreshGames(), refreshCustomItems()])
|
||||
await Promise.all([refreshGames(), refreshCustomItems(), refreshUsers()])
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -60,6 +61,21 @@ async function refreshCustomItems() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUsers() {
|
||||
if (!auth.user?.isAdmin) return
|
||||
try {
|
||||
const data = await api.listAdminUsers()
|
||||
users.value = (data.users || []).map((user) => ({
|
||||
...user,
|
||||
draftEmail: user.email,
|
||||
draftNickname: user.nickname || '',
|
||||
draftIsAdmin: !!user.isAdmin,
|
||||
}))
|
||||
} catch (e) {
|
||||
error.value = '회원 목록을 불러오지 못했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
function resetMessages() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
@@ -258,6 +274,50 @@ async function removeGame() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUser(user) {
|
||||
resetMessages()
|
||||
try {
|
||||
const data = await api.updateAdminUser(user.id, {
|
||||
email: user.draftEmail,
|
||||
nickname: user.draftNickname,
|
||||
isAdmin: !!user.draftIsAdmin,
|
||||
})
|
||||
const updated = data.user
|
||||
users.value = users.value.map((entry) =>
|
||||
entry.id === updated.id
|
||||
? {
|
||||
...updated,
|
||||
draftEmail: updated.email,
|
||||
draftNickname: updated.nickname || '',
|
||||
draftIsAdmin: !!updated.isAdmin,
|
||||
}
|
||||
: entry
|
||||
)
|
||||
success.value = '회원 정보를 저장했어요.'
|
||||
} catch (e) {
|
||||
error.value = '회원 정보 저장에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
async function removeUser(user) {
|
||||
resetMessages()
|
||||
if (user.id === auth.user?.id) {
|
||||
error.value = '현재 로그인한 관리자 계정은 직접 삭제할 수 없어요.'
|
||||
return
|
||||
}
|
||||
|
||||
const ok = window.confirm(`${user.email} 계정을 삭제할까요? 작성한 티어표와 커스텀 이미지도 함께 삭제됩니다.`)
|
||||
if (!ok) return
|
||||
|
||||
try {
|
||||
await api.deleteAdminUser(user.id)
|
||||
users.value = users.value.filter((entry) => entry.id !== user.id)
|
||||
success.value = '회원 계정을 삭제했어요.'
|
||||
} catch (e) {
|
||||
error.value = '회원 삭제에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
const displayThumbnailUrl = computed(() => {
|
||||
if (thumbPreviewUrl.value) return thumbPreviewUrl.value
|
||||
if (selectedGame.value?.game?.thumbnailSrc) return toApiUrl(selectedGame.value.game.thumbnailSrc)
|
||||
@@ -398,6 +458,43 @@ function fmt(ts) {
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
<div class="panel__title">회원 관리</div>
|
||||
<div class="hint hint--tight">가입한 계정의 이메일, 닉네임, 관리자 권한을 수정하거나 계정을 삭제할 수 있어요.</div>
|
||||
</div>
|
||||
<button class="btn btn--ghost" @click="refreshUsers">새로고침</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!users.length" class="hint">아직 가입한 회원이 없어요.</div>
|
||||
<div v-else class="userList">
|
||||
<article v-for="user in users" :key="user.id" class="userCard">
|
||||
<div class="userCard__head">
|
||||
<div>
|
||||
<div class="userCard__title">{{ user.nickname || '닉네임 없음' }}</div>
|
||||
<div class="userCard__meta">{{ fmt(user.createdAt) }}</div>
|
||||
</div>
|
||||
<span class="roleBadge" :class="{ 'roleBadge--admin': user.draftIsAdmin }">
|
||||
{{ user.draftIsAdmin ? '관리자' : '일반 회원' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input v-model="user.draftEmail" class="input" placeholder="이메일" />
|
||||
<input v-model="user.draftNickname" class="input" placeholder="닉네임" />
|
||||
<label class="checkRow">
|
||||
<input v-model="user.draftIsAdmin" type="checkbox" :disabled="user.id === auth.user?.id" />
|
||||
<span>관리자 권한</span>
|
||||
</label>
|
||||
|
||||
<div class="userCard__actions">
|
||||
<button class="btn btn--ghost" @click="saveUser(user)">회원 저장</button>
|
||||
<button class="btn btn--danger" :disabled="user.id === auth.user?.id" @click="removeUser(user)">회원 삭제</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
@@ -703,6 +800,55 @@ function fmt(ts) {
|
||||
font-size: 13px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.userList {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.userCard {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
padding: 14px;
|
||||
}
|
||||
.userCard__head {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.userCard__title {
|
||||
font-weight: 900;
|
||||
}
|
||||
.userCard__meta {
|
||||
margin-top: 4px;
|
||||
opacity: 0.72;
|
||||
font-size: 13px;
|
||||
}
|
||||
.userCard__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.roleBadge {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.roleBadge--admin {
|
||||
background: rgba(96, 165, 250, 0.18);
|
||||
}
|
||||
.checkRow {
|
||||
margin-top: 12px;
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
opacity: 0.88;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.section--topGrid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -719,7 +865,8 @@ function fmt(ts) {
|
||||
width: min(100%, 256px);
|
||||
}
|
||||
.thumbGrid,
|
||||
.customItemGrid {
|
||||
.customItemGrid,
|
||||
.userList {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.itemPreviewCard {
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const gameId = computed(() => route.params.gameId)
|
||||
|
||||
@@ -34,6 +36,10 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
function createNew() {
|
||||
if (!auth.user) {
|
||||
router.push(`/login?redirect=/editor/${gameId.value}/new`)
|
||||
return
|
||||
}
|
||||
router.push(`/editor/${gameId.value}/new`)
|
||||
}
|
||||
|
||||
@@ -50,7 +56,7 @@ function openTierList(id) {
|
||||
<p class="desc">새 티어표를 만들거나, 다른 사람들이 올린 티어표를 확인하세요.</p>
|
||||
</div>
|
||||
<div class="head__right">
|
||||
<button class="primary" @click="createNew">새로운 티어표 만들기</button>
|
||||
<button class="primary" @click="createNew">{{ auth.user ? '새로운 티어표 만들기' : '로그인 후 새 티어표 만들기' }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const items = ref([])
|
||||
const error = ref('')
|
||||
@@ -24,6 +26,10 @@ function goGame(gameId) {
|
||||
}
|
||||
|
||||
function goFreeform() {
|
||||
if (!auth.user) {
|
||||
router.push('/login?redirect=/editor/freeform/new')
|
||||
return
|
||||
}
|
||||
router.push('/editor/freeform/new')
|
||||
}
|
||||
|
||||
@@ -48,7 +54,7 @@ function thumbUrl(g) {
|
||||
<div class="thumbWrap thumbWrap--freeform">
|
||||
<div class="thumbFallback">+</div>
|
||||
</div>
|
||||
<div class="card__eyebrow">템플릿 없이 시작</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)">
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { api } from '../lib/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const email = ref('')
|
||||
@@ -27,7 +28,7 @@ async function submit() {
|
||||
try {
|
||||
if (mode.value === 'signup') await auth.signup(email.value, password.value)
|
||||
else await auth.login(email.value, password.value)
|
||||
router.push('/me')
|
||||
router.push(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
|
||||
} catch (e) {
|
||||
error.value = '로그인/회원가입에 실패했어요.'
|
||||
}
|
||||
@@ -146,4 +147,3 @@ async function submit() {
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import Sortable from 'sortablejs'
|
||||
import * as htmlToImage from 'html-to-image'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const gameId = computed(() => route.params.gameId)
|
||||
const tierListId = computed(() => route.params.tierListId)
|
||||
const gameName = ref('')
|
||||
@@ -27,6 +30,8 @@ const description = ref('')
|
||||
const isPublic = ref(false)
|
||||
const error = ref('')
|
||||
const isSaving = ref(false)
|
||||
const ownerId = ref('')
|
||||
const isDragActive = ref(false)
|
||||
|
||||
const boardEl = ref(null)
|
||||
const groupListEl = ref(null)
|
||||
@@ -34,6 +39,9 @@ const poolEl = ref(null)
|
||||
const groupDropEls = ref({})
|
||||
const fileEl = ref(null)
|
||||
|
||||
const isNewTierList = computed(() => tierListId.value === 'new')
|
||||
const canEdit = computed(() => !!auth.user && (!ownerId.value || ownerId.value === auth.user.id))
|
||||
|
||||
function setGroupDropEl(groupId, el) {
|
||||
if (!el) return
|
||||
groupDropEls.value[groupId] = el
|
||||
@@ -104,6 +112,7 @@ async function initSortables() {
|
||||
}
|
||||
|
||||
function addCustomImage(file) {
|
||||
if (!file || !file.type.startsWith('image/')) return
|
||||
const url = URL.createObjectURL(file)
|
||||
const id = `c-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
itemsById.value = {
|
||||
@@ -114,16 +123,35 @@ function addCustomImage(file) {
|
||||
}
|
||||
|
||||
function openFile() {
|
||||
if (!canEdit.value) return
|
||||
fileEl.value?.click()
|
||||
}
|
||||
|
||||
function onFileChange(e) {
|
||||
const file = e.target.files && e.target.files[0]
|
||||
if (!file) return
|
||||
addCustomImage(file)
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (!files.length) return
|
||||
files.forEach(addCustomImage)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
function onDragEnter() {
|
||||
if (!canEdit.value) return
|
||||
isDragActive.value = true
|
||||
}
|
||||
|
||||
function onDragLeave(event) {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||
isDragActive.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onDropFiles(event) {
|
||||
if (!canEdit.value) return
|
||||
isDragActive.value = false
|
||||
const files = Array.from(event.dataTransfer?.files || []).filter((file) => file.type.startsWith('image/'))
|
||||
files.forEach(addCustomImage)
|
||||
}
|
||||
|
||||
async function downloadImage() {
|
||||
if (!boardEl.value) return
|
||||
const dataUrl = await htmlToImage.toPng(boardEl.value, { pixelRatio: 2, backgroundColor: '#0b1220' })
|
||||
@@ -200,6 +228,13 @@ async function save() {
|
||||
|
||||
onMounted(() => {
|
||||
;(async () => {
|
||||
await auth.refresh()
|
||||
|
||||
if (isNewTierList.value && !auth.user) {
|
||||
router.replace(`/login?redirect=/editor/${gameId.value}/new`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const gameRes = await api.getGame(gameId.value)
|
||||
gameName.value = gameRes.game?.name || gameId.value
|
||||
@@ -221,6 +256,7 @@ onMounted(() => {
|
||||
try {
|
||||
const res = await api.getTierList(tierListId.value)
|
||||
const t = res.tierList
|
||||
ownerId.value = t.authorId
|
||||
title.value = t.title
|
||||
description.value = t.description || ''
|
||||
isPublic.value = !!t.isPublic
|
||||
@@ -237,7 +273,9 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
await initSortables()
|
||||
if (canEdit.value) {
|
||||
await initSortables()
|
||||
}
|
||||
})()
|
||||
})
|
||||
</script>
|
||||
@@ -246,22 +284,28 @@ onMounted(() => {
|
||||
<section class="head">
|
||||
<div>
|
||||
<div class="kicker">{{ gameName || gameId }}</div>
|
||||
<input v-model="title" class="titleInput" placeholder="티어표 이름을 입력하세요" />
|
||||
<input v-model="title" class="titleInput" placeholder="티어표 이름을 입력하세요" :readonly="!canEdit" />
|
||||
<input
|
||||
v-model="description"
|
||||
class="descInput"
|
||||
placeholder="설명(선택): 이 티어표의 기준/룰"
|
||||
:readonly="!canEdit"
|
||||
/>
|
||||
<div class="hint">
|
||||
그룹 이름/순서 변경과 아이템 드래그&드롭이 가능합니다. 저장하려면 로그인 후 <b>저장</b>을 누르세요.
|
||||
<template v-if="canEdit">
|
||||
그룹 이름/순서 변경과 아이템 드래그&드롭이 가능합니다. 저장하려면 <b>저장</b>을 누르세요.
|
||||
</template>
|
||||
<template v-else>
|
||||
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 수 있어요.
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<label class="toggle">
|
||||
<input v-model="isPublic" type="checkbox" />
|
||||
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }">
|
||||
<input v-model="isPublic" type="checkbox" :disabled="!canEdit" />
|
||||
<span>공개</span>
|
||||
</label>
|
||||
<button class="btn" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
|
||||
<button v-if="canEdit" class="btn" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
|
||||
<button class="btn btn--primary" @click="downloadImage">이미지로 다운로드</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -274,7 +318,7 @@ onMounted(() => {
|
||||
<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" />
|
||||
<input v-model="g.name" class="groupName" :readonly="!canEdit" />
|
||||
</div>
|
||||
<div
|
||||
class="row__drop"
|
||||
@@ -293,15 +337,29 @@ onMounted(() => {
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="sidebar__title">아이템</div>
|
||||
<div class="sidebar__hint">게임별 기본 이미지 + 커스텀 업로드를 여기에 모읍니다.</div>
|
||||
<div class="sidebar__hint">
|
||||
{{ canEdit ? '게임별 기본 이미지와 커스텀 업로드를 여기에 모읍니다.' : '공개 티어표는 보기 전용입니다.' }}
|
||||
</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
|
||||
v-if="canEdit"
|
||||
class="dropzone"
|
||||
:class="{ 'dropzone--active': isDragActive }"
|
||||
@dragenter.prevent="onDragEnter"
|
||||
@dragover.prevent="onDragEnter"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDropFiles"
|
||||
>
|
||||
<div class="dropzone__title">커스텀 이미지 추가</div>
|
||||
<div class="dropzone__desc">여러 이미지를 한 번에 드래그하거나 파일 선택으로 추가할 수 있어요.</div>
|
||||
</div>
|
||||
<input ref="fileEl" type="file" accept="image/*" multiple class="hidden" @change="onFileChange" />
|
||||
<button v-if="canEdit" class="btn btn--ghost" @click="openFile">파일 선택</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -369,6 +427,10 @@ onMounted(() => {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.toggle--disabled {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
.btn {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
@@ -504,6 +566,27 @@ onMounted(() => {
|
||||
font-size: 13px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.dropzone {
|
||||
margin-top: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
text-align: center;
|
||||
}
|
||||
.dropzone--active {
|
||||
border-color: rgba(110, 231, 183, 0.6);
|
||||
background: rgba(110, 231, 183, 0.08);
|
||||
}
|
||||
.dropzone__title {
|
||||
font-weight: 900;
|
||||
}
|
||||
.dropzone__desc {
|
||||
margin-top: 6px;
|
||||
opacity: 0.74;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.pool {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
||||
Reference in New Issue
Block a user