Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2a838ff34 |
@@ -513,7 +513,7 @@ async function findPrimaryAdminUser() {
|
||||
return mapUserRow(rows[0])
|
||||
}
|
||||
|
||||
async function listUsers({ queryText = '', sort = 'recent' } = {}) {
|
||||
async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' } = {}) {
|
||||
const where = []
|
||||
const params = []
|
||||
const trimmedQuery = typeof queryText === 'string' ? queryText.trim() : ''
|
||||
@@ -523,12 +523,19 @@ async function listUsers({ queryText = '', sort = 'recent' } = {}) {
|
||||
params.push(`%${trimmedQuery}%`, `%${trimmedQuery}%`)
|
||||
}
|
||||
|
||||
const isAsc = direction === 'asc'
|
||||
const orderBy =
|
||||
sort === 'created'
|
||||
? 'u.created_at DESC, recent_activity_at DESC, u.email ASC'
|
||||
? isAsc
|
||||
? 'u.created_at ASC, recent_activity_at ASC, u.email ASC'
|
||||
: 'u.created_at DESC, recent_activity_at DESC, u.email ASC'
|
||||
: sort === 'tierlists'
|
||||
? 'tierlist_count DESC, recent_activity_at DESC, u.email ASC'
|
||||
: 'recent_activity_at DESC, u.created_at ASC, u.email ASC'
|
||||
? isAsc
|
||||
? 'tierlist_count ASC, recent_activity_at ASC, u.email ASC'
|
||||
: 'tierlist_count DESC, recent_activity_at DESC, u.email ASC'
|
||||
: isAsc
|
||||
? 'recent_activity_at ASC, u.created_at ASC, u.email ASC'
|
||||
: 'recent_activity_at DESC, u.created_at ASC, u.email ASC'
|
||||
|
||||
const rows = await query(
|
||||
`
|
||||
|
||||
@@ -565,12 +565,13 @@ router.get('/users', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
sort: z.enum(['recent', 'created', 'tierlists']).optional().default('recent'),
|
||||
direction: z.enum(['asc', 'desc']).optional().default('desc'),
|
||||
})
|
||||
const parsed = schema.safeParse(req.query)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const [users, primaryAdmin] = await Promise.all([
|
||||
listUsers({ queryText: parsed.data.q, sort: parsed.data.sort }),
|
||||
listUsers({ queryText: parsed.data.q, sort: parsed.data.sort, direction: parsed.data.direction }),
|
||||
findPrimaryAdminUser(),
|
||||
])
|
||||
res.json({ users: users.map((user) => decorateAdminUser(user, primaryAdmin)) })
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-01 v1.3.12
|
||||
- 관리자 회원 관리 상단에 정렬 방향 선택을 추가해, 최근 활동순·가입순·작성 티어표순을 각각 오름차순/내림차순으로 다시 볼 수 있게 확장함.
|
||||
- 회원 정보 수정, 새 게임 생성, 비밀번호 초기화 모달은 Settings 톤 입력 스타일을 유지하면서 각 입력칸에 글자 수 피드백을 함께 보여주도록 정리함.
|
||||
- 로그인, 설정, 티어 에디터 제목·설명·요청 제목·요청 설명·티어 행 이름에도 최대 길이와 현재 입력 길이 안내를 붙여, 제출 전에 제한을 바로 인지할 수 있게 개선함.
|
||||
|
||||
## 2026-04-01 v1.3.11
|
||||
- **회원 관리 편집 모달 전환**: 관리자 회원 카드를 읽기 전용 정보 카드로 바꾸고, `회원 정보 수정` 버튼으로 Settings 톤의 편집 모달에서 이메일/닉네임/운영자 권한을 저장하도록 재구성
|
||||
- **회원 검색/정렬 추가**: 회원 관리 상단에 이메일/닉네임 검색과 `최근 활동순`, `가입순`, `작성 티어표 많은 순` 정렬을 추가해 운영자가 원하는 기준으로 목록을 다시 볼 수 있도록 확장
|
||||
|
||||
@@ -62,8 +62,8 @@ export const api = {
|
||||
approveAdminTemplateRequest: (requestId, payload) =>
|
||||
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/approve`, { method: 'POST', body: payload || {} }),
|
||||
rejectAdminTemplateRequest: (requestId) => request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/reject`, { method: 'POST', body: {} }),
|
||||
listAdminUsers: ({ q = '', sort = 'recent' } = {}) =>
|
||||
request(`/api/admin/users?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}`),
|
||||
listAdminUsers: ({ q = '', sort = 'recent', direction = 'desc' } = {}) =>
|
||||
request(`/api/admin/users?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}&direction=${encodeURIComponent(direction)}`),
|
||||
updateAdminUser: (userId, payload) =>
|
||||
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),
|
||||
updateAdminUserPassword: (userId, payload) =>
|
||||
|
||||
@@ -64,6 +64,7 @@ const modalTargetCustomItem = ref(null)
|
||||
const users = ref([])
|
||||
const userQuery = ref('')
|
||||
const userSort = ref('recent')
|
||||
const userSortDirection = ref('desc')
|
||||
const imageStats = ref(null)
|
||||
const imageQueue = ref({ concurrency: 1, activeCount: 0, pendingCount: 0 })
|
||||
const imageRecentJobs = ref([])
|
||||
@@ -515,7 +516,7 @@ async function refreshTemplateRequests() {
|
||||
async function refreshUsers() {
|
||||
if (!auth.user?.isAdmin) return
|
||||
try {
|
||||
const data = await api.listAdminUsers({ q: userQuery.value, sort: userSort.value })
|
||||
const data = await api.listAdminUsers({ q: userQuery.value, sort: userSort.value, direction: userSortDirection.value })
|
||||
users.value = (data.users || []).map((user) => ({
|
||||
...user,
|
||||
isAvatarBusy: false,
|
||||
@@ -1589,6 +1590,10 @@ async function saveFeaturedOrder() {
|
||||
<option value="created">가입순</option>
|
||||
<option value="tierlists">작성 티어표 많은 순</option>
|
||||
</select>
|
||||
<select v-model="userSortDirection" class="select toolbar__select" @change="submitUserFilters">
|
||||
<option value="desc">내림차순</option>
|
||||
<option value="asc">오름차순</option>
|
||||
</select>
|
||||
<button class="btn btn--ghost toolbar__button" type="button" @click="submitUserFilters">조회</button>
|
||||
</div>
|
||||
|
||||
@@ -1658,8 +1663,22 @@ async function saveFeaturedOrder() {
|
||||
<div class="modalCard__title">새 게임 만들기</div>
|
||||
<div class="modalCard__desc">게임 이름과 고유 ID를 입력한 뒤 생성하면 바로 아래 상세 관리 화면으로 이어집니다.</div>
|
||||
<div class="modalCard__form">
|
||||
<input v-model="newGameName" class="input" placeholder="게임 이름" />
|
||||
<input v-model="newGameId" class="input" placeholder="game id (영문/숫자)" @keydown.enter.prevent="createGame" />
|
||||
<label class="field">
|
||||
<span class="field__label">게임 이름</span>
|
||||
<input v-model="newGameName" class="field__input" maxlength="60" placeholder="게임 이름" />
|
||||
<span class="field__hint">{{ newGameName.length }}/60자</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">게임 ID</span>
|
||||
<input
|
||||
v-model="newGameId"
|
||||
class="field__input"
|
||||
maxlength="120"
|
||||
placeholder="game id (영문/숫자)"
|
||||
@keydown.enter.prevent="createGame"
|
||||
/>
|
||||
<span class="field__hint">영문, 숫자, 하이픈 조합 권장 · {{ newGameId.length }}/120자</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeGameCreateModal">취소</button>
|
||||
@@ -1675,14 +1694,14 @@ async function saveFeaturedOrder() {
|
||||
<div class="userEditForm">
|
||||
<label class="field">
|
||||
<span class="field__label">이메일</span>
|
||||
<input v-model="modalUserDraftEmail" class="field__input" placeholder="계정 이메일" />
|
||||
<span class="field__hint">로그인 계정으로 사용하는 이메일입니다.</span>
|
||||
<input v-model="modalUserDraftEmail" class="field__input" maxlength="255" placeholder="계정 이메일" />
|
||||
<span class="field__hint">로그인 계정으로 사용하는 이메일입니다. {{ modalUserDraftEmail.length }}/255자</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field__label">닉네임</span>
|
||||
<input v-model="modalUserDraftNickname" class="field__input" placeholder="표시용 닉네임" />
|
||||
<span class="field__hint">티어표 작성자명과 프로필에 표시됩니다.</span>
|
||||
<input v-model="modalUserDraftNickname" class="field__input" maxlength="40" placeholder="표시용 닉네임" />
|
||||
<span class="field__hint">티어표 작성자명과 프로필에 표시됩니다. {{ modalUserDraftNickname.length }}/40자</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
@@ -1706,7 +1725,18 @@ async function saveFeaturedOrder() {
|
||||
<div class="modalCard__title">비밀번호 초기화</div>
|
||||
<div class="modalCard__desc">{{ modalTargetUser ? `${userDisplayName(modalTargetUser)} 계정에 설정할 새 비밀번호를 입력해주세요.` : '' }}</div>
|
||||
<div class="modalCard__form">
|
||||
<input v-model="modalPasswordDraft" class="input" type="password" placeholder="초기화할 비밀번호 입력" @keydown.enter.prevent="confirmUserPasswordReset" />
|
||||
<label class="field">
|
||||
<span class="field__label">새 비밀번호</span>
|
||||
<input
|
||||
v-model="modalPasswordDraft"
|
||||
class="field__input"
|
||||
type="password"
|
||||
maxlength="120"
|
||||
placeholder="초기화할 비밀번호 입력"
|
||||
@keydown.enter.prevent="confirmUserPasswordReset"
|
||||
/>
|
||||
<span class="field__hint">6~120자 권장 · {{ modalPasswordDraft.length }}/120자</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeUserPasswordModal">취소</button>
|
||||
@@ -2528,11 +2558,11 @@ async function saveFeaturedOrder() {
|
||||
border: 0;
|
||||
}
|
||||
.btn {
|
||||
height: 100%;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
word-break: keep-all;
|
||||
margin-top: 12px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
@@ -3110,6 +3140,7 @@ async function saveFeaturedOrder() {
|
||||
.userCard__actions--compact {
|
||||
grid-template-columns: auto auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.roleBadge {
|
||||
width: fit-content;
|
||||
|
||||
@@ -79,8 +79,8 @@ async function submit() {
|
||||
<form class="authFields" @submit.prevent="submit">
|
||||
<label class="field">
|
||||
<span class="field__label">이메일</span>
|
||||
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" />
|
||||
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다.</span>
|
||||
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" maxlength="255" />
|
||||
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다. {{ email.length }}/255자</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
@@ -91,8 +91,9 @@ async function submit() {
|
||||
type="password"
|
||||
placeholder="********"
|
||||
autocomplete="current-password"
|
||||
maxlength="120"
|
||||
/>
|
||||
<span class="field__hint">8자 이상으로 설정하면 더 안전하게 사용할 수 있어요.</span>
|
||||
<span class="field__hint">6~120자 입력 가능 · {{ password.length }}/120자</span>
|
||||
</label>
|
||||
|
||||
<label v-if="mode === 'signup'" class="field">
|
||||
@@ -103,8 +104,9 @@ async function submit() {
|
||||
type="password"
|
||||
placeholder="********"
|
||||
autocomplete="new-password"
|
||||
maxlength="120"
|
||||
/>
|
||||
<span class="field__hint">같은 비밀번호를 한 번 더 입력해주세요.</span>
|
||||
<span class="field__hint">같은 비밀번호를 한 번 더 입력해주세요. {{ passwordConfirm.length }}/120자</span>
|
||||
</label>
|
||||
|
||||
<div v-if="!hasUsers" class="roleBadge">첫 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
|
||||
|
||||
@@ -156,8 +156,8 @@ async function logout() {
|
||||
<div class="settingsFields">
|
||||
<label class="field">
|
||||
<span class="field__label">닉네임</span>
|
||||
<input v-model="nickname" class="field__input" placeholder="작성자 닉네임" />
|
||||
<span class="field__hint">티어표 작성자 이름으로 표시됩니다.</span>
|
||||
<input v-model="nickname" class="field__input" maxlength="40" placeholder="작성자 닉네임" />
|
||||
<span class="field__hint">티어표 작성자 이름으로 표시됩니다. {{ nickname.length }}/40자</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
|
||||
@@ -794,10 +794,12 @@ onUnmounted(() => {
|
||||
<label class="templateRequestDraft__field">
|
||||
<span class="templateRequestDraft__label">요청 제목</span>
|
||||
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="80" placeholder="예: 템플릿 등록 요청" />
|
||||
<span class="templateRequestDraft__hint">{{ templateRequestDraftTitle.length }}/80자</span>
|
||||
</label>
|
||||
<label class="templateRequestDraft__field">
|
||||
<span class="templateRequestDraft__label">요청 설명</span>
|
||||
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가용으로 신규 템플릿이 필요합니다." />
|
||||
<span class="templateRequestDraft__hint">{{ templateRequestDraftDescription.length }}/240자</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modalCard__actions">
|
||||
@@ -823,10 +825,12 @@ onUnmounted(() => {
|
||||
<label class="templateRequestDraft__field">
|
||||
<span class="templateRequestDraft__label">요청 제목</span>
|
||||
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="80" placeholder="예: 템플릿 업데이트 요청" />
|
||||
<span class="templateRequestDraft__hint">{{ templateRequestDraftTitle.length }}/80자</span>
|
||||
</label>
|
||||
<label class="templateRequestDraft__field">
|
||||
<span class="templateRequestDraft__label">요청 설명</span>
|
||||
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="240" placeholder="예: 여름 이벤트 한정 캐릭터 추가" />
|
||||
<span class="templateRequestDraft__hint">{{ templateRequestDraftDescription.length }}/240자</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modalCard__actions">
|
||||
@@ -928,7 +932,7 @@ onUnmounted(() => {
|
||||
열 삭제
|
||||
</button>
|
||||
<span class="grab" title="드래그로 순서 변경" data-group-handle>↕</span>
|
||||
<input v-model="g.name" class="groupName" :readonly="!canEdit" />
|
||||
<input v-model="g.name" class="groupName" maxlength="16" :readonly="!canEdit" />
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
@@ -1004,7 +1008,8 @@ onUnmounted(() => {
|
||||
<template v-if="globalRightRailOpen">
|
||||
<div class="editorSidebar__section">
|
||||
<div class="editorSidebar__label">Title</div>
|
||||
<input v-model="title" class="editorSidebar__input" placeholder="Title Text" :readonly="!canEdit" />
|
||||
<input v-model="title" class="editorSidebar__input" maxlength="120" placeholder="Title Text" :readonly="!canEdit" />
|
||||
<div class="editorSidebar__hint">{{ title.length }}/120자</div>
|
||||
<div v-if="untitledWarning" class="editorSidebar__hint editorSidebar__hint--warn">{{ untitledWarning }}</div>
|
||||
</div>
|
||||
|
||||
@@ -1014,8 +1019,10 @@ onUnmounted(() => {
|
||||
v-model="description"
|
||||
class="editorSidebar__textarea"
|
||||
placeholder="Description Text"
|
||||
maxlength="1000"
|
||||
:readonly="!canEdit"
|
||||
></textarea>
|
||||
<div class="editorSidebar__hint">{{ description.length }}/1000자</div>
|
||||
</div>
|
||||
|
||||
<div class="editorSidebar__section">
|
||||
@@ -1438,6 +1445,10 @@ onUnmounted(() => {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
}
|
||||
.templateRequestDraft__hint {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.46);
|
||||
}
|
||||
.templateRequestDraft__input {
|
||||
width: 100%;
|
||||
padding: 14px 0;
|
||||
|
||||
Reference in New Issue
Block a user