갤러리형 게시판 개발

갤러리형 게시판 개발

이미지 중심의 갤러리형 게시판을 개발하는 방법을 단계별로 설명합니다.

📋 기본 구조

갤러리형 게시판은 다음과 같은 특징을 가집니다:
- 썸네일 이미지 중심의 목록 표시
- 그리드 레이아웃
- 이미지 확대/축소 기능
- 라이트박스 효과

🔧 목록 템플릿 (list.html)

1. 기본 갤러리 목록

<div class="gallery-container">
    <!-- 갤러리 헤더 -->
    <div class="gallery-header">
        <div class="gallery-info">
            <h2>{$module_info->browser_title}</h2>
            <p class="total-count">총 {$total_count}개의 게시물</p>
        </div>

        <!-- 보기 방식 선택 -->
        <div class="view-options">
            <button type="button" class="view-btn active" data-view="grid" title="그리드뷰">
                <i class="fa fa-th"></i>
            </button>
            <button type="button" class="view-btn" data-view="list" title="리스트뷰">
                <i class="fa fa-list"></i>
            </button>
        </div>

        <!-- 정렬 옵션 -->
        <div class="sort-options">
            <select name="order_type" onchange="changeSort(this.value)">
                <option value="regdate" {@if($order_type=='regdate')}selected{@end}>최신순</option>
                <option value="readed_count" {@if($order_type=='readed_count')}selected{@end}>조회순</option>
                <option value="voted_count" {@if($order_type=='voted_count')}selected{@end}>추천순</option>
                <option value="comment_count" {@if($order_type=='comment_count')}selected{@end}>댓글순</option>
            </select>
        </div>

        <!-- 검색 -->
        <div class="search-box">
            <form action="./" method="get" class="search-form">
                <input type="hidden" name="mid" value="{$mid}">
                <input type="hidden" name="act" value="dispBoardContent">
                <select name="search_target">
                    <option value="title_content" {@if($search_target=='title_content')}selected{@end}>제목+내용</option>
                    <option value="title" {@if($search_target=='title')}selected{@end}>제목</option>
                    <option value="content" {@if($search_target=='content')}selected{@end}>내용</option>
                    <option value="nick_name" {@if($search_target=='nick_name')}selected{@end}>작성자</option>
                    <option value="tag" {@if($search_target=='tag')}selected{@end}>태그</option>
                </select>
                <input type="text" name="search_keyword" value="{htmlspecialchars($search_keyword)}" placeholder="검색어를 입력하세요">
                <button type="submit" class="btn-search">
                    <i class="fa fa-search"></i>
                </button>
            </form>
        </div>
    </div>

    <!-- 갤러리 그리드 -->
    <div class="gallery-grid" id="gallery_grid">
        <!--@foreach($document_list as $document)-->
        {@$thumbnail = $document->getThumbnail(300, 300, 'crop')}
        <div class="gallery-item" data-document-srl="{$document->document_srl}">
            <div class="gallery-card">
                <!-- 썸네일 이미지 -->
                <div class="gallery-thumbnail">
                    <a href="{getUrl('','document_srl',$document->document_srl)}" class="thumbnail-link">
                        <!--@if($thumbnail)-->
                            <img src="{$thumbnail}" alt="{htmlspecialchars($document->getTitleText())}" loading="lazy">
                        <!--@else-->
                            <div class="no-image">
                                <i class="fa fa-image"></i>
                                <span>이미지 없음</span>
                            </div>
                        <!--@end-->

                        <!-- 이미지 오버레이 -->
                        <div class="thumbnail-overlay">
                            <div class="overlay-icons">
                                <span class="view-icon" title="보기">
                                    <i class="fa fa-eye"></i>
                                </span>
                                <!--@if($document->getCommentCount() > 0)-->
                                <span class="comment-icon" title="댓글">
                                    <i class="fa fa-comment"></i>
                                    <span class="count">{$document->getCommentCount()}</span>
                                </span>
                                <!--@end-->
                                <!--@if($document->get('voted_count') > 0)-->
                                <span class="like-icon" title="추천">
                                    <i class="fa fa-heart"></i>
                                    <span class="count">{$document->get('voted_count')}</span>
                                </span>
                                <!--@end-->
                            </div>
                        </div>

                        <!-- 카테고리 배지 -->
                        <!--@if($document->get('category_srl'))-->
                        <div class="category-badge">
                            {$document->get('category_name')}
                        </div>
                        <!--@end-->

                        <!-- 새글 배지 -->
                        <!--@if($document->isNew())-->
                        <div class="new-badge">NEW</div>
                        <!--@end-->
                    </a>
                </div>

                <!-- 갤러리 정보 -->
                <div class="gallery-info">
                    <h3 class="gallery-title">
                        <a href="{getUrl('','document_srl',$document->document_srl)}">
                            {cut_str($document->getTitleText(), 30, '...')}
                        </a>
                    </h3>

                    <div class="gallery-meta">
                        <div class="author-info">
                            <!--@if($document->getProfileImage())-->
                                <img src="{$document->getProfileImage()}" alt="프로필" class="author-avatar">
                            <!--@else-->
                                <div class="author-avatar default">
                                    <i class="fa fa-user"></i>
                                </div>
                            <!--@end-->
                            <span class="author-name">{$document->getNickName()}</span>
                        </div>

                        <div class="post-stats">
                            <span class="date" title="{zdate($document->get('regdate'), 'Y-m-d H:i:s')}">
                                {zdate($document->get('regdate'), 'Y.m.d')}
                            </span>
                            <span class="views">
                                <i class="fa fa-eye"></i>
                                {number_format($document->get('readed_count'))}
                            </span>
                        </div>
                    </div>

                    <!-- 태그 -->
                    <!--@if($document->get('tags'))-->
                    <div class="gallery-tags">
                        <!--@foreach(explode(',', $document->get('tags')) as $tag)-->
                        <span class="tag">#{trim($tag)}</span>
                        <!--@end-->
                    </div>
                    <!--@end-->

                    <!-- 간단한 내용 미리보기 -->
                    <!--@if($document->getContentText())-->
                    <div class="gallery-excerpt">
                        {cut_str(strip_tags($document->getContentText()), 80, '...')}
                    </div>
                    <!--@end-->
                </div>
            </div>
        </div>
        <!--@end-->
    </div>

    <!-- 목록이 비어있을 때 -->
    <!--@if(!$document_list)-->
    <div class="empty-gallery">
        <div class="empty-icon">
            <i class="fa fa-images"></i>
        </div>
        <h3>등록된 이미지가 없습니다</h3>
        <p>첫 번째 이미지를 업로드해보세요!</p>
        <!--@if($grant->write_document)-->
        <a href="{getUrl('','act','dispBoardWrite')}" class="btn btn-primary">
            <i class="fa fa-plus"></i> 이미지 업로드
        </a>
        <!--@end-->
    </div>
    <!--@end-->

    <!-- 페이지네이션 -->
    <!--@if($page_navigation)-->
    <div class="gallery-pagination">
        <div class="pagination">
            {$page_navigation->page_navigation}
        </div>
    </div>
    <!--@end-->

    <!-- 글쓰기 버튼 -->
    <!--@if($grant->write_document)-->
    <div class="gallery-actions">
        <a href="{getUrl('','act','dispBoardWrite')}" class="btn btn-write">
            <i class="fa fa-camera"></i> 사진 올리기
        </a>
    </div>
    <!--@end-->
