1003 lines
39 KiB
JavaScript
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\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;
|