운영 기능 및 GA4 대시보드 반영

This commit is contained in:
박성필
2026-08-18 10:53:58 +09:00
parent 612e87847c
commit b492abd3b4
24 changed files with 1521 additions and 77 deletions

338
public/assets/js/admin.js Normal file
View 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();
});
})();

View File

@@ -1,3 +1,115 @@
(() => {
const root = document.querySelector('[data-analytics-consent]');
if (!root) return;
const banner = document.querySelector('[data-cookie-consent]');
const settingsButton = document.querySelector('[data-cookie-settings]');
const acceptButton = banner?.querySelector('[data-cookie-accept]');
const declineButton = banner?.querySelector('[data-cookie-decline]');
const enabled = root.dataset.analyticsEnabled === 'true';
const measurementId = root.dataset.analyticsMeasurementId || '';
const consentKey = root.dataset.analyticsConsentKey || 'gtsit_analytics_consent_v1';
let analyticsLoaded = false;
const readConsent = () => {
try {
return window.localStorage.getItem(consentKey);
} catch {
return null;
}
};
const saveConsent = (value) => {
try {
window.localStorage.setItem(consentKey, value);
} catch {
// 저장소를 사용할 수 없는 경우 현재 페이지만 선택을 유지합니다.
}
};
const setGoogleConsent = (analyticsStorage) => {
window.dataLayer = window.dataLayer || [];
window.gtag = window.gtag || function gtag() {
window.dataLayer.push(arguments);
};
window.gtag('consent', 'update', {
analytics_storage: analyticsStorage,
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
});
};
const loadAnalytics = () => {
if (!enabled || !measurementId || analyticsLoaded) return;
analyticsLoaded = true;
window.dataLayer = window.dataLayer || [];
window.gtag = window.gtag || function gtag() {
window.dataLayer.push(arguments);
};
window.gtag('consent', 'default', {
analytics_storage: 'denied',
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
});
window.gtag('consent', 'update', {
analytics_storage: 'granted',
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
});
window.gtag('js', new Date());
window.gtag('config', measurementId, {
allow_google_signals: false,
allow_ad_personalization_signals: false,
});
const script = document.createElement('script');
script.async = true;
script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(measurementId)}`;
document.head.append(script);
};
const clearAnalyticsCookies = () => {
const names = document.cookie.split(';').map((cookie) => cookie.split('=')[0].trim()).filter((name) => name.startsWith('_ga'));
names.forEach((name) => {
document.cookie = `${name}=; Max-Age=0; path=/; SameSite=Lax`;
document.cookie = `${name}=; Max-Age=0; path=/; domain=.gtsit.co.kr; SameSite=Lax`;
});
};
const hideBanner = () => {
if (banner) banner.hidden = true;
};
const showBanner = () => {
if (!banner) return;
banner.hidden = false;
acceptButton?.focus();
};
acceptButton?.addEventListener('click', () => {
saveConsent('granted');
hideBanner();
if (analyticsLoaded) setGoogleConsent('granted');
else loadAnalytics();
});
declineButton?.addEventListener('click', () => {
saveConsent('denied');
setGoogleConsent('denied');
clearAnalyticsCookies();
hideBanner();
});
settingsButton?.addEventListener('click', showBanner);
const consent = readConsent();
if (consent === 'granted') loadAnalytics();
else if (consent !== 'denied') showBanner();
})();
(() => {
const toggle = document.querySelector('.menu-toggle');
const navigation = document.querySelector('.site-navigation');
@@ -212,6 +324,82 @@
else returnButton.hidden = false;
})();
(() => {
const slider = document.querySelector('[data-article-slider]');
if (!slider) return;
const slides = [...slider.querySelectorAll('.article-gallery-slide')];
const dots = [...slider.querySelectorAll('[data-article-dot]')];
const previous = slider.querySelector('[data-article-prev]');
const next = slider.querySelector('[data-article-next]');
const pause = slider.querySelector('[data-article-pause]');
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
let current = 0;
let timer = null;
let isPaused = false;
let isInteracting = false;
const show = (index) => {
current = (index + slides.length) % slides.length;
slides.forEach((slide, slideIndex) => {
const active = slideIndex === current;
slide.classList.toggle('is-active', active);
slide.setAttribute('aria-hidden', String(!active));
});
dots.forEach((dot, dotIndex) => {
const active = dotIndex === current;
dot.classList.toggle('is-active', active);
if (active) dot.setAttribute('aria-current', 'true');
else dot.removeAttribute('aria-current');
});
};
const stop = () => {
if (timer !== null) window.clearInterval(timer);
timer = null;
};
const start = () => {
stop();
if (isPaused || isInteracting || reducedMotion.matches || document.hidden) return;
timer = window.setInterval(() => show(current + 1), 5000);
};
const move = (direction) => {
show(current + direction);
start();
};
previous?.addEventListener('click', () => move(-1));
next?.addEventListener('click', () => move(1));
dots.forEach((dot) => dot.addEventListener('click', () => {
show(Number(dot.dataset.articleDot));
start();
}));
pause?.addEventListener('click', () => {
isPaused = !isPaused;
pause.setAttribute('aria-pressed', String(isPaused));
pause.textContent = isPaused ? '자동재생 시작' : '자동재생 일시정지';
start();
});
slider.addEventListener('mouseenter', () => {
isInteracting = true;
stop();
});
slider.addEventListener('mouseleave', () => {
isInteracting = false;
start();
});
slider.addEventListener('focusin', () => {
isInteracting = true;
stop();
});
slider.addEventListener('focusout', () => {
isInteracting = false;
start();
});
document.addEventListener('visibilitychange', start);
reducedMotion.addEventListener('change', start);
start();
})();
(() => {
const mapFrame = document.querySelector('[data-naver-map]');
if (!mapFrame) return;
@@ -219,6 +407,9 @@
const mapElement = mapFrame.querySelector('.naver-map');
const statusElement = mapFrame.querySelector('[data-naver-map-status]');
const fullscreenButton = mapFrame.querySelector('[data-naver-map-fullscreen]');
const zoomControl = mapFrame.querySelector('[data-naver-map-zoom]');
const zoomInButton = mapFrame.querySelector('[data-naver-map-zoom-in]');
const zoomOutButton = mapFrame.querySelector('[data-naver-map-zoom-out]');
const externalLink = mapFrame.parentElement?.querySelector('[data-naver-map-external]');
const clientId = mapFrame.dataset.clientId;
const latitude = Number(mapFrame.dataset.latitude);
@@ -231,6 +422,7 @@
mapFrame.dataset.mapState = state;
mapFrame.setAttribute('aria-busy', String(state === 'loading'));
if (fullscreenButton) fullscreenButton.hidden = state !== 'ready';
if (zoomControl) zoomControl.hidden = state !== 'ready';
if (statusElement && message) statusElement.textContent = message;
};
@@ -249,10 +441,15 @@
center: position,
zoom: 17,
minZoom: 10,
zoomControl: true,
zoomControlOptions: {
position: window.naver.maps.Position.RIGHT_CENTER,
},
maxZoom: 21,
draggable: false,
keyboardShortcuts: false,
scrollWheel: false,
pinchZoom: false,
disableDoubleClickZoom: true,
disableDoubleTapZoom: true,
disableTwoFingerTapZoom: true,
zoomControl: false,
});
new window.naver.maps.Marker({
@@ -261,6 +458,22 @@
title: label,
});
const updateZoomButtons = () => {
const zoom = map.getZoom();
if (zoomInButton) zoomInButton.disabled = zoom >= 21;
if (zoomOutButton) zoomOutButton.disabled = zoom <= 10;
};
const setFixedZoom = (difference) => {
const nextZoom = Math.min(21, Math.max(10, map.getZoom() + difference));
map.setCenter(position);
map.setZoom(nextZoom, false);
map.setCenter(position);
updateZoomButtons();
};
zoomInButton?.addEventListener('click', () => setFixedZoom(1));
zoomOutButton?.addEventListener('click', () => setFixedZoom(-1));
updateZoomButtons();
const refreshMapSize = () => {
window.setTimeout(() => {
window.naver.maps.Event.trigger(map, 'resize');