</div>

<!-- 라이트박스 모달 -->
<div id="lightbox_modal" class="lightbox-modal" style="display: none;">
    <div class="lightbox-overlay" onclick="closeLightbox()"></div>
    <div class="lightbox-content">
        <button class="lightbox-close" onclick="closeLightbox()">
            <i class="fa fa-times"></i>
        </button>
        <div class="lightbox-nav">
            <button class="lightbox-prev" onclick="prevImage()">
                <i class="fa fa-chevron-left"></i>
            </button>
            <button class="lightbox-next" onclick="nextImage()">
                <i class="fa fa-chevron-right"></i>
            </button>
        </div>
        <div class="lightbox-image">
            <img id="lightbox_img" src="" alt="">
        </div>
        <div class="lightbox-info">
            <h3 id="lightbox_title"></h3>
            <p id="lightbox_author"></p>
        </div>
    </div>
</div>

2. CSS 스타일링

/* 갤러리 컨테이너 */
.gallery-container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 20px;
}

/* 갤러리 헤더 */
.gallery-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 30px;
    flex-wrap: wrap;
    gap: 20px;
}

.gallery-info h2 {
    margin: 0 0 5px 0;
    font-size: 24px;
    color: #333;
}

.total-count {
    margin: 0;
    color: #666;
    font-size: 14px;
}

