운영 기능 및 GA4 대시보드 반영
This commit is contained in:
338
public/assets/js/admin.js
Normal file
338
public/assets/js/admin.js
Normal file
@@ -0,0 +1,338 @@
|
||||
(() => {
|
||||
const gallery = document.querySelector('[data-media-upload="gallery"]');
|
||||
if (gallery) {
|
||||
const fileInput = gallery.querySelector('[data-media-files]');
|
||||
const list = gallery.querySelector('[data-media-list]');
|
||||
fileInput?.addEventListener('change', () => {
|
||||
list.replaceChildren();
|
||||
const title = document.querySelector('input[name="title"]')?.value.trim() || '게시글';
|
||||
[...fileInput.files].forEach((file, index) => {
|
||||
const item = document.createElement('article');
|
||||
item.className = 'admin-new-media-item';
|
||||
const preview = document.createElement('img');
|
||||
preview.src = URL.createObjectURL(file);
|
||||
preview.alt = '';
|
||||
const name = document.createElement('strong');
|
||||
name.textContent = file.name;
|
||||
const label = document.createElement('label');
|
||||
label.textContent = '대체 텍스트';
|
||||
const alt = document.createElement('input');
|
||||
alt.name = 'new_gallery_alts[]';
|
||||
alt.value = `${title} - 대표 이미지 ${index + 1}`;
|
||||
alt.maxLength = 255;
|
||||
alt.required = true;
|
||||
label.append(alt);
|
||||
item.append(preview, name, label);
|
||||
list.append(item);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.admin-media-delete input[type="checkbox"]').forEach((checkbox) => {
|
||||
checkbox.addEventListener('change', () => {
|
||||
const item = checkbox.closest('.admin-media-item');
|
||||
const alt = item?.querySelector('input:not([type]), input[type="text"]');
|
||||
item?.classList.toggle('is-delete', checkbox.checked);
|
||||
if (alt) alt.required = !checkbox.checked;
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const editor = document.querySelector('[data-block-editor]');
|
||||
const sourceWrap = document.querySelector('[data-body-source]');
|
||||
const source = document.querySelector('[data-post-body]');
|
||||
if (!editor || !sourceWrap || !source) return;
|
||||
|
||||
const list = editor.querySelector('[data-block-list]');
|
||||
const deleteBin = editor.querySelector('[data-delete-bin]');
|
||||
const inventory = new Map();
|
||||
editor.querySelectorAll('[data-existing-inline]').forEach((item) => {
|
||||
inventory.set(Number(item.dataset.token), {
|
||||
id: Number(item.dataset.id),
|
||||
token: Number(item.dataset.token),
|
||||
path: item.dataset.path,
|
||||
alt: item.dataset.alt,
|
||||
});
|
||||
});
|
||||
let nextToken = Number(editor.dataset.nextToken || 1);
|
||||
|
||||
const button = (label, action, className = '') => {
|
||||
const element = document.createElement('button');
|
||||
element.type = 'button';
|
||||
element.textContent = label;
|
||||
element.dataset.blockAction = action;
|
||||
if (className) element.className = className;
|
||||
return element;
|
||||
};
|
||||
|
||||
const resizeText = (textarea) => {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = `${Math.max(112, textarea.scrollHeight)}px`;
|
||||
};
|
||||
|
||||
const updateControls = () => {
|
||||
const blocks = [...list.children];
|
||||
blocks.forEach((block, index) => {
|
||||
block.querySelector('[data-block-action="up"]').disabled = index === 0;
|
||||
block.querySelector('[data-block-action="down"]').disabled = index === blocks.length - 1;
|
||||
});
|
||||
};
|
||||
|
||||
const bindBlockActions = (block) => {
|
||||
block.querySelector('[data-block-action="up"]').addEventListener('click', () => {
|
||||
const previous = block.previousElementSibling;
|
||||
if (previous) list.insertBefore(block, previous);
|
||||
updateControls();
|
||||
});
|
||||
block.querySelector('[data-block-action="down"]').addEventListener('click', () => {
|
||||
const next = block.nextElementSibling;
|
||||
if (next) list.insertBefore(next, block);
|
||||
updateControls();
|
||||
});
|
||||
block.querySelector('[data-block-action="remove"]').addEventListener('click', () => {
|
||||
if (block.dataset.existingId) {
|
||||
const deleted = document.createElement('input');
|
||||
deleted.type = 'hidden';
|
||||
deleted.name = 'delete_images[]';
|
||||
deleted.value = block.dataset.existingId;
|
||||
deleteBin.append(deleted);
|
||||
}
|
||||
block.remove();
|
||||
if (!list.children.length) addTextBlock('');
|
||||
updateControls();
|
||||
});
|
||||
};
|
||||
|
||||
const createShell = (type, title) => {
|
||||
const block = document.createElement('article');
|
||||
block.className = `admin-content-block admin-content-block-${type}`;
|
||||
block.dataset.blockType = type;
|
||||
const header = document.createElement('header');
|
||||
const heading = document.createElement('strong');
|
||||
heading.textContent = title;
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'admin-content-block-actions';
|
||||
actions.append(
|
||||
button('위로', 'up'),
|
||||
button('아래로', 'down'),
|
||||
button('삭제', 'remove', 'is-danger'),
|
||||
);
|
||||
header.append(heading, actions);
|
||||
block.append(header);
|
||||
bindBlockActions(block);
|
||||
return block;
|
||||
};
|
||||
|
||||
const appendInsertControls = (block) => {
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'admin-content-insert-actions';
|
||||
const addText = document.createElement('button');
|
||||
addText.type = 'button';
|
||||
addText.textContent = '아래에 문단 추가';
|
||||
addText.addEventListener('click', () => {
|
||||
const added = addTextBlock('', block.nextElementSibling);
|
||||
added.querySelector('textarea').focus();
|
||||
});
|
||||
const addImage = document.createElement('button');
|
||||
addImage.type = 'button';
|
||||
addImage.textContent = '아래에 이미지 추가';
|
||||
addImage.addEventListener('click', () => chooseImage(block.nextElementSibling));
|
||||
controls.append(addText, addImage);
|
||||
block.append(controls);
|
||||
};
|
||||
|
||||
const addTextBlock = (value, before = null) => {
|
||||
const block = createShell('text', '텍스트 문단');
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.rows = 4;
|
||||
textarea.value = value;
|
||||
textarea.placeholder = '본문 내용을 입력하세요.';
|
||||
textarea.setAttribute('aria-label', '본문 텍스트 문단');
|
||||
textarea.addEventListener('input', () => resizeText(textarea));
|
||||
block.append(textarea);
|
||||
appendInsertControls(block);
|
||||
list.insertBefore(block, before);
|
||||
requestAnimationFrame(() => resizeText(textarea));
|
||||
updateControls();
|
||||
return block;
|
||||
};
|
||||
|
||||
const addImageBlock = ({ id = null, token, path, alt, fileInput = null }, before = null) => {
|
||||
const block = createShell('image', id ? '등록된 본문 이미지' : '새 본문 이미지');
|
||||
block.dataset.token = String(token);
|
||||
if (id) block.dataset.existingId = String(id);
|
||||
|
||||
const preview = document.createElement('img');
|
||||
preview.src = path;
|
||||
preview.alt = '';
|
||||
const fields = document.createElement('div');
|
||||
fields.className = 'admin-content-image-fields';
|
||||
const label = document.createElement('label');
|
||||
label.textContent = '대체 텍스트';
|
||||
const altInput = document.createElement('input');
|
||||
altInput.name = id ? `media_alt[${id}]` : 'new_inline_alts[]';
|
||||
altInput.value = alt;
|
||||
altInput.maxLength = 255;
|
||||
altInput.required = true;
|
||||
label.append(altInput);
|
||||
fields.append(label);
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.name = 'inline_images[]';
|
||||
fileInput.className = 'admin-hidden-file';
|
||||
const tokenInput = document.createElement('input');
|
||||
tokenInput.type = 'hidden';
|
||||
tokenInput.name = 'new_inline_tokens[]';
|
||||
tokenInput.value = String(token);
|
||||
fields.append(fileInput, tokenInput);
|
||||
}
|
||||
block.append(preview, fields);
|
||||
appendInsertControls(block);
|
||||
list.insertBefore(block, before);
|
||||
updateControls();
|
||||
return block;
|
||||
};
|
||||
|
||||
const imageCount = () => list.querySelectorAll('[data-block-type="image"]').length;
|
||||
const chooseImage = (before = null) => {
|
||||
if (imageCount() >= 10) {
|
||||
window.alert('본문 이미지는 최대 10장까지 등록할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/jpeg,image/png,image/webp';
|
||||
input.className = 'admin-hidden-file';
|
||||
input.addEventListener('change', () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
const title = document.querySelector('input[name="title"]')?.value.trim() || '게시글';
|
||||
addImageBlock({
|
||||
token: nextToken++,
|
||||
path: URL.createObjectURL(file),
|
||||
alt: `${title} - 본문 이미지`,
|
||||
fileInput: input,
|
||||
}, before?.isConnected ? before : null);
|
||||
}, { once: true });
|
||||
deleteBin.append(input);
|
||||
input.click();
|
||||
};
|
||||
|
||||
const usedTokens = new Set();
|
||||
const initialBody = source.value.trim();
|
||||
const parts = initialBody ? initialBody.split(/\r?\n(?:[\t ]*\r?\n)+/) : [];
|
||||
parts.forEach((part) => {
|
||||
const match = part.trim().match(/^\[\[image:(\d+)\]\]$/);
|
||||
const media = match ? inventory.get(Number(match[1])) : null;
|
||||
if (media) {
|
||||
addImageBlock(media);
|
||||
usedTokens.add(media.token);
|
||||
} else {
|
||||
addTextBlock(part);
|
||||
}
|
||||
});
|
||||
inventory.forEach((media) => {
|
||||
if (!usedTokens.has(media.token)) addImageBlock(media);
|
||||
});
|
||||
if (!list.children.length) addTextBlock('');
|
||||
|
||||
editor.querySelector('[data-add-text]').addEventListener('click', () => {
|
||||
const block = addTextBlock('');
|
||||
block.querySelector('textarea').focus();
|
||||
});
|
||||
editor.querySelector('[data-add-image]').addEventListener('click', () => chooseImage());
|
||||
|
||||
source.required = false;
|
||||
sourceWrap.classList.add('is-enhanced');
|
||||
editor.classList.add('is-ready');
|
||||
source.closest('form').addEventListener('submit', () => {
|
||||
source.value = [...list.children].map((block) => {
|
||||
if (block.dataset.blockType === 'image') return `[[image:${block.dataset.token}]]`;
|
||||
return block.querySelector('textarea').value.trim();
|
||||
}).filter(Boolean).join('\n\n');
|
||||
});
|
||||
|
||||
const previewDialog = document.querySelector('[data-live-preview]');
|
||||
const openPreview = document.querySelector('[data-open-live-preview]');
|
||||
const closePreview = previewDialog?.querySelector('[data-close-live-preview]');
|
||||
const renderPreview = () => {
|
||||
const previewTitle = previewDialog.querySelector('[data-preview-title]');
|
||||
const previewCategory = previewDialog.querySelector('[data-preview-category]');
|
||||
const previewAuthor = previewDialog.querySelector('[data-preview-author]');
|
||||
const previewBody = previewDialog.querySelector('[data-preview-body]');
|
||||
const previewGallery = previewDialog.querySelector('[data-preview-gallery]');
|
||||
const galleryMain = previewDialog.querySelector('[data-preview-gallery-main]');
|
||||
const galleryThumbs = previewDialog.querySelector('[data-preview-gallery-thumbs]');
|
||||
const galleryCount = previewDialog.querySelector('[data-preview-gallery-count]');
|
||||
|
||||
previewTitle.textContent = document.querySelector('input[name="title"]')?.value.trim() || '제목 없는 글';
|
||||
previewCategory.textContent = document.querySelector('select[name="category"]')?.value || '';
|
||||
previewAuthor.textContent = `${document.querySelector('input[name="author"]')?.value.trim() || ''} 기술팀`;
|
||||
previewBody.replaceChildren();
|
||||
[...list.children].forEach((block) => {
|
||||
if (block.dataset.blockType === 'image') {
|
||||
const figure = document.createElement('figure');
|
||||
const image = document.createElement('img');
|
||||
image.src = block.querySelector('img').src;
|
||||
image.alt = block.querySelector('.admin-content-image-fields input')?.value.trim() || '';
|
||||
figure.append(image);
|
||||
previewBody.append(figure);
|
||||
return;
|
||||
}
|
||||
const value = block.querySelector('textarea').value.trim();
|
||||
if (!value) return;
|
||||
const paragraph = document.createElement('p');
|
||||
paragraph.textContent = value;
|
||||
previewBody.append(paragraph);
|
||||
});
|
||||
if (!previewBody.children.length) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'is-empty';
|
||||
empty.textContent = '본문 내용을 입력하면 여기에 표시됩니다.';
|
||||
previewBody.append(empty);
|
||||
}
|
||||
|
||||
const galleryItems = [
|
||||
...document.querySelectorAll('[data-media-upload="gallery"] .admin-media-item:not(.is-delete), [data-media-upload="gallery"] .admin-new-media-item'),
|
||||
].map((item) => ({
|
||||
src: item.querySelector('img')?.src || '',
|
||||
alt: item.querySelector('input:not([type="checkbox"])')?.value.trim() || '',
|
||||
})).filter((item) => item.src);
|
||||
galleryMain.replaceChildren();
|
||||
galleryThumbs.replaceChildren();
|
||||
previewGallery.hidden = galleryItems.length === 0;
|
||||
if (galleryItems.length) {
|
||||
const mainImage = document.createElement('img');
|
||||
mainImage.src = galleryItems[0].src;
|
||||
mainImage.alt = galleryItems[0].alt;
|
||||
galleryMain.append(mainImage);
|
||||
galleryItems.forEach((item, index) => {
|
||||
const thumb = document.createElement('button');
|
||||
thumb.type = 'button';
|
||||
thumb.setAttribute('aria-label', `${index + 1}번째 대표 이미지 보기`);
|
||||
if (index === 0) thumb.classList.add('is-active');
|
||||
const image = document.createElement('img');
|
||||
image.src = item.src;
|
||||
image.alt = '';
|
||||
thumb.append(image);
|
||||
thumb.addEventListener('click', () => {
|
||||
mainImage.src = item.src;
|
||||
mainImage.alt = item.alt;
|
||||
[...galleryThumbs.children].forEach((child) => child.classList.toggle('is-active', child === thumb));
|
||||
});
|
||||
galleryThumbs.append(thumb);
|
||||
});
|
||||
galleryCount.textContent = `대표 이미지 ${galleryItems.length}장`;
|
||||
}
|
||||
};
|
||||
|
||||
openPreview?.addEventListener('click', () => {
|
||||
renderPreview();
|
||||
previewDialog.showModal();
|
||||
});
|
||||
closePreview?.addEventListener('click', () => previewDialog.close());
|
||||
previewDialog?.addEventListener('click', (event) => {
|
||||
if (event.target === previewDialog) previewDialog.close();
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user