인터랙션 기능

인터랙션 기능

게시판에서 사용자 상호작용을 위한 고급 기능들을 구현하는 방법을 학습합니다.

추천/비추천 시스템

기본 구조

<!-- 추천/비추천 버튼 -->
<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">&times;</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;
    }
}

모범 사례

  1. 사용자 피드백: 모든 상호작용에 즉각적인 피드백 제공
  2. 접근성: 키보드 내비게이션과 스크린 리더 지원
  3. 성능: 비동기 처리로 페이지 새로고침 방지
  4. 보안: 모든 입력값 검증 및 CSRF 보호
  5. 모바일 최적화: 터치 인터페이스 고려

다음 단계

인터랙션 기능을 구현했다면, 갤러리 게시판에서 시각적 콘텐츠 관리를 학습하세요.