.view-options {
    display: flex;
    gap: 5px;
}

.view-btn {
    padding: 8px 12px;
    border: 1px solid #ddd;
    background: white;
    cursor: pointer;
    border-radius: 4px;
    transition: all 0.3s ease;
}

.view-btn.active,
.view-btn:hover {
    background: #007bff;
    color: white;
    border-color: #007bff;
}

/* 갤러리 그리드 */
.gallery-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
    gap: 20px;
    margin-bottom: 40px;
}

.gallery-item {
    transition: transform 0.3s ease;
}

.gallery-item:hover {
    transform: translateY(-5px);
}

.gallery-card {
    background: white;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 4px 6px rgba(0,0,0,0.1);
    transition: box-shadow 0.3s ease;
}

.gallery-card:hover {
    box-shadow: 0 8px 25px rgba(0,0,0,0.15);
}

/* 썸네일 */
.gallery-thumbnail {
    position: relative;
    padding-bottom: 75%; /* 4:3 비율 */
    overflow: hidden;
}

.thumbnail-link {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    display: block;
}

.gallery-thumbnail img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    transition: transform 0.3s ease;
}

.gallery-thumbnail:hover img {
    transform: scale(1.05);
}

.no-image {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    height: 100%;
    background: #f8f9fa;
    color: #6c757d;
    font-size: 14px;
}

.no-image i {
    font-size: 30px;
    margin-bottom: 8px;
}

/* 썸네일 오버레이 */
.thumbnail-overlay {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0,0,0,0.6);
    display: flex;
    align-items: center;
    justify-content: center;
    opacity: 0;
    transition: opacity 0.3s ease;
}

.gallery-thumbnail:hover .thumbnail-overlay {
    opacity: 1;
}

.overlay-icons {
    display: flex;
    gap: 15px;
    color: white;
}

.overlay-icons span {
    display: flex;
    align-items: center;
    gap: 5px;
    font-size: 14px;
}

/* 배지 */
.category-badge,
.new-badge {
    position: absolute;
    top: 10px;
    left: 10px;
    padding: 4px 8px;
    border-radius: 4px;
    font-size: 12px;
    font-weight: bold;
    z-index: 1;
}

.category-badge {
    background: #007bff;
    color: white;
}

.new-badge {
    background: #dc3545;
    color: white;
    left: auto;
    right: 10px;
}

/* 갤러리 정보 */
.gallery-info {
    padding: 16px;
}

.gallery-title {
    margin: 0 0 12px 0;
    font-size: 16px;
    line-height: 1.4;
}

.gallery-title a {
    color: #333;
    text-decoration: none;
    transition: color 0.3s ease;
}

.gallery-title a:hover {
    color: #007bff;
}

.gallery-meta {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 10px;
}

.author-info {
    display: flex;
    align-items: center;
    gap: 8px;
}

.author-avatar {
    width: 24px;
    height: 24px;
    border-radius: 50%;
    object-fit: cover;
}

.author-avatar.default {
    background: #dee2e6;
    display: flex;
    align-items: center;
    justify-content: center;
    color: #6c757d;
    font-size: 12px;
}

.author-name {
    font-size: 13px;
    color: #666;
    font-weight: 500;
}

.post-stats {
    display: flex;
    gap: 12px;
    font-size: 12px;
    color: #999;
}

.post-stats span {
    display: flex;
    align-items: center;
    gap: 3px;
}

/* 태그 */
.gallery-tags {
    margin-bottom: 8px;
}

.tag {
    display: inline-block;
    background: #e9ecef;
    color: #495057;
    padding: 2px 6px;
    border-radius: 3px;
    font-size: 11px;
    margin-right: 4px;
    margin-bottom: 4px;
}

.tag:hover {
    background: #007bff;
    color: white;
    cursor: pointer;
}

/* 내용 미리보기 */
.gallery-excerpt {
    font-size: 13px;
    color: #666;
    line-height: 1.4;
}

/* 라이트박스 */
.lightbox-modal {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    z-index: 9999;
    display: flex;
    align-items: center;
    justify-content: center;
}

.lightbox-overlay {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0,0,0,0.9);
}

.lightbox-content {
    position: relative;
    max-width: 90%;
    max-height: 90%;
    background: white;
    border-radius: 8px;
    overflow: hidden;
}

