v4.0.9: 관리자 회원 목록 2열·덱 공개/전체 통계 추가

관리자가 회원별 저장 덱 이용 현황을 한눈에 볼 수 있도록 API 집계와 UI를 보강한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 16:51:14 +09:00
parent eb4b35bccc
commit bc30356c8b
8 changed files with 83 additions and 23 deletions

View File

@@ -1 +1 @@
v4.0.8 v4.0.9

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./style/output.css?v=4.0.6" /> <link rel="stylesheet" href="./style/output.css?v=4.0.9" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=Noto+Sans+KR:wght@400;700;900&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800;900&family=Noto+Sans+KR:wght@400;700;900&display=swap" rel="stylesheet" />
<title>관리자 - UNIARE</title> <title>관리자 - UNIARE</title>
</head> </head>
@@ -35,7 +35,7 @@
<input type="search" id="user-search" class="box-border min-w-0 flex-1 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/15" placeholder="이메일, 사용자명 검색..." /> <input type="search" id="user-search" class="box-border min-w-0 flex-1 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm outline-none transition focus:border-blue-500 focus:bg-white focus:ring-2 focus:ring-blue-500/15" placeholder="이메일, 사용자명 검색..." />
<button type="button" id="user-search-btn" class="cursor-pointer rounded-xl border-0 bg-slate-800 px-5 py-2.5 text-sm font-bold text-white transition hover:bg-slate-700">검색</button> <button type="button" id="user-search-btn" class="cursor-pointer rounded-xl border-0 bg-slate-800 px-5 py-2.5 text-sm font-bold text-white transition hover:bg-slate-700">검색</button>
</div> </div>
<div id="user-list" class="flex flex-col gap-3"></div> <div id="user-list" class="grid grid-cols-1 gap-3 sm:grid-cols-2"></div>
</section> </section>
<section id="tab-decks" class="hidden"> <section id="tab-decks" class="hidden">
@@ -215,23 +215,26 @@
const { users, total } = await UaDeckApi.adminGetUsers(q); const { users, total } = await UaDeckApi.adminGetUsers(q);
const list = document.getElementById('user-list'); const list = document.getElementById('user-list');
list.innerHTML = list.innerHTML =
`<p class="mb-4 text-xs text-slate-400">총 ${total}명</p>` + `<p class="col-span-full mb-1 text-xs text-slate-400">총 ${total}명</p>` +
users users
.map((u) => { .map((u) => {
const isSelf = u.id === currentAdminId; const isSelf = u.id === currentAdminId;
const deckPublic = Number(u.deckPublic ?? 0);
const deckTotal = Number(u.deckTotal ?? 0);
return ` return `
<article class="rounded-2xl border border-slate-200 bg-white px-[1.125rem] py-4 shadow-sm"> <article class="rounded-2xl border border-slate-200 bg-white px-4 py-3.5 shadow-sm">
<div class="flex items-center gap-4"> <div class="flex items-start gap-3">
<img src="${escapeHtml(resolveAvatarSrc(u))}" alt="" class="h-12 w-12 shrink-0 rounded-full border-2 border-slate-200 bg-slate-100 object-cover" onerror="this.src='${DEFAULT_AVATAR}'" /> <img src="${escapeHtml(resolveAvatarSrc(u))}" alt="" class="h-11 w-11 shrink-0 rounded-full border-2 border-slate-200 bg-slate-100 object-cover" onerror="this.src='${DEFAULT_AVATAR}'" />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<h3 class="mb-1 text-[0.9375rem] font-bold leading-snug text-slate-900">${escapeHtml(u.username)}${isSelf ? ' <span class="rounded-md bg-blue-50 px-2 py-0.5 text-[0.625rem] font-bold text-blue-600">나</span>' : ''}</h3> <h3 class="mb-0.5 text-sm font-bold leading-snug text-slate-900">${escapeHtml(u.username)}${isSelf ? ' <span class="rounded-md bg-blue-50 px-1.5 py-0.5 text-[0.625rem] font-bold text-blue-600">나</span>' : ''}</h3>
<div class="text-xs leading-normal text-slate-400">${escapeHtml(u.email)}</div> <div class="truncate text-xs text-slate-400">${escapeHtml(u.email)}</div>
<div class="mt-2 flex flex-wrap gap-1.5"> <div class="mt-1.5 text-xs font-semibold text-slate-600" title="공개 덱 / 전체 저장 덱">덱 ${deckPublic}/${deckTotal} <span class="font-normal text-slate-400">(공개/전체)</span></div>
<span class="rounded-md bg-blue-50 px-2 py-0.5 text-[0.625rem] font-bold text-blue-600">${escapeHtml(u.role)}</span> <div class="mt-1.5 flex flex-wrap gap-1">
<span class="rounded-md px-2 py-0.5 text-[0.625rem] font-bold ${u.isActive ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-500'}">${u.isActive ? '활성' : '비활성'}</span> <span class="rounded-md bg-blue-50 px-1.5 py-0.5 text-[0.625rem] font-bold text-blue-600">${escapeHtml(u.role)}</span>
<span class="rounded-md px-1.5 py-0.5 text-[0.625rem] font-bold ${u.isActive ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-500'}">${u.isActive ? '활성' : '비활성'}</span>
</div> </div>
</div> </div>
<button type="button" data-edit='${JSON.stringify(u).replace(/'/g, '&#39;')}' class="shrink-0 cursor-pointer rounded-lg border-0 bg-slate-100 px-3 py-1.5 text-[0.6875rem] font-bold text-slate-600 hover:bg-slate-200">수정</button> <button type="button" data-edit='${JSON.stringify(u).replace(/'/g, '&#39;')}' class="shrink-0 cursor-pointer rounded-lg border-0 bg-slate-100 px-2.5 py-1.5 text-[0.6875rem] font-bold text-slate-600 hover:bg-slate-200">수정</button>
</div> </div>
</article>`; </article>`;
}) })

