diff --git a/backend/src/db.js b/backend/src/db.js index 7eba86c..366e70a 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -506,25 +506,52 @@ async function updateUserProfile({ id, nickname, avatarSrc }) { return findUserById(id) } -async function listUsers() { - const rows = await query(` - SELECT - u.id, - u.email, - u.nickname, - u.is_admin, - u.avatar_src, - u.created_at, - COUNT(t.id) AS tierlist_count, - GREATEST( +async function findPrimaryAdminUser() { + const rows = await query( + 'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1' + ) + return mapUserRow(rows[0]) +} + +async function listUsers({ queryText = '', sort = 'recent' } = {}) { + const where = [] + const params = [] + const trimmedQuery = typeof queryText === 'string' ? queryText.trim() : '' + + if (trimmedQuery) { + where.push('(u.email LIKE ? OR u.nickname LIKE ?)') + params.push(`%${trimmedQuery}%`, `%${trimmedQuery}%`) + } + + const orderBy = + sort === 'created' + ? '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' + + const rows = await query( + ` + SELECT + u.id, + u.email, + u.nickname, + u.is_admin, + u.avatar_src, u.created_at, - COALESCE(MAX(t.updated_at), 0) - ) AS recent_activity_at - FROM users u - LEFT JOIN tierlists t ON t.author_id = u.id - GROUP BY u.id, u.email, u.nickname, u.is_admin, u.avatar_src, u.created_at - ORDER BY recent_activity_at DESC, u.created_at ASC, u.email ASC - `) + COUNT(t.id) AS tierlist_count, + GREATEST( + u.created_at, + COALESCE(MAX(t.updated_at), 0) + ) AS recent_activity_at + FROM users u + LEFT JOIN tierlists t ON t.author_id = u.id + ${where.length ? `WHERE ${where.join(' AND ')}` : ''} + GROUP BY u.id, u.email, u.nickname, u.is_admin, u.avatar_src, u.created_at + ORDER BY ${orderBy} + `, + params + ) return rows.map(mapUserRow) } @@ -1852,6 +1879,7 @@ module.exports = { findUserById, createUser, updateUserProfile, + findPrimaryAdminUser, listUsers, adminUpdateUser, adminUpdateUserPassword, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index f3db3c3..1306669 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -22,6 +22,7 @@ const { findCustomItemsByIds, deleteCustomItems, listUsers, + findPrimaryAdminUser, listAdminTierLists, findTierListById, listAdminTemplateRequests, @@ -62,6 +63,30 @@ function buildItemLabelFromFilename(file) { const upload = createMemoryUpload(multer, { fileSize: 8 * 1024 * 1024, maxCount: 50 }) const avatarUpload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 }) +function decorateAdminUser(user, primaryAdmin) { + if (!user) return null + const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id + return { + ...user, + isPrimaryAdmin, + isOperator: !!user.isAdmin && !isPrimaryAdmin, + role: isPrimaryAdmin ? 'owner' : user.isAdmin ? 'operator' : 'user', + } +} + +async function getAdminUserContext(targetUserId, actingUserId) { + const [targetUser, actingUser, primaryAdmin] = await Promise.all([ + findUserById(targetUserId), + findUserById(actingUserId), + findPrimaryAdminUser(), + ]) + return { targetUser, actingUser, primaryAdmin } +} + +function canManageAdminRole(actingUser, primaryAdmin) { + return !!actingUser?.isAdmin && primaryAdmin?.id === actingUser.id +} + router.post('/games', requireAdmin, async (req, res) => { const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) }) const parsed = schema.safeParse(req.body) @@ -537,8 +562,18 @@ router.delete('/custom-items', requireAdmin, async (req, res) => { }) router.get('/users', requireAdmin, async (req, res) => { - const users = await listUsers() - res.json({ users }) + const schema = z.object({ + q: z.string().trim().max(120).optional().default(''), + sort: z.enum(['recent', 'created', 'tierlists']).optional().default('recent'), + }) + 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 }), + findPrimaryAdminUser(), + ]) + res.json({ users: users.map((user) => decorateAdminUser(user, primaryAdmin)) }) }) router.patch('/users/:userId', requireAdmin, async (req, res) => { @@ -550,21 +585,34 @@ router.patch('/users/:userId', requireAdmin, async (req, res) => { const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId) + if (!targetUser) return res.status(404).json({ error: 'not_found' }) + + const actingIsPrimaryAdmin = canManageAdminRole(actingUser, primaryAdmin) + const targetIsPrimaryAdmin = primaryAdmin?.id === targetUser.id + const roleChanged = parsed.data.isAdmin !== !!targetUser.isAdmin + 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' }) + if (targetIsPrimaryAdmin && !actingIsPrimaryAdmin) { + return res.status(403).json({ error: 'primary_admin_protected' }) + } + if (targetIsPrimaryAdmin && !parsed.data.isAdmin) { + return res.status(400).json({ error: 'primary_admin_required' }) + } + if (roleChanged && !actingIsPrimaryAdmin) { + return res.status(403).json({ error: 'primary_admin_only' }) + } try { const updated = await adminUpdateUser({ - id: user.id, + id: targetUser.id, email: parsed.data.email, nickname: parsed.data.nickname, isAdmin: parsed.data.isAdmin, }) - res.json({ user: updated }) + res.json({ user: decorateAdminUser(updated, primaryAdmin) }) } catch (e) { if (e && e.code === 'ER_DUP_ENTRY') { return res.status(409).json({ error: 'email_taken' }) @@ -580,8 +628,11 @@ router.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar') const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) - const user = await findUserById(req.params.userId) - if (!user) return res.status(404).json({ error: 'not_found' }) + const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId) + if (!targetUser) return res.status(404).json({ error: 'not_found' }) + if (primaryAdmin?.id === targetUser.id && !canManageAdminRole(actingUser, primaryAdmin)) { + return res.status(403).json({ error: 'primary_admin_protected' }) + } const optimized = req.file ? await writeOptimizedImage({ @@ -594,16 +645,16 @@ router.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar') }) : null const shouldRemoveAvatar = parsed.data.removeAvatar === '1' - const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || user.avatarSrc || '' + const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || targetUser.avatarSrc || '' const updated = await adminUpdateUser({ - id: user.id, - email: user.email, - nickname: user.nickname || '', - isAdmin: !!user.isAdmin, + id: targetUser.id, + email: targetUser.email, + nickname: targetUser.nickname || '', + isAdmin: !!targetUser.isAdmin, avatarSrc: nextAvatarSrc, }) - res.json({ user: updated }) + res.json({ user: decorateAdminUser(updated, primaryAdmin) }) }) router.delete('/users/:userId', requireAdmin, async (req, res) => { @@ -611,10 +662,19 @@ router.delete('/users/:userId', requireAdmin, async (req, res) => { 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' }) + const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId) + if (!targetUser) return res.status(404).json({ error: 'not_found' }) - await adminDeleteUser(user.id) + const actingIsPrimaryAdmin = canManageAdminRole(actingUser, primaryAdmin) + const targetIsPrimaryAdmin = primaryAdmin?.id === targetUser.id + if (targetIsPrimaryAdmin) { + return res.status(400).json({ error: 'cannot_delete_primary_admin' }) + } + if (targetUser.isAdmin && !actingIsPrimaryAdmin) { + return res.status(403).json({ error: 'primary_admin_only' }) + } + + await adminDeleteUser(targetUser.id) res.json({ ok: true }) }) @@ -625,11 +685,14 @@ router.patch('/users/:userId/password', requireAdmin, async (req, res) => { const parsed = schema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) - const user = await findUserById(req.params.userId) - if (!user) return res.status(404).json({ error: 'not_found' }) + const { targetUser, actingUser, primaryAdmin } = await getAdminUserContext(req.params.userId, req.session.userId) + if (!targetUser) return res.status(404).json({ error: 'not_found' }) + if (primaryAdmin?.id === targetUser.id && !canManageAdminRole(actingUser, primaryAdmin)) { + return res.status(403).json({ error: 'primary_admin_protected' }) + } const passwordHash = await bcrypt.hash(parsed.data.password, 10) - await adminUpdateUserPassword({ id: user.id, passwordHash }) + await adminUpdateUserPassword({ id: targetUser.id, passwordHash }) res.json({ ok: true }) }) diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index cf2d170..fcc4967 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -9,6 +9,7 @@ const { findUserById, createUser, updateUserProfile, + findPrimaryAdminUser, } = require('../db') const { requireAuth } = require('../middleware/auth') const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage') @@ -25,6 +26,24 @@ const profileSchema = z.object({ removeAvatar: z.union([z.string(), z.undefined()]).optional(), }) +async function serializeUser(user) { + if (!user) return null + const primaryAdmin = await findPrimaryAdminUser() + const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id + + return { + id: user.id, + email: user.email, + nickname: user.nickname || '', + isAdmin: !!user.isAdmin, + isPrimaryAdmin, + isOperator: !!user.isAdmin && !isPrimaryAdmin, + role: isPrimaryAdmin ? 'owner' : user.isAdmin ? 'operator' : 'user', + avatarSrc: user.avatarSrc || '', + createdAt: user.createdAt, + } +} + router.post('/signup', async (req, res) => { const parsed = signupSchema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) @@ -39,9 +58,9 @@ router.post('/signup', async (req, res) => { req.session.userId = user.id req.session.isAdmin = !!user.isAdmin - req.session.save((err) => { + req.session.save(async (err) => { if (err) return res.status(500).json({ error: 'session_save_failed' }) - res.json(user) + res.json(await serializeUser(user)) }) }) @@ -58,16 +77,9 @@ router.post('/login', async (req, res) => { req.session.userId = user.id req.session.isAdmin = !!user.isAdmin - req.session.save((err) => { + req.session.save(async (err) => { if (err) return res.status(500).json({ error: 'session_save_failed' }) - res.json({ - id: user.id, - email: user.email, - nickname: user.nickname || '', - isAdmin: !!user.isAdmin, - avatarSrc: user.avatarSrc || '', - createdAt: user.createdAt, - }) + res.json(await serializeUser(user)) }) }) @@ -80,7 +92,7 @@ router.get('/me', async (req, res) => { if (!req.session || !req.session.userId) return res.json({ user: null }) const user = await findUserById(req.session.userId) if (!user) return res.json({ user: null }) - res.json({ user }) + res.json({ user: await serializeUser(user) }) }) router.get('/meta', async (req, res) => { @@ -115,7 +127,7 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) = avatarSrc: nextAvatarSrc, }) - res.json({ user: updated }) + res.json({ user: await serializeUser(updated) }) }) module.exports = router diff --git a/docs/todo.md b/docs/todo.md index e9a5277..bbc5752 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -17,5 +17,5 @@ ## 중기 개선 - 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다. -- 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다. +- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다. - 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다. diff --git a/docs/update.md b/docs/update.md index a82d353..997d786 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-04-01 v1.3.11 +- **회원 관리 편집 모달 전환**: 관리자 회원 카드를 읽기 전용 정보 카드로 바꾸고, `회원 정보 수정` 버튼으로 Settings 톤의 편집 모달에서 이메일/닉네임/운영자 권한을 저장하도록 재구성 +- **회원 검색/정렬 추가**: 회원 관리 상단에 이메일/닉네임 검색과 `최근 활동순`, `가입순`, `작성 티어표 많은 순` 정렬을 추가해 운영자가 원하는 기준으로 목록을 다시 볼 수 있도록 확장 +- **최고 관리자 보호 도입**: 가장 먼저 생성된 관리자 계정을 `최고 관리자`로 구분하고, 운영자는 최고 관리자 권한/아바타/비밀번호/삭제를 변경할 수 없도록 백엔드 보호 로직과 역할 메타데이터를 추가 + ## 2026-04-01 v1.3.10 - 게임 허브 공개 티어표 카드 그리드는 최소/최대 폭을 고정해, 목록이 1~2장뿐일 때도 카드가 화면 전체를 먹으며 과하게 커지지 않도록 보정함. - 티어표 행 삭제는 상단 아이콘 대신 우측 하단의 작은 텍스트 액션으로 바꿔, 랭크 카드 안에서 더 조용하고 정돈된 편집 흐름으로 정리함. diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index d03fb6a..3c9b4ba 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -62,7 +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: () => request('/api/admin/users'), + listAdminUsers: ({ q = '', sort = 'recent' } = {}) => + request(`/api/admin/users?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}`), updateAdminUser: (userId, payload) => request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }), updateAdminUserPassword: (userId, payload) => diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index c5c534b..eeb6076 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -47,6 +47,7 @@ const importModalNewGameId = ref('') const importModalNewGameName = ref('') const previewModalOpen = ref(false) const previewTierList = ref(null) +const userEditModalOpen = ref(false) const userPasswordModalOpen = ref(false) const userDeleteModalOpen = ref(false) const userRoleModalOpen = ref(false) @@ -55,9 +56,14 @@ const customItemDeleteModalOpen = ref(false) const modalTargetUser = ref(null) const modalPasswordDraft = ref('') const modalRoleNextAdmin = ref(false) +const modalUserDraftEmail = ref('') +const modalUserDraftNickname = ref('') +const modalUserDraftIsAdmin = ref(false) const modalTargetCustomItem = ref(null) const users = ref([]) +const userQuery = ref('') +const userSort = ref('recent') const imageStats = ref(null) const imageQueue = ref({ concurrency: 1, activeCount: 0, pendingCount: 0 }) const imageRecentJobs = ref([]) @@ -128,7 +134,7 @@ const adminOverviewStats = computed(() => { const publishedTierLists = adminTierLists.value.filter((tierList) => tierList.isPublic).length const pendingRequests = templateRequests.value.length const orphanItems = customItems.value.filter((item) => item.usageCount === 0).length - const adminCount = users.value.filter((user) => user.isAdmin || user.draftIsAdmin).length + const adminCount = users.value.filter((user) => user.isAdmin).length if (activeTab.value === 'featured') { return [ @@ -351,9 +357,25 @@ function setUserAvatarInput(userId, el) { userAvatarInputs.value[userId] = el } -function isUserDirty(user) { - if (!user) return false - return user.draftEmail !== user.email || (user.draftNickname || '') !== (user.nickname || '') || !!user.draftIsAdmin !== !!user.isAdmin +const canManageModalRole = computed(() => { + if (!auth.user?.isPrimaryAdmin) return false + if (!modalTargetUser.value) return false + return !modalTargetUser.value.isPrimaryAdmin +}) + +const isUserEditDirty = computed(() => { + if (!modalTargetUser.value) return false + return ( + modalUserDraftEmail.value.trim() !== (modalTargetUser.value.email || '') || + modalUserDraftNickname.value.trim() !== (modalTargetUser.value.nickname || '') || + !!modalUserDraftIsAdmin.value !== !!modalTargetUser.value.isAdmin + ) +}) + +function roleLabelOf(user) { + if (user?.isPrimaryAdmin) return '최고 관리자' + if (user?.isAdmin) return '운영자' + return '일반 회원' } function openUserAvatarPicker(user) { @@ -372,17 +394,14 @@ async function uploadUserAvatar(user, file, { remove = false } = {}) { entry.id === updated.id ? { ...entry, - avatarSrc: updated.avatarSrc || '', - email: updated.email, - nickname: updated.nickname || '', - isAdmin: !!updated.isAdmin, - draftEmail: updated.email, - draftNickname: updated.nickname || '', - draftIsAdmin: !!updated.isAdmin, + ...updated, isAvatarBusy: false, } : entry ) + if (modalTargetUser.value?.id === updated.id) { + modalTargetUser.value = { ...modalTargetUser.value, ...updated } + } if (updated.id === auth.user?.id) await auth.refresh() await refreshUsers() success.value = remove ? '회원 썸네일을 삭제했어요.' : '회원 썸네일을 업데이트했어요.' @@ -496,12 +515,9 @@ async function refreshTemplateRequests() { async function refreshUsers() { if (!auth.user?.isAdmin) return try { - const data = await api.listAdminUsers() + const data = await api.listAdminUsers({ q: userQuery.value, sort: userSort.value }) users.value = (data.users || []).map((user) => ({ ...user, - draftEmail: user.email, - draftNickname: user.nickname || '', - draftIsAdmin: !!user.isAdmin, isAvatarBusy: false, })) } catch (e) { @@ -778,29 +794,45 @@ async function removeGame() { } } -async function saveUser(user) { +function openUserEditModal(user) { resetMessages() + modalTargetUser.value = user ? { ...user } : null + modalUserDraftEmail.value = user?.email || '' + modalUserDraftNickname.value = user?.nickname || '' + modalUserDraftIsAdmin.value = !!user?.isAdmin + userEditModalOpen.value = true +} + +function closeUserEditModal() { + userEditModalOpen.value = false + modalTargetUser.value = null + modalUserDraftEmail.value = '' + modalUserDraftNickname.value = '' + modalUserDraftIsAdmin.value = false +} + +async function saveUserEdit() { + resetMessages() + if (!modalTargetUser.value?.id) return + try { - const data = await api.updateAdminUser(user.id, { - email: user.draftEmail, - nickname: user.draftNickname, - isAdmin: !!user.draftIsAdmin, + const data = await api.updateAdminUser(modalTargetUser.value.id, { + email: modalUserDraftEmail.value.trim(), + nickname: modalUserDraftNickname.value.trim(), + isAdmin: !!modalUserDraftIsAdmin.value, }) const updated = data.user users.value = users.value.map((entry) => entry.id === updated.id ? { ...entry, - email: updated.email, - nickname: updated.nickname || '', - isAdmin: !!updated.isAdmin, - draftEmail: updated.email, - draftNickname: updated.nickname || '', - draftIsAdmin: !!updated.isAdmin, + ...updated, + isAvatarBusy: entry.isAvatarBusy || false, } : entry ) if (updated.id === auth.user?.id) await auth.refresh() + closeUserEditModal() await refreshUsers() success.value = '회원 정보를 저장했어요.' } catch (e) { @@ -810,7 +842,7 @@ async function saveUser(user) { function openUserPasswordModal(user) { resetMessages() - modalTargetUser.value = user || null + modalTargetUser.value = user ? { ...user } : null modalPasswordDraft.value = '' userPasswordModalOpen.value = true } @@ -842,7 +874,7 @@ async function confirmUserPasswordReset() { function openUserDeleteModal(user) { resetMessages() - modalTargetUser.value = user || null + modalTargetUser.value = user ? { ...user } : null userDeleteModalOpen.value = true } @@ -869,34 +901,31 @@ async function confirmUserDelete() { } -function openUserRoleModal(user) { +function openUserRoleModal(user, nextIsAdmin = !modalUserDraftIsAdmin.value) { resetMessages() - modalTargetUser.value = user || null - modalRoleNextAdmin.value = !user?.draftIsAdmin + modalTargetUser.value = user ? { ...user } : null + modalRoleNextAdmin.value = !!nextIsAdmin userRoleModalOpen.value = true } function closeUserRoleModal() { userRoleModalOpen.value = false - modalTargetUser.value = null + if (!userEditModalOpen.value) modalTargetUser.value = null modalRoleNextAdmin.value = false } function confirmUserRoleDraft() { if (!modalTargetUser.value?.id) return - users.value = users.value.map((entry) => - entry.id === modalTargetUser.value.id - ? { - ...entry, - draftIsAdmin: modalRoleNextAdmin.value, - } - : entry - ) - const targetLabel = modalRoleNextAdmin.value ? '관리자로 지정했어요. 저장하면 반영됩니다.' : '관리자 권한 해제로 표시했어요. 저장하면 반영됩니다.' + modalUserDraftIsAdmin.value = modalRoleNextAdmin.value + const targetLabel = modalRoleNextAdmin.value ? '운영자 권한을 저장 대기 상태로 반영했어요.' : '운영자 권한 해제를 저장 대기 상태로 반영했어요.' closeUserRoleModal() success.value = targetLabel } +function submitUserFilters() { + refreshUsers() +} + function submitCustomItemSearch() { customItemPage.value = 1 refreshCustomItems() @@ -1548,6 +1577,21 @@ async function saveFeaturedOrder() { +
+ + + +
+
아직 가입한 회원이 없어요.
@@ -1584,25 +1628,17 @@ async function saveFeaturedOrder() {
-
Administrator
+
{{ roleLabelOf(user) }}
가입일{{ fmt(user.createdAt) }}
작성 티어표{{ user.tierListCount }}개
최근 활동{{ fmt(user.recentActivityAt || user.createdAt) }}
+
계정명{{ user.email }}
+
닉네임{{ user.nickname || '미설정' }}
+
권한{{ roleLabelOf(user) }}
- - - -
- +
@@ -1632,6 +1668,39 @@ async function saveFeaturedOrder() { +
+ +
+