.lightbox-close {
    position: absolute;
    top: 15px;
    right: 15px;
    background: rgba(0,0,0,0.5);
    color: white;
    border: none;
    width: 40px;
    height: 40px;
    border-radius: 50%;
    cursor: pointer;
    z-index: 1;
    display: flex;
    align-items: center;
    justify-content: center;
}

.lightbox-nav button {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    background: rgba(0,0,0,0.5);
    color: white;
    border: none;
    width: 50px;
    height: 50px;
    border-radius: 50%;
    cursor: pointer;
    font-size: 18px;
    transition: background 0.3s ease;
}

.lightbox-prev {
    left: 20px;
}

.lightbox-next {
    right: 20px;
}

.lightbox-nav button:hover {
    background: rgba(0,0,0,0.7);
}

.lightbox-image img {
    max-width: 100%;
    max-height: 70vh;
    display: block;
}

.lightbox-info {
    padding: 20px;
    background: white;
}

/* 빈 갤러리 */
.empty-gallery {
    text-align: center;
    padding: 60px 20px;
    color: #6c757d;
}

.empty-icon {
    font-size: 48px;
    margin-bottom: 20px;
    opacity: 0.5;
}

/* 반응형 */
@media (max-width: 768px) {
    .gallery-grid {
        grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
        gap: 15px;
    }

    .gallery-header {
        flex-direction: column;
        align-items: stretch;
        gap: 15px;
    }

    .gallery-meta {
        flex-direction: column;
        gap: 8px;
        align-items: flex-start;
    }
}

@media (max-width: 480px) {
    .gallery-grid {
        grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
        gap: 10px;
    }

    .gallery-container {
        padding: 15px;
    }
}

3. JavaScript 기능

