// 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: '' + 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 = '
' + escapeHtml((err && err.message) || '불러오지 못했습니다.') + '
' + '관리자로여기에 미리보기가 나타납니다.
'; }; 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\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;