인터랙션 기능¶
게시판에서 사용자 상호작용을 위한 고급 기능들을 구현하는 방법을 학습합니다.
추천/비추천 시스템¶
기본 구조¶
<!-- 추천/비추천 버튼 -->
<div class="vote-buttons">
<button type="button" class="btn-vote btn-recommend" data-document-srl="{$oDocument->document_srl}">
<i class="fa fa-thumbs-up"></i>
추천 ({$oDocument->voted_count})
</button>
<button type="button" class="btn-vote btn-blame" data-document-srl="{$oDocument->document_srl}">
<i class="fa fa-thumbs-down"></i>
비추천 ({$oDocument->blamed_count})
</button>
</div>
JavaScript 구현¶
class VoteSystem {
constructor() {
this.initEvents();
}
initEvents() {
document.addEventListener('click', (e) => {
if (e.target.closest('.btn-vote')) {
e.preventDefault();
this.handleVote(e.target.closest('.btn-vote'));
}
});
}
async handleVote(button) {
const documentSrl = button.dataset.documentSrl;
const voteType = button.classList.contains('btn-recommend') ? 'recommend' : 'blame';
// 로그인 체크
if (!logged_info || !logged_info.member_srl) {
alert('로그인이 필요합니다.');
return;
}
try {
const response = await fetch('/index.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
module: 'board',
act: 'procBoardVoteDocument',
document_srl: documentSrl,
vote_type: voteType
})
});
const result = await response.json();
if (result.error === '0') {
this.updateVoteDisplay(documentSrl, result);
this.showVoteMessage(voteType, true);
} else {
this.showVoteMessage(voteType, false, result.message);
}
} catch (error) {
console.error('Vote error:', error);
this.showVoteMessage(voteType, false, '네트워크 오류가 발생했습니다.');
}
}
updateVoteDisplay(documentSrl, result) {
const recommendBtn = document.querySelector(`.btn-recommend[data-document-srl="${documentSrl}"]`);
const blameBtn = document.querySelector(`.btn-blame[data-document-srl="${documentSrl}"]`);
if (recommendBtn) {
recommendBtn.innerHTML = `<i class="fa fa-thumbs-up"></i> 추천 (${result.voted_count || 0})`;
}
if (blameBtn) {
blameBtn.innerHTML = `<i class="fa fa-thumbs-down"></i> 비추천 (${result.blamed_count || 0})`;
}
// 투표한 버튼 비활성화
if (result.vote_type === 'recommend') {
recommendBtn?.classList.add('voted');
} else if (result.vote_type === 'blame') {
blameBtn?.classList.add('voted');
}
}
showVoteMessage(voteType, success, message = '') {
const defaultMessages = {
recommend: success ? '추천되었습니다.' : '추천에 실패했습니다.',
blame: success ? '비추천되었습니다.' : '비추천에 실패했습니다.'
};
const text = message || defaultMessages[voteType];
// 토스트 메시지 표시
this.showToast(text, success ? 'success' : 'error');
}
showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: ${type === 'success' ? '#28a745' : type === 'error' ? '#dc3545' : '#007bff'};
color: white;
padding: 12px 20px;
border-radius: 4px;
z-index: 9999;
animation: slideIn 0.3s ease-out;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease-in';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
}
// 초기화
const voteSystem = new VoteSystem();
CSS 스타일¶
.vote-buttons {
display: flex;
gap: 10px;
margin: 20px 0;
justify-content: center;
}
.btn-vote {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 8px 16px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.btn-vote:hover {
background: #f8f9fa;
}
.btn-recommend:hover {
border-color: #28a745;
color: #28a745;
}
.btn-blame:hover {
border-color: #dc3545;
color: #dc3545;
}
.btn-vote.voted {
opacity: 0.6;
cursor: not-allowed;
}
.btn-vote.voted.btn-recommend {
background: #28a745;
color: white;
border-color: #28a745;
}
.btn-vote.voted.btn-blame {
background: #dc3545;
color: white;
border-color: #dc3545;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
스크랩 기능¶
스크랩 버튼¶
<button type="button" class="btn-scrap" data-document-srl="{$oDocument->document_srl}">
<i class="fa fa-bookmark"></i>
스크랩
</button>
스크랩 시스템 구현¶
class ScrapSystem {
constructor() {
this.initEvents();
}
initEvents() {
document.addEventListener('click', (e) => {
if (e.target.closest('.btn-scrap')) {
e.preventDefault();
this.handleScrap(e.target.closest('.btn-scrap'));
}
});
}
async handleScrap(button) {
const documentSrl = button.dataset.documentSrl;
if (!logged_info || !logged_info.member_srl) {
alert('로그인이 필요합니다.');
return;
}
try {
const response = await fetch('/index.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
module: 'board',
act: 'procBoardScrapDocument',
document_srl: documentSrl
})
});
const result = await response.json();
if (result.error === '0') {
button.classList.toggle('scrapped');
this.updateScrapButton(button, result.is_scrapped);
this.showMessage(result.is_scrapped ? '스크랩되었습니다.' : '스크랩이 취소되었습니다.');
} else {
this.showMessage('스크랩에 실패했습니다: ' + result.message, 'error');
}
} catch (error) {
console.error('Scrap error:', error);
this.showMessage('네트워크 오류가 발생했습니다.', 'error');
}
}
updateScrapButton(button, isScrapped) {
if (isScrapped) {
button.innerHTML = '<i class="fa fa-bookmark"></i> 스크랩됨';
button.classList.add('scrapped');
} else {
button.innerHTML = '<i class="fa fa-bookmark-o"></i> 스크랩';
button.classList.remove('scrapped');
}
}
showMessage(message, type = 'success') {
// 기존 토스트와 동일한 방식으로 메시지 표시
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: ${type === 'success' ? '#28a745' : '#dc3545'};
color: white;
padding: 12px 20px;
border-radius: 4px;
z-index: 9999;
animation: slideIn 0.3s ease-out;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease-in';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
}
// 초기화
const scrapSystem = new ScrapSystem();
신고 기능¶
신고 모달¶
<!-- 신고 버튼 -->
<button type="button" class="btn-report" data-document-srl="{$oDocument->document_srl}">
<i class="fa fa-flag"></i>
신고
</button>
<!-- 신고 모달 -->
<div id="reportModal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3>게시물 신고</h3>
<button type="button" class="modal-close">×</button>
</div>
<div class="modal-body">
<form id="reportForm">
<input type="hidden" id="reportDocumentSrl" name="document_srl" />
<div class="form-group">
<label>신고 사유</label>
<div class="radio-group">
<label><input type="radio" name="report_reason" value="spam"> 스팸/광고</label>
<label><input type="radio" name="report_reason" value="abuse"> 욕설/비방</label>
<label><input type="radio" name="report_reason" value="inappropriate"> 부적절한 내용</label>
<label><input type="radio" name="report_reason" value="copyright"> 저작권 침해</label>
<label><input type="radio" name="report_reason" value="other"> 기타</label>
</div>
</div>
<div class="form-group">
<label for="reportDetails">상세 내용</label>
<textarea id="reportDetails" name="report_details" rows="4"
placeholder="신고 사유를 자세히 입력해 주세요."></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-close">취소</button>
<button type="button" class="btn btn-danger" id="submitReport">신고하기</button>
</div>
</div>
</div>
신고 시스템 구현¶
class ReportSystem {
constructor() {
this.modal = document.getElementById('reportModal');
this.form = document.getElementById('reportForm');
this.initEvents();
}
initEvents() {
// 신고 버튼 클릭
document.addEventListener('click', (e) => {
if (e.target.closest('.btn-report')) {
e.preventDefault();
this.openReportModal(e.target.closest('.btn-report'));
}
});
// 모달 닫기
document.addEventListener('click', (e) => {
if (e.target.classList.contains('modal-close') || e.target === this.modal) {
this.closeModal();
}
});
// 신고 제출
document.getElementById('submitReport')?.addEventListener('click', () => {
this.submitReport();
});
// ESC 키로 모달 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.modal.style.display === 'block') {
this.closeModal();
}
});
}
openReportModal(button) {
if (!logged_info || !logged_info.member_srl) {
alert('로그인이 필요합니다.');
return;
}
const documentSrl = button.dataset.documentSrl;
document.getElementById('reportDocumentSrl').value = documentSrl;
this.modal.style.display = 'block';
document.body.style.overflow = 'hidden';
// 폼 초기화
this.form.reset();
}
closeModal() {
this.modal.style.display = 'none';
document.body.style.overflow = '';
}
async submitReport() {
const formData = new FormData(this.form);
const documentSrl = formData.get('document_srl');
const reason = formData.get('report_reason');
const details = formData.get('report_details');
if (!reason) {
alert('신고 사유를 선택해 주세요.');
return;
}
if (!details.trim()) {
alert('상세 내용을 입력해 주세요.');
return;
}
try {
const response = await fetch('/index.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
module: 'board',
act: 'procBoardReportDocument',
document_srl: documentSrl,
report_reason: reason,
report_details: details
})
});
const result = await response.json();
if (result.error === '0') {
alert('신고가 접수되었습니다. 검토 후 조치하겠습니다.');
this.closeModal();
} else {
alert('신고 접수에 실패했습니다: ' + result.message);
}
} catch (error) {
console.error('Report error:', error);
alert('네트워크 오류가 발생했습니다.');
}
}
}
// 초기화
const reportSystem = new ReportSystem();
모달 CSS¶
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
border-radius: 8px;
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
animation: modalShow 0.3s ease-out;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #eee;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #999;
}
.modal-body {
padding: 20px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 20px;
border-top: 1px solid #eee;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.radio-group label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: bold;
}
.form-group textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
resize: vertical;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-danger {
background: #dc3545;
color: white;
}
@keyframes modalShow {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
실시간 조회수¶
실시간 조회수 업데이트¶
class ViewCountTracker {
constructor() {
this.documentSrl = this.getDocumentSrl();
this.viewElement = document.querySelector('.view-count');
if (this.documentSrl && this.viewElement) {
this.trackView();
this.startRealTimeUpdate();
}
}
getDocumentSrl() {
// URL에서 document_srl 추출
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('document_srl');
}
async trackView() {
try {
await fetch('/index.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
module: 'board',
act: 'procBoardUpdateViewCount',
document_srl: this.documentSrl
})
});
} catch (error) {
console.error('View count tracking error:', error);
}
}
startRealTimeUpdate() {
// 10초마다 조회수 업데이트
setInterval(() => {
this.updateViewCount();
}, 10000);
}
async updateViewCount() {
try {
const response = await fetch('/index.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
module: 'board',
act: 'getBoardDocumentInfo',
document_srl: this.documentSrl
})
});
const result = await response.json();
if (result.error === '0' && result.document) {
this.viewElement.textContent = result.document.readed_count;
}
} catch (error) {
console.error('View count update error:', error);
}
}
}
// 초기화
const viewCountTracker = new ViewCountTracker();
소셜 공유¶
소셜 공유 버튼¶
<div class="social-share">
<h4>공유하기</h4>
<div class="share-buttons">
<button type="button" class="btn-share facebook" data-platform="facebook">
<i class="fab fa-facebook-f"></i>
페이스북
</button>
<button type="button" class="btn-share twitter" data-platform="twitter">
<i class="fab fa-twitter"></i>
트위터
</button>
<button type="button" class="btn-share kakao" data-platform="kakao">
<i class="fas fa-comment"></i>
카카오톡
</button>
<button type="button" class="btn-share copy-url" data-platform="copy">
<i class="fas fa-link"></i>
URL 복사
</button>
</div>
</div>
소셜 공유 구현¶
class SocialShare {
constructor() {
this.url = window.location.href;
this.title = document.title;
this.description = this.getDescription();
this.initEvents();
this.loadKakaoSDK();
}
initEvents() {
document.addEventListener('click', (e) => {
if (e.target.closest('.btn-share')) {
e.preventDefault();
const button = e.target.closest('.btn-share');
const platform = button.dataset.platform;
this.share(platform);
}
});
}
getDescription() {
const metaDesc = document.querySelector('meta[name="description"]');
return metaDesc ? metaDesc.getAttribute('content') : '';
}
share(platform) {
switch (platform) {
case 'facebook':
this.shareFacebook();
break;
case 'twitter':
this.shareTwitter();
break;
case 'kakao':
this.shareKakao();
break;
case 'copy':
this.copyUrl();
break;
}
}
shareFacebook() {
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(this.url)}`;
this.openWindow(url);
}
shareTwitter() {
const text = `${this.title} ${this.url}`;
const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}`;
this.openWindow(url);
}
shareKakao() {
if (typeof Kakao !== 'undefined') {
Kakao.Link.sendDefault({
objectType: 'feed',
content: {
title: this.title,
description: this.description,
imageUrl: this.getImageUrl(),
link: {
mobileWebUrl: this.url,
webUrl: this.url,
},
},
buttons: [
{
title: '웹으로 보기',
link: {
mobileWebUrl: this.url,
webUrl: this.url,
},
},
],
});
} else {
alert('카카오톡 공유 기능을 불러오는 중입니다. 잠시 후 다시 시도해 주세요.');
}
}
async copyUrl() {
try {
await navigator.clipboard.writeText(this.url);
this.showMessage('URL이 클립보드에 복사되었습니다.');
} catch (error) {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = this.url;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
this.showMessage('URL이 클립보드에 복사되었습니다.');
}
}
getImageUrl() {
const ogImage = document.querySelector('meta[property="og:image"]');
if (ogImage) {
return ogImage.getAttribute('content');
}
const firstImage = document.querySelector('.document-content img');
return firstImage ? firstImage.src : '';
}
openWindow(url) {
window.open(url, 'shareWindow', 'width=600,height=400,scrollbars=yes,resizable=yes');
}
loadKakaoSDK() {
if (typeof Kakao === 'undefined') {
const script = document.createElement('script');
script.src = 'https://developers.kakao.com/sdk/js/kakao.js';
script.onload = () => {
if (typeof Kakao !== 'undefined') {
Kakao.init('YOUR_KAKAO_APP_KEY'); // 실제 카카오 앱 키 입력
}
};
document.head.appendChild(script);
}
}
showMessage(message) {
const toast = document.createElement('div');
toast.className = 'toast toast-success';
toast.textContent = message;
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #28a745;
color: white;
padding: 12px 20px;
border-radius: 4px;
z-index: 9999;
animation: slideIn 0.3s ease-out;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease-in';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
}
// 초기화
const socialShare = new SocialShare();
소셜 공유 CSS¶
.social-share {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
}
.social-share h4 {
margin: 0 0 15px 0;
font-size: 16px;
color: #333;
}
.share-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn-share {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 8px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
color: white;
text-decoration: none;
transition: all 0.3s;
}
.btn-share.facebook {
background: #1877f2;
}
.btn-share.facebook:hover {
background: #166fe5;
}
.btn-share.twitter {
background: #1da1f2;
}
.btn-share.twitter:hover {
background: #0d8bd9;
}
.btn-share.kakao {
background: #fee500;
color: #333;
}
.btn-share.kakao:hover {
background: #f9d71c;
}
.btn-share.copy-url {
background: #6c757d;
}
.btn-share.copy-url:hover {
background: #5a6268;
}
@media (max-width: 768px) {
.share-buttons {
flex-direction: column;
}
.btn-share {
justify-content: center;
}
}
모범 사례¶
- 사용자 피드백: 모든 상호작용에 즉각적인 피드백 제공
- 접근성: 키보드 내비게이션과 스크린 리더 지원
- 성능: 비동기 처리로 페이지 새로고침 방지
- 보안: 모든 입력값 검증 및 CSRF 보호
- 모바일 최적화: 터치 인터페이스 고려
다음 단계¶
인터랙션 기능을 구현했다면, 갤러리 게시판에서 시각적 콘텐츠 관리를 학습하세요.