// 갤러리 기능 스크립트
(function() {
    'use strict';

    let currentImageIndex = 0;
    let imageList = [];

    // 초기화
    function initGallery() {
        // 이미지 목록 수집
        collectImages();

        // 썸네일 클릭 이벤트
        document.querySelectorAll('.thumbnail-link').forEach((link, index) => {
            link.addEventListener('click', function(e) {
                if(e.ctrlKey || e.metaKey) return; // Ctrl+클릭은 새탭으로

                e.preventDefault();
                openLightbox(index);
            });
        });

        // 보기 방식 변경
        document.querySelectorAll('.view-btn').forEach(btn => {
            btn.addEventListener('click', changeViewMode);
        });

        // 키보드 이벤트
        document.addEventListener('keydown', handleKeydown);

        // 무한 스크롤
        initInfiniteScroll();
    }

    // 이미지 목록 수집
    function collectImages() {
        imageList = [];
        document.querySelectorAll('.gallery-item').forEach(item => {
            const link = item.querySelector('.thumbnail-link');
            const img = item.querySelector('img');
            const title = item.querySelector('.gallery-title a');
            const author = item.querySelector('.author-name');

            if(img && img.src) {
                imageList.push({
                    src: img.src.replace(/&w=\d+&h=\d+/, ''), // 원본 이미지
                    title: title ? title.textContent : '',
                    author: author ? author.textContent : '',
                    url: link ? link.href : ''
                });
            }
        });
    }

    // 라이트박스 열기
    function openLightbox(index) {
        currentImageIndex = index;
        const modal = document.getElementById('lightbox_modal');
        const img = document.getElementById('lightbox_img');
        const title = document.getElementById('lightbox_title');
        const author = document.getElementById('lightbox_author');

        if(imageList[index]) {
            img.src = imageList[index].src;
            title.textContent = imageList[index].title;
            author.textContent = 'by ' + imageList[index].author;

            modal.style.display = 'flex';
            document.body.style.overflow = 'hidden';
        }
    }

    // 라이트박스 닫기
    window.closeLightbox = function() {
        const modal = document.getElementById('lightbox_modal');
        modal.style.display = 'none';
        document.body.style.overflow = '';
    }

    // 이전 이미지
    window.prevImage = function() {
        if(currentImageIndex > 0) {
            openLightbox(currentImageIndex - 1);
        } else {
            openLightbox(imageList.length - 1); // 순환
        }
    }

    // 다음 이미지
    window.nextImage = function() {
        if(currentImageIndex < imageList.length - 1) {
            openLightbox(currentImageIndex + 1);
        } else {
            openLightbox(0); // 순환
        }
    }

    // 키보드 이벤트 처리
    function handleKeydown(e) {
        const modal = document.getElementById('lightbox_modal');
        if(modal.style.display === 'flex') {
            switch(e.key) {
                case 'Escape':
                    closeLightbox();
                    break;
                case 'ArrowLeft':
                    prevImage();
                    break;
                case 'ArrowRight':
                    nextImage();
                    break;
            }
        }
    }

    // 보기 방식 변경
    function changeViewMode(e) {
        const viewType = e.target.dataset.view;
        const grid = document.getElementById('gallery_grid');

        // 버튼 상태 변경
        document.querySelectorAll('.view-btn').forEach(btn => {
            btn.classList.remove('active');
        });
        e.target.classList.add('active');

        // 그리드 클래스 변경
        grid.className = 'gallery-grid view-' + viewType;

        // 로컬 스토리지에 저장
        localStorage.setItem('gallery_view_mode', viewType);
    }

    // 무한 스크롤
    function initInfiniteScroll() {
        let loading = false;
        let currentPage = 1;

        window.addEventListener('scroll', function() {
            if(loading) return;

            const scrollHeight = document.documentElement.scrollHeight;
            const scrollTop = document.documentElement.scrollTop;
            const clientHeight = document.documentElement.clientHeight;

            if(scrollTop + clientHeight >= scrollHeight - 1000) {
                loadMoreImages();
            }
        });

        function loadMoreImages() {
            loading = true;
            currentPage++;

            const url = new URL(location.href);
            url.searchParams.set('page', currentPage);

            fetch(url.toString())
                .then(response => response.text())
                .then(html => {
                    const parser = new DOMParser();
                    const doc = parser.parseFromString(html, 'text/html');
                    const newItems = doc.querySelectorAll('.gallery-item');

                    if(newItems.length > 0) {
                        const grid = document.getElementById('gallery_grid');
                        newItems.forEach(item => {
                            grid.appendChild(item);
                        });

                        // 이벤트 재바인딩
                        collectImages();
                        bindNewItemEvents();
                    }
                })
                .catch(error => {
                    console.error('Error loading more images:', error);
                })
                .finally(() => {
                    loading = false;
                });
        }
    }

    // 새 아이템 이벤트 바인딩
    function bindNewItemEvents() {
        document.querySelectorAll('.thumbnail-link:not(.bound)').forEach((link, index) => {
            link.classList.add('bound');
            link.addEventListener('click', function(e) {
                if(e.ctrlKey || e.metaKey) return;
                e.preventDefault();
                openLightbox(imageList.length - document.querySelectorAll('.gallery-item').length + index);
            });
        });
    }

    // 정렬 변경
    window.changeSort = function(orderType) {
        const url = new URL(location.href);
        url.searchParams.set('order_type', orderType);
        url.searchParams.delete('page');
        location.href = url.toString();
    }

    // 초기화 실행
    document.addEventListener('DOMContentLoaded', function() {
        initGallery();

        // 저장된 보기 방식 복원
        const savedViewMode = localStorage.getItem('gallery_view_mode');
        if(savedViewMode) {
            const btn = document.querySelector(`[data-view="${savedViewMode}"]`);
            if(btn) btn.click();
        }
    });
})();

💡 고급 기능

1. 이미지 lazy loading

// Intersection Observer를 사용한 lazy loading
function initLazyLoading() {
    const imageObserver = new IntersectionObserver((entries, observer) => {
        entries.forEach(entry => {
            if(entry.isIntersecting) {
                const img = entry.target;
                img.src = img.dataset.src;
                img.classList.remove('lazy');
                observer.unobserve(img);
            }
        });
    });

    document.querySelectorAll('img[data-src]').forEach(img => {
        imageObserver.observe(img);
    });
}

2. 이미지 필터링

// 카테고리별 필터링
function initImageFilter() {
    const filterButtons = document.querySelectorAll('.filter-btn');
    const galleryItems = document.querySelectorAll('.gallery-item');

    filterButtons.forEach(btn => {
        btn.addEventListener('click', function() {
            const filter = this.dataset.filter;

            galleryItems.forEach(item => {
                if(filter === 'all' || item.dataset.category === filter) {
                    item.style.display = 'block';
                } else {
                    item.style.display = 'none';
                }
            });

            // 버튼 상태 변경
            filterButtons.forEach(b => b.classList.remove('active'));
            this.classList.add('active');
        });
    });
}

🔗 관련 문서