Files
Genesis_GameServer/GameServer/wwwroot/js/views/admin.js
2026-09-03 19:18:46 +09:00

1003 lines
39 KiB
JavaScript

// views/admin.js — 관리자 화면
// 라우트: #/admin · #/admin/new · #/admin/edit/:postNo · #/admin/categories
//
// 공개 화면과 달리 여기서만 글을 쓰고 고친다.
// 서버는 쿠키 세션으로 권한을 확인하므로, 401 이 오면 곧바로 로그인 화면으로 되돌린다.
import { api, ApiError } from '../api.js';
import { confirmDialog, debounce, el, escapeHtml, formatDate, setBusy, toast } from '../ui.js';
import { renderMarkdown } from '../markdown.js';
import { navigate } from '../router.js';
/** 대시보드에서 한 번에 긁어오는 게시물 수 */
const DASH_PAGE_SIZE = 50;
/** 미리보기 갱신 지연 */
const PREVIEW_DEBOUNCE_MS = 150;
// ---------------------------------------------------------------------------
// 공용 헬퍼
// ---------------------------------------------------------------------------
/** 정수 안전 변환 */
function toInt(value) {
const n = Number(value);
return Number.isFinite(n) ? Math.trunc(n) : 0;
}
/** '12' 처럼 양의 정수인지 확인한다. */
function parsePostNo(value) {
const text = String(value === null || value === undefined ? '' : value).trim();
if (!/^\d{1,9}$/.test(text)) return 0;
const n = Number(text);
return n > 0 ? n : 0;
}
/** accent 헥사 색상만 통과 */
function safeAccent(value) {
const text = typeof value === 'string' ? value.trim() : '';
return /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(text) ? text : '';
}
/** http(s) 또는 사이트 상대경로만 허용 */
function safeUrl(value) {
const text = typeof value === 'string' ? value.trim() : '';
if (!text) return '';
if (text.slice(0, 2) === '//') return '';
if (text.charAt(0) === '/') return text;
return /^https?:\/\//i.test(text) ? text : '';
}
/**
* 관리자 화면 공통 오류 처리.
* 401 이면 로그인 화면으로 돌려보내고 true 를 반환한다.
* @returns {boolean} 호출부가 더 진행하면 안 되는 경우 true
*/
function handleAuthError(err) {
if (err instanceof ApiError && err.status === 401) {
toast('로그인이 필요합니다.', 'error');
navigate('#/admin');
return true;
}
return false;
}
/** 오류를 토스트로 알린다 (401 은 위에서 처리). */
function reportError(err, fallback) {
if (handleAuthError(err)) return;
toast((err && err.message) || fallback || '요청을 처리하지 못했습니다.', 'error');
}
/** 관리자 화면 상단 바 */
function adminBar(titleText, buttons) {
const bar = el('div', { class: 'admin__bar' },
el('h2', { class: 'admin__title', text: titleText })
);
const side = el('div', { class: 'row-between' });
for (const btn of buttons) if (btn) side.appendChild(btn);
bar.appendChild(side);
return bar;
}
// ---------------------------------------------------------------------------
// #/admin — 로그인 또는 대시보드
// ---------------------------------------------------------------------------
/** 로그인 카드를 그린다. */
function renderLogin(outlet, options) {
const notConfigured = options && options.notConfigured === true;
const input = el('input', {
class: 'input',
type: 'password',
id: 'admin-pw',
autocomplete: 'current-password',
placeholder: '관리자 비밀번호'
});
const hint = el('p', { class: 'field__hint' });
const submit = el('button', { class: 'btn btn--primary', type: 'submit', text: '로그인' });
function showHint(message, isError) {
hint.textContent = message || '';
hint.classList.toggle('is-error', isError === true);
}
if (notConfigured) {
showHint('관리자 비밀번호가 아직 설정되지 않았습니다. GameServer/appsettings.json 의 ' +
'Portfolio:AdminPassword 값을 CHANGE_ME 에서 바꾼 뒤 서버를 다시 시작해 주세요.', true);
}
const form = el('form', { class: 'stack', novalidate: true },
el('div', { class: 'field' },
el('label', { class: 'field__label', for: 'admin-pw', text: '비밀번호' }),
input
),
hint,
submit
);
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
const password = input.value;
if (!password) {
showHint('비밀번호를 입력해 주세요.', true);
input.focus();
return;
}
submit.disabled = true;
submit.textContent = '확인 중…';
setBusy(true);
try {
await api.auth.login(password);
toast('로그인했습니다.', 'ok');
navigate('#/admin');
} catch (err) {
const status = err && err.status;
if (status === 503) {
showHint('관리자 비밀번호가 설정되지 않았습니다. GameServer/appsettings.json 의 ' +
'Portfolio:AdminPassword 값을 바꾼 뒤 서버를 다시 시작해 주세요.', true);
} else if (status === 429) {
showHint('로그인 시도가 너무 많습니다. 몇 분 뒤에 다시 시도해 주세요.', true);
} else {
showHint((err && err.message) || '로그인하지 못했습니다.', true);
}
input.select();
} finally {
submit.disabled = false;
submit.textContent = '로그인';
setBusy(false);
}
});
const card = el('div', { class: 'glass admin' },
el('h2', { class: 'admin__title', text: '관리자 로그인' }),
el('p', { class: 'tagline', text: '글을 쓰거나 고치려면 로그인이 필요합니다.' }),
form
);
outlet.appendChild(el('section', { class: 'section' }, card));
input.focus();
}
/** 대시보드에 쓸 게시물 전체를 페이지 단위로 모은다. */
async function loadAllPosts() {
const all = [];
let page = 1;
let total = 0;
for (;;) {
const res = await api.posts.list({ category: 'all', page, size: DASH_PAGE_SIZE });
const items = res && Array.isArray(res.items) ? res.items : [];
total = toInt(res && res.total);
all.push(...items);
if (!items.length || all.length >= total || page > 200) break;
page += 1;
}
return all;
}
/** 대시보드의 게시물 한 줄 */
function postRow(post, onDeleted) {
const postNo = toInt(post && post.postNo);
const title = String((post && post.title) || '(제목 없음)');
const catName = String((post && post.categoryName) || '미분류');
const accent = safeAccent(post && post.categoryAccent);
const del = el('button', { class: 'btn btn--danger btn--sm', type: 'button', text: '삭제' });
del.addEventListener('click', async () => {
const ok = await confirmDialog('"' + title + '" 게시물을 삭제할까요?', {
okText: '삭제', danger: true
});
if (!ok) return;
del.disabled = true;
setBusy(true);
try {
await api.posts.remove(postNo);
toast('삭제했습니다.', 'ok');
onDeleted(postNo);
} catch (err) {
del.disabled = false;
reportError(err, '삭제하지 못했습니다.');
} finally {
setBusy(false);
}
});
return el('div', { class: 'row' },
el('div', { class: 'row__main' },
el('span', {
class: 'card__cat',
html: '<span class="cat__dot" aria-hidden="true"' +
(accent ? ' style="background:' + accent + '"' : '') + '></span>' + escapeHtml(catName)
}),
el('strong', { class: 'card__title', text: title }),
el('span', { class: 'card__meta', text: formatDate(post && post.createdAt) })
),
el('div', { class: 'row__side' },
el('a', { class: 'btn btn--ghost btn--sm', href: '#/post/' + postNo, text: '보기' }),
el('a', { class: 'btn btn--ghost btn--sm', href: '#/admin/edit/' + postNo, text: '수정' }),
del
)
);
}
/** 로그인된 관리자 대시보드 */
async function renderDashboard(outlet) {
const logout = el('button', { class: 'btn btn--ghost btn--sm', type: 'button', text: '로그아웃' });
logout.addEventListener('click', async () => {
logout.disabled = true;
try {
await api.auth.logout();
toast('로그아웃했습니다.', 'ok');
} catch {
// 실패해도 화면은 로그인으로 되돌린다.
} finally {
navigate('#/admin');
}
});
const section = el('section', { class: 'section' },
adminBar('관리자', [
el('a', { class: 'btn btn--primary btn--sm', href: '#/admin/new', text: '새 글' }),
el('a', { class: 'btn btn--ghost btn--sm', href: '#/admin/categories', text: '카테고리 관리' }),
logout
])
);
const rows = el('div', { class: 'rows' },
el('p', { class: 'tagline', text: '게시물을 불러오는 중…' })
);
section.appendChild(rows);
outlet.appendChild(section);
setBusy(true);
try {
const posts = await loadAllPosts();
rows.replaceChildren();
if (!posts.length) {
rows.appendChild(el('div', { class: 'empty' },
el('div', { class: 'empty__icon', 'aria-hidden': 'true', text: '✦' }),
el('p', { text: '아직 게시물이 없습니다.' }),
el('a', { class: 'btn btn--primary', href: '#/admin/new', text: '첫 글 쓰기' })
));
return;
}
const onDeleted = (postNo) => {
const remaining = posts.filter((p) => toInt(p.postNo) !== postNo);
posts.length = 0;
posts.push(...remaining);
rows.replaceChildren();
if (!posts.length) {
rows.appendChild(el('p', { class: 'tagline', text: '게시물이 모두 삭제되었습니다.' }));
return;
}
for (const p of posts) rows.appendChild(postRow(p, onDeleted));
};
for (const p of posts) rows.appendChild(postRow(p, onDeleted));
} catch (err) {
rows.replaceChildren();
if (handleAuthError(err)) return;
rows.appendChild(el('p', { class: 'tagline', text: (err && err.message) || '목록을 불러오지 못했습니다.' }));
} finally {
setBusy(false);
}
}
/**
* #/admin — 로그인 상태에 따라 로그인 카드 또는 대시보드.
* @param {Object} params
* @param {HTMLElement} outlet
* @returns {Function} cleanup
*/
export async function view(params, outlet) {
document.title = '김낙준 · 관리자';
let disposed = false;
setBusy(true);
let me = null;
try {
me = await api.auth.me();
} catch {
// me() 는 401 을 던지지 않지만, 서버가 죽어 있으면 여기로 온다.
me = null;
} finally {
setBusy(false);
}
if (disposed) return () => { disposed = true; };
// 비밀번호 미설정(503) 안내는 로그인 시도 시점에 보여준다.
// me() 응답의 Message 는 api.js 가 Data 만 벗겨 주므로 여기서는 볼 수 없다.
if (!(me && me.authenticated)) renderLogin(outlet, null);
else await renderDashboard(outlet);
return () => {
disposed = true;
setBusy(false);
};
}
// ---------------------------------------------------------------------------
// #/admin/new, #/admin/edit/:postNo — 에디터
// ---------------------------------------------------------------------------
/** 텍스트영역 커서 위치에 문자열을 끼워 넣는다 (선택/스크롤 보존). */
function insertAtCaret(textarea, text) {
const start = typeof textarea.selectionStart === 'number' ? textarea.selectionStart : textarea.value.length;
const end = typeof textarea.selectionEnd === 'number' ? textarea.selectionEnd : start;
const scroll = textarea.scrollTop;
const value = textarea.value;
textarea.value = value.slice(0, start) + text + value.slice(end);
const caret = start + text.length;
textarea.selectionStart = caret;
textarea.selectionEnd = caret;
textarea.scrollTop = scroll;
textarea.focus();
}
/**
* 에디터 뷰.
* @param {Object} params postNo (수정일 때만)
* @param {HTMLElement} outlet
* @returns {Function} cleanup
*/
export async function editorView(params, outlet) {
const postNo = parsePostNo(params && params.postNo);
const isEdit = postNo > 0;
document.title = isEdit ? '김낙준 · 글 수정' : '김낙준 · 새 글';
let disposed = false;
let dirty = false;
/** 이번 편집 세션에서 올린 이미지 URL (삭제 시 서버 파일도 지운다) */
const sessionUploads = new Set();
/** 화면에 붙어 있는 이미지 목록 */
let images = [];
let thumbnailUrl = '';
// ── 로그인 확인 ───────────────────────────────────────────────
setBusy(true);
try {
const me = await api.auth.me();
if (!me || !me.authenticated) {
setBusy(false);
toast('로그인이 필요합니다.', 'error');
navigate('#/admin');
return () => { disposed = true; };
}
} catch {
setBusy(false);
navigate('#/admin');
return () => { disposed = true; };
}
// ── 데이터 로드 ───────────────────────────────────────────────
let categories = [];
let post = null;
try {
categories = await api.categories.list();
if (isEdit) post = await api.posts.get(postNo);
} catch (err) {
setBusy(false);
if (disposed) return () => { disposed = true; };
if (handleAuthError(err)) return () => { disposed = true; };
outlet.innerHTML =
'<div class="empty"><div class="empty__icon" aria-hidden="true">⚠</div>' +
'<p>' + escapeHtml((err && err.message) || '불러오지 못했습니다.') + '</p>' +
'<a class="btn btn--ghost" href="#/admin">관리자로</a></div>';
return () => { disposed = true; };
} finally {
setBusy(false);
}
if (disposed) return () => { disposed = true; };
if (!categories.length) {
outlet.innerHTML =
'<div class="empty"><div class="empty__icon" aria-hidden="true">✦</div>' +
'<p>카테고리가 하나도 없습니다.</p>' +
'<p class="tagline">글을 쓰려면 카테고리를 먼저 만들어야 합니다.</p>' +
'<a class="btn btn--primary" href="#/admin/categories">카테고리 만들기</a></div>';
return () => { disposed = true; };
}
// 기존 이미지 목록 채우기
if (post && Array.isArray(post.images)) {
images = post.images
.map((img) => ({
imageUrl: safeUrl(img && img.imageUrl),
caption: img && img.caption ? String(img.caption) : '',
sortOrder: toInt(img && img.sortOrder)
}))
.filter((img) => img.imageUrl);
}
thumbnailUrl = safeUrl(post && post.thumbnailUrl);
// ── 폼 구성 ───────────────────────────────────────────────────
const selCategory = el('select', { class: 'select', id: 'ed-cat' });
for (const cat of categories) {
selCategory.appendChild(el('option', {
value: String(toInt(cat.categoryNo)),
text: String(cat.name || cat.slug || '')
}));
}
selCategory.value = String(toInt(post ? post.categoryNo : categories[0].categoryNo));
const inTitle = el('input', {
class: 'input', id: 'ed-title', type: 'text', maxlength: '200',
placeholder: '제목', value: post ? String(post.title || '') : ''
});
const inSummary = el('input', {
class: 'input', id: 'ed-sum', type: 'text', maxlength: '500',
placeholder: '목록에 보일 한 줄 요약', value: post && post.summary ? String(post.summary) : ''
});
const taBody = el('textarea', {
class: 'textarea', id: 'ed-body', spellcheck: 'false',
placeholder: '마크다운으로 작성하세요.'
});
taBody.value = post && post.contentMd ? String(post.contentMd) : '';
const preview = el('div', { class: 'editor__preview post__body' });
const thumbs = el('div', { class: 'thumbs' });
const dropzone = el('div', {
class: 'dropzone', tabindex: '0', role: 'button',
'aria-label': '이미지 추가'
},
el('p', { text: '이미지를 끌어다 놓거나 클릭해서 고르세요' }),
el('p', { class: 'field__hint', text: 'Ctrl+V 로 붙여넣기도 됩니다 · png jpg gif webp avif' })
);
const fileInput = el('input', {
type: 'file', accept: 'image/png,image/jpeg,image/gif,image/webp,image/avif',
multiple: true, class: 'sr-only'
});
const btnSave = el('button', { class: 'btn btn--primary', type: 'button', text: isEdit ? '수정 저장' : '저장' });
const btnCancel = el('a', { class: 'btn btn--ghost btn--sm', href: '#/admin', text: '취소' });
// ── 미리보기 ──────────────────────────────────────────────────
const updatePreview = () => {
preview.innerHTML = taBody.value.trim()
? renderMarkdown(taBody.value)
: '<p class="md-p tagline">여기에 미리보기가 나타납니다.</p>';
};
const debouncedPreview = debounce(updatePreview, PREVIEW_DEBOUNCE_MS);
// ── 더러움 표시 ───────────────────────────────────────────────
function markDirty() {
if (dirty) return;
dirty = true;
window.addEventListener('beforeunload', onBeforeUnload);
}
function clearDirty() {
dirty = false;
window.removeEventListener('beforeunload', onBeforeUnload);
}
function onBeforeUnload(ev) {
ev.preventDefault();
ev.returnValue = '';
return '';
}
// ── 썸네일 목록 ───────────────────────────────────────────────
function renderThumbs() {
thumbs.replaceChildren();
if (!images.length) {
thumbs.appendChild(el('p', { class: 'field__hint', text: '아직 이미지가 없습니다.' }));
return;
}
images.forEach((img, index) => {
const isCover = thumbnailUrl && img.imageUrl === thumbnailUrl;
const btnCover = el('button', {
class: 'btn btn--ghost btn--sm', type: 'button',
text: isCover ? '대표 ✓' : '대표'
});
btnCover.addEventListener('click', () => {
thumbnailUrl = isCover ? '' : img.imageUrl;
markDirty();
renderThumbs();
});
const btnInsert = el('button', {
class: 'btn btn--ghost btn--sm', type: 'button', text: '본문삽입'
});
btnInsert.addEventListener('click', () => {
insertAtCaret(taBody, '\n![](' + img.imageUrl + ')\n');
markDirty();
updatePreview();
});
const btnDel = el('button', {
class: 'btn btn--danger btn--sm', type: 'button', text: '삭제'
});
btnDel.addEventListener('click', async () => {
const url = img.imageUrl;
images = images.filter((_, i) => i !== index);
if (thumbnailUrl === url) thumbnailUrl = '';
markDirty();
renderThumbs();
// 이번 세션에 올린 파일만 서버에서도 지운다.
if (sessionUploads.has(url)) {
sessionUploads.delete(url);
try {
await api.uploads.remove(url);
} catch {
// 파일 정리 실패는 편집을 막지 않는다.
}
}
});
thumbs.appendChild(el('figure', { class: isCover ? 'thumb is-cover' : 'thumb' },
el('img', {
class: 'thumb__img', src: img.imageUrl, alt: img.caption || '이미지 ' + (index + 1),
loading: 'lazy', decoding: 'async'
}),
el('div', { class: 'thumb__actions' }, btnCover, btnInsert, btnDel)
));
});
}
// ── 업로드 ────────────────────────────────────────────────────
let uploading = false;
// 서버가 알려 주는 한도. 한 번만 받아서 재사용한다.
let uploadLimit = null;
async function getUploadLimit() {
if (uploadLimit) return uploadLimit;
try {
uploadLimit = await api.uploads.limit();
} catch {
uploadLimit = null;
}
return uploadLimit;
}
/**
* 보내기 전에 걸러 낸다.
* 한도를 넘긴 본문은 서버가 연결을 끊어 버려서 브라우저에서는 원인 모를
* 네트워크 오류로만 보인다. 100MB 를 다 올린 뒤에 그러면 시간 낭비가 크다.
* @returns {string} 문제가 있으면 사용자에게 보여 줄 메시지, 없으면 ''
*/
function checkBeforeUpload(file, limit) {
if (!limit) return '';
if (typeof limit.maxBytes === 'number' && file.size > limit.maxBytes) {
const mb = (file.size / (1024 * 1024)).toFixed(1);
const max = Math.floor(limit.maxMegabytes || (limit.maxBytes / (1024 * 1024)));
return file.name + ' 은 ' + mb + 'MB 라 올릴 수 없습니다. (최대 ' + max + 'MB)';
}
const exts = Array.isArray(limit.allowedExtensions) ? limit.allowedExtensions : [];
if (exts.length) {
const dot = file.name.lastIndexOf('.');
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : '';
if (!exts.includes(ext)) {
return file.name + ' 은 올릴 수 없는 형식입니다. (' + exts.join(' ') + ')';
}
}
return '';
}
async function uploadFiles(fileList) {
const files = Array.from(fileList || []).filter((f) => f && f.size > 0);
if (!files.length || uploading) return;
uploading = true;
dropzone.classList.remove('is-over');
setBusy(true);
let okCount = 0;
const limit = await getUploadLimit();
// 순차 업로드: 하나가 실패해도 나머지는 계속한다.
for (let i = 0; i < files.length; i += 1) {
const file = files[i];
dropzone.setAttribute('data-progress', (i + 1) + ' / ' + files.length);
// 보내기 전에 한도를 확인한다.
const problem = checkBeforeUpload(file, limit);
if (problem) {
toast(problem, 'error');
continue;
}
try {
const res = await api.uploads.upload(file);
if (disposed) break;
const url = safeUrl(res && res.url);
if (!url) throw new ApiError(0, '서버가 올바른 주소를 돌려주지 않았습니다.');
sessionUploads.add(url);
images.push({ imageUrl: url, caption: '', sortOrder: images.length });
if (!thumbnailUrl) thumbnailUrl = url;
okCount += 1;
markDirty();
renderThumbs();
} catch (err) {
if (handleAuthError(err)) {
uploading = false;
setBusy(false);
return;
}
toast(file.name + ': ' + ((err && err.message) || '업로드 실패'), 'error');
}
}
dropzone.removeAttribute('data-progress');
uploading = false;
setBusy(false);
if (okCount > 0) toast(okCount + '장 올렸습니다.', 'ok');
}
function onDragOver(ev) {
ev.preventDefault();
dropzone.classList.add('is-over');
}
function onDragLeave(ev) {
ev.preventDefault();
dropzone.classList.remove('is-over');
}
function onDrop(ev) {
ev.preventDefault();
dropzone.classList.remove('is-over');
if (ev.dataTransfer && ev.dataTransfer.files) uploadFiles(ev.dataTransfer.files);
}
function onDropzoneClick() {
fileInput.click();
}
function onDropzoneKey(ev) {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
fileInput.click();
}
}
function onFileChange() {
uploadFiles(fileInput.files);
fileInput.value = '';
}
function onPaste(ev) {
const items = ev.clipboardData && ev.clipboardData.items;
if (!items) return;
const files = [];
for (const item of items) {
if (item.kind === 'file') {
const f = item.getAsFile();
if (f && /^image\//i.test(f.type)) files.push(f);
}
}
if (files.length) {
ev.preventDefault();
uploadFiles(files);
}
}
// ── 저장 ──────────────────────────────────────────────────────
async function save() {
const title = inTitle.value.trim();
if (!title) {
toast('제목을 입력해 주세요.', 'error');
inTitle.focus();
return;
}
const categoryNo = toInt(selCategory.value);
if (!categoryNo) {
toast('카테고리를 선택해 주세요.', 'error');
selCategory.focus();
return;
}
const dto = {
categoryNo,
title,
summary: inSummary.value.trim() || null,
contentMd: taBody.value || null,
thumbnailUrl: thumbnailUrl || null,
images: images.map((img, i) => ({
imageUrl: img.imageUrl,
caption: img.caption || null,
sortOrder: i
}))
};
btnSave.disabled = true;
btnSave.textContent = '저장 중…';
setBusy(true);
try {
if (isEdit) await api.posts.update(postNo, dto);
else await api.posts.create(dto);
// 저장에 성공했으니 이동 전에 이탈 경고를 먼저 푼다.
clearDirty();
toast(isEdit ? '수정했습니다.' : '저장했습니다.', 'ok');
navigate('#/admin');
} catch (err) {
// 입력값은 그대로 두고 버튼만 되살린다.
btnSave.disabled = false;
btnSave.textContent = isEdit ? '수정 저장' : '저장';
reportError(err, '저장하지 못했습니다.');
} finally {
setBusy(false);
}
}
// ── 조립 ──────────────────────────────────────────────────────
const section = el('section', { class: 'section' },
adminBar(isEdit ? '글 수정' : '새 글', [btnCancel, btnSave])
);
const form = el('div', { class: 'editor glass' },
el('div', { class: 'form-grid' },
el('div', { class: 'field' },
el('label', { class: 'field__label', for: 'ed-cat', text: '카테고리' }),
selCategory
),
el('div', { class: 'field' },
el('label', { class: 'field__label', for: 'ed-title', text: '제목' }),
inTitle
)
),
el('div', { class: 'field' },
el('label', { class: 'field__label', for: 'ed-sum', text: '요약' }),
inSummary,
el('p', { class: 'field__hint', text: '목록 카드에 보입니다. 비워 두어도 됩니다.' })
),
el('div', { class: 'editor__panes' },
el('div', { class: 'editor__pane' },
el('label', { class: 'field__label', for: 'ed-body', text: '본문 (마크다운)' }),
taBody
),
el('div', { class: 'editor__pane' },
el('span', { class: 'field__label', text: '미리보기' }),
preview
)
),
el('div', { class: 'field' },
el('span', { class: 'field__label', text: '이미지' }),
dropzone,
fileInput,
thumbs
)
);
section.appendChild(form);
outlet.appendChild(section);
renderThumbs();
updatePreview();
// ── 리스너 ────────────────────────────────────────────────────
const onBodyInput = () => { markDirty(); debouncedPreview(); };
const onFieldInput = () => { markDirty(); };
taBody.addEventListener('input', onBodyInput);
inTitle.addEventListener('input', onFieldInput);
inSummary.addEventListener('input', onFieldInput);
selCategory.addEventListener('change', onFieldInput);
btnSave.addEventListener('click', save);
dropzone.addEventListener('click', onDropzoneClick);
dropzone.addEventListener('keydown', onDropzoneKey);
dropzone.addEventListener('dragover', onDragOver);
dropzone.addEventListener('dragenter', onDragOver);
dropzone.addEventListener('dragleave', onDragLeave);
dropzone.addEventListener('drop', onDrop);
fileInput.addEventListener('change', onFileChange);
form.addEventListener('paste', onPaste);
inTitle.focus();
return () => {
disposed = true;
clearDirty();
debouncedPreview.cancel();
taBody.removeEventListener('input', onBodyInput);
inTitle.removeEventListener('input', onFieldInput);
inSummary.removeEventListener('input', onFieldInput);
selCategory.removeEventListener('change', onFieldInput);
btnSave.removeEventListener('click', save);
dropzone.removeEventListener('click', onDropzoneClick);
dropzone.removeEventListener('keydown', onDropzoneKey);
dropzone.removeEventListener('dragover', onDragOver);
dropzone.removeEventListener('dragenter', onDragOver);
dropzone.removeEventListener('dragleave', onDragLeave);
dropzone.removeEventListener('drop', onDrop);
fileInput.removeEventListener('change', onFileChange);
form.removeEventListener('paste', onPaste);
setBusy(false);
};
}
// ---------------------------------------------------------------------------
// #/admin/categories — 카테고리 관리
// ---------------------------------------------------------------------------
/**
* 카테고리 관리 뷰.
* @param {Object} params
* @param {HTMLElement} outlet
* @returns {Function} cleanup
*/
export async function categoriesView(params, outlet) {
document.title = '김낙준 · 카테고리';
let disposed = false;
setBusy(true);
try {
const me = await api.auth.me();
if (!me || !me.authenticated) {
setBusy(false);
toast('로그인이 필요합니다.', 'error');
navigate('#/admin');
return () => { disposed = true; };
}
} catch {
setBusy(false);
navigate('#/admin');
return () => { disposed = true; };
} finally {
setBusy(false);
}
const section = el('section', { class: 'section' },
adminBar('카테고리 관리', [
el('a', { class: 'btn btn--ghost btn--sm', href: '#/admin', text: '관리자로' })
])
);
const rows = el('div', { class: 'rows' });
section.appendChild(rows);
outlet.appendChild(section);
/** 한 줄을 편집 가능한 폼으로 만든다. */
function categoryRow(cat, reload) {
const categoryNo = toInt(cat.categoryNo);
const inSlug = el('input', { class: 'input', type: 'text', value: String(cat.slug || ''), maxlength: '64' });
const inName = el('input', { class: 'input', type: 'text', value: String(cat.name || ''), maxlength: '100' });
const inDesc = el('input', {
class: 'input', type: 'text', value: cat.description ? String(cat.description) : '', maxlength: '255'
});
const inAccent = el('input', { class: 'input', type: 'color', value: safeAccent(cat.accent) || '#67e8f9' });
const inSort = el('input', { class: 'input', type: 'number', value: String(toInt(cat.sortOrder)) });
const btnSave = el('button', { class: 'btn btn--ghost btn--sm', type: 'button', text: '저장' });
btnSave.addEventListener('click', async () => {
btnSave.disabled = true;
setBusy(true);
try {
await api.categories.update(categoryNo, {
slug: inSlug.value.trim().toLowerCase(),
name: inName.value.trim(),
description: inDesc.value.trim() || null,
accent: inAccent.value,
sortOrder: toInt(inSort.value)
});
toast('저장했습니다.', 'ok');
reload();
} catch (err) {
btnSave.disabled = false;
reportError(err, '저장하지 못했습니다.');
} finally {
setBusy(false);
}
});
const btnDel = el('button', { class: 'btn btn--danger btn--sm', type: 'button', text: '삭제' });
btnDel.addEventListener('click', async () => {
const ok = await confirmDialog('"' + String(cat.name || cat.slug) + '" 카테고리를 삭제할까요?', {
okText: '삭제', danger: true
});
if (!ok) return;
btnDel.disabled = true;
setBusy(true);
try {
await api.categories.remove(categoryNo);
toast('삭제했습니다.', 'ok');
reload();
} catch (err) {
btnDel.disabled = false;
reportError(err, '삭제하지 못했습니다.');
} finally {
setBusy(false);
}
});
return el('div', { class: 'row' },
el('div', { class: 'row__main' },
el('div', { class: 'form-grid' },
el('div', { class: 'field' }, el('span', { class: 'field__label', text: 'slug' }), inSlug),
el('div', { class: 'field' }, el('span', { class: 'field__label', text: '이름' }), inName)
),
el('div', { class: 'form-grid' },
el('div', { class: 'field' }, el('span', { class: 'field__label', text: '설명' }), inDesc),
el('div', { class: 'form-row' },
el('div', { class: 'field' }, el('span', { class: 'field__label', text: '색' }), inAccent),
el('div', { class: 'field' }, el('span', { class: 'field__label', text: '정렬' }), inSort)
)
),
el('p', { class: 'field__hint', text: '게시물 ' + toInt(cat.postCount) + '개' })
),
el('div', { class: 'row__side' }, btnSave, btnDel)
);
}
/** 새 카테고리 추가 폼 */
function addForm(reload) {
const inSlug = el('input', { class: 'input', type: 'text', placeholder: 'game', maxlength: '64' });
const inName = el('input', { class: 'input', type: 'text', placeholder: '게임', maxlength: '100' });
const inDesc = el('input', { class: 'input', type: 'text', placeholder: '설명 (선택)', maxlength: '255' });
const inAccent = el('input', { class: 'input', type: 'color', value: '#67e8f9' });
const inSort = el('input', { class: 'input', type: 'number', value: '0' });
const btnAdd = el('button', { class: 'btn btn--primary btn--sm', type: 'button', text: '추가' });
btnAdd.addEventListener('click', async () => {
const slug = inSlug.value.trim().toLowerCase();
const name = inName.value.trim();
if (!/^[a-z0-9-]{1,64}$/.test(slug)) {
toast('slug 는 영문 소문자·숫자·하이픈만 쓸 수 있습니다.', 'error');
inSlug.focus();
return;
}
if (!name) {
toast('이름을 입력해 주세요.', 'error');
inName.focus();
return;
}
btnAdd.disabled = true;
setBusy(true);
try {
await api.categories.create({
slug, name,
description: inDesc.value.trim() || null,
accent: inAccent.value,
sortOrder: toInt(inSort.value)
});
toast('추가했습니다.', 'ok');
reload();
} catch (err) {
btnAdd.disabled = false;
reportError(err, '추가하지 못했습니다.');
} finally {
setBusy(false);
}
});
return el('div', { class: 'row glass' },
el('div', { class: 'row__main' },
el('strong', { class: 'card__title', text: '새 카테고리' }),
el('div', { class: 'form-grid' },
el('div', { class: 'field' }, el('span', { class: 'field__label', text: 'slug' }), inSlug),
el('div', { class: 'field' }, el('span', { class: 'field__label', text: '이름' }), inName)
),
el('div', { class: 'form-grid' },
el('div', { class: 'field' }, el('span', { class: 'field__label', text: '설명' }), inDesc),
el('div', { class: 'form-row' },
el('div', { class: 'field' }, el('span', { class: 'field__label', text: '색' }), inAccent),
el('div', { class: 'field' }, el('span', { class: 'field__label', text: '정렬' }), inSort)
)
)
),
el('div', { class: 'row__side' }, btnAdd)
);
}
async function load() {
if (disposed) return;
rows.replaceChildren(el('p', { class: 'tagline', text: '불러오는 중…' }));
setBusy(true);
try {
const list = await api.categories.list();
if (disposed) return;
rows.replaceChildren();
rows.appendChild(addForm(load));
for (const cat of list) rows.appendChild(categoryRow(cat, load));
} catch (err) {
if (disposed) return;
rows.replaceChildren();
if (handleAuthError(err)) return;
rows.appendChild(el('p', { class: 'tagline', text: (err && err.message) || '불러오지 못했습니다.' }));
} finally {
setBusy(false);
}
}
await load();
return () => {
disposed = true;
setBusy(false);
};
}
export default view;