View File

@@ -11,6 +11,41 @@ import {
} from '../services/previewImage.js'; } from '../services/previewImage.js';
import { getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js'; import { getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js';
/**
* 회원 목록에 저장 덱 수(공개/전체) 부여
* @param {import('better-sqlite3').Database} db
* @param {Array<{ id: number }>} users
* @returns {Array<object>}
*/
function attachUserDeckCounts(db, users) {
if (!users.length) return users;
const placeholders = users.map(() => '?').join(',');
const ids = users.map((u) => u.id);
const counts = db
.prepare(
`
SELECT user_id AS userId,
COUNT(*) AS deckTotal,
SUM(CASE WHEN is_public = 1 THEN 1 ELSE 0 END) AS deckPublic
FROM decks
WHERE share_type = 'user' AND user_id IN (${placeholders})
GROUP BY user_id
`,
)
.all(...ids);
const countByUser = new Map(counts.map((row) => [row.userId, row]));
return users.map((user) => {
const stats = countByUser.get(user.id);
return {
...user,
deckPublic: stats?.deckPublic ?? 0,
deckTotal: stats?.deckTotal ?? 0,
};
});
}
/** /**
* 관리자 라우트 * 관리자 라우트
* @param {import('better-sqlite3').Database} db * @param {import('better-sqlite3').Database} db
@@ -49,10 +84,13 @@ export function createAdminRouter(db) {
} }
res.json({ res.json({
users: rows.map((u) => ({ users: attachUserDeckCounts(
...u, db,
avatarUrl: getAvatarUrl(u.id) || u.avatarUrl, rows.map((u) => ({
})), ...u,
avatarUrl: getAvatarUrl(u.id) || u.avatarUrl,
})),
),
total, total,
page: Number(page), page: Number(page),
limit: Number(limit), limit: Number(limit),
@@ -70,9 +108,14 @@ export function createAdminRouter(db) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' }); return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
} }
const deckCount = db.prepare('SELECT COUNT(*) AS c FROM decks WHERE user_id = ?').get(user.id).c; const deckTotal = db.prepare(
"SELECT COUNT(*) AS c FROM decks WHERE user_id = ? AND share_type = 'user'",
).get(user.id).c;
const deckPublic = db.prepare(
"SELECT COUNT(*) AS c FROM decks WHERE user_id = ? AND share_type = 'user' AND is_public = 1",
).get(user.id).c;
user.avatarUrl = getAvatarUrl(user.id) || user.avatarUrl; user.avatarUrl = getAvatarUrl(user.id) || user.avatarUrl;
res.json({ user, deckCount }); res.json({ user, deckCount: deckTotal, deckPublic, deckTotal });
}); });
router.put('/users/:id', (req, res) => { router.put('/users/:id', (req, res) => {

View File

@@ -1,5 +1,10 @@
# 의사결정 이력 # 의사결정 이력
## 2026-06-19 v4.0.9 — 관리자 회원 목록에 덱 이용률 표시
💡 사이트 이용 현황을 관리자가 한눈에 파악할 수 있어야 하기 때문.
🔧 회원 API에 `share_type='user'` 저장 덱의 공개·전체 수를 집계하고, 회원 카드를 2열 그리드로 배치.
## 2026-06-23 v4.0.5 — 공통 UI는 Tailwind components 레이어로 통합 ## 2026-06-23 v4.0.5 — 공통 UI는 Tailwind components 레이어로 통합
💡 화면마다 분리된 CSS 파일을 유지하면 Tailwind 빌드 누락·로드 순서 문제가 반복되기 때문. 💡 화면마다 분리된 CSS 파일을 유지하면 Tailwind 빌드 누락·로드 순서 문제가 반복되기 때문.

View File

@@ -88,7 +88,7 @@ script/ads.js
```text ```text
관리자·로그인·회원가입 관리자·로그인·회원가입
admin.html, login.html, signup.html — Tailwind 유틸리티 admin.html (회원 2열·덱 공개/전체 통계), login.html, signup.html — Tailwind 유틸리티
내 덱·공개 덱·프로필 내 덱·공개 덱·프로필
deckHistory.html, preset.html, profile.html — HTML 클래스 + components.css deckHistory.html, preset.html, profile.html — HTML 클래스 + components.css

View File

@@ -186,8 +186,8 @@
| Method | Path | 기능 | | Method | Path | 기능 |
|---|---|---| |---|---|---|
| GET | `/api/v1/admin/users` | 회원 목록·검색 | | GET | `/api/v1/admin/users` | 회원 목록·검색 (`deckPublic`, `deckTotal` 포함) |
| GET | `/api/v1/admin/users/:id` | 회원 상세 | | GET | `/api/v1/admin/users/:id` | 회원 상세 (`deckPublic`, `deckTotal`, `deckCount` 호환) |
| PUT | `/api/v1/admin/users/:id` | 회원 정보·역할·활성 상태 변경 | | PUT | `/api/v1/admin/users/:id` | 회원 정보·역할·활성 상태 변경 |
| POST | `/api/v1/admin/users/:id/reset-password` | 비밀번호 초기화 | | POST | `/api/v1/admin/users/:id/reset-password` | 비밀번호 초기화 |
| GET | `/api/v1/admin/decks` | 전체 덱 목록 | | GET | `/api/v1/admin/decks` | 전체 덱 목록 |

View File

@@ -1,5 +1,14 @@
# 업데이트 이력 # 업데이트 이력
## 2026-06-19 / v4.0.9 — 관리자 회원 목록 2열·덱 이용 통계
### 수행 작업
- `admin.html` 회원 카드: `sm:grid-cols-2` 2열 그리드, 카드 패딩·아바타 크기 축소
- 회원별 저장 덱 수 `공개/전체` 형식 표시 (관리자 전용)
- `GET /api/v1/admin/users`: `deckPublic`, `deckTotal` 필드 추가 (`share_type='user'` 기준)
- `GET /api/v1/admin/users/:id`: `deckPublic`, `deckTotal` 분리 반환
- `admin.html` `output.css?v=4.0.9`, `npm run build:css` 재실행
## 2026-06-23 / v4.0.8 — nginx 정적 캐시·deck-favorites 배포 수정 ## 2026-06-23 / v4.0.8 — nginx 정적 캐시·deck-favorites 배포 수정
### 수행 작업 ### 수행 작업

File diff suppressed because one or more lines are too long