갤러리형 게시판¶
이미지 중심의 갤러리형 게시판을 구현하는 방법을 학습합니다.
기본 갤러리 레이아웃¶
그리드 형태의 목록¶
<!-- 갤러리형 리스트 -->
<div class="gallery-list">
<ul class="gallery-items">
<li loop="$document_list=>$no,$document" class="gallery-item">
{@
// 썸네일 가져오기
$thumbnail = $document->getThumbnail(300, 200);
if(!$thumbnail) {
$thumbnail = '/modules/board/skins/gallery/img/no-image.png';
}
}
<a href="{getUrl('document_srl',$document->document_srl)}" class="item-link">
<div class="thumbnail">
<img src="{$thumbnail}" alt="{$document->getTitle()}" />
<!-- 오버레이 정보 -->
<div class="overlay">
<span class="view-count">
<i class="xi-eye"></i> {$document->get('readed_count')}
</span>
<span class="comment-count" cond="$document->getCommentCount()">
<i class="xi-comment"></i> {$document->getCommentCount()}
</span>
</div>
</div>
<div class="item-info">
<h3 class="title">{$document->getTitle()}</h3>
<div class="meta">
<span class="author">{$document->getNickName()}</span>
<span class="date">{$document->getRegdate('Y.m.d')}</span>
</div>
</div>
</a>
</li>
</ul>
</div>
<!-- CSS -->
<style>
.gallery-list {
margin: -10px;
}
.gallery-items {
display: flex;
flex-wrap: wrap;
list-style: none;
padding: 0;
}
.gallery-item {
width: 25%;
padding: 10px;
box-sizing: border-box;
}
@media (max-width: 1200px) {
.gallery-item { width: 33.333%; }
}
@media (max-width: 768px) {
.gallery-item { width: 50%; }
}
@media (max-width: 480px) {
.gallery-item { width: 100%; }
}
.item-link {
display: block;
text-decoration: none;
color: inherit;
}
.thumbnail {
position: relative;
overflow: hidden;
padding-bottom: 66.666%; /* 3:2 비율 */
}
.thumbnail img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.item-link:hover .thumbnail img {
transform: scale(1.1);
}
.overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0,0,0,0.7));
color: white;
padding: 10px;
display: flex;
justify-content: space-between;
}
.item-info {
padding: 15px 10px;
}
.title {
font-size: 16px;
margin: 0 0 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
font-size: 13px;
color: #666;
}
</style>
이미지 필터링 기능¶
이미지가 있는 글만 표시¶
<!-- 이미지 필터 옵션 -->
<div class="filter-options">
<label>
<input type="checkbox" id="filter-has-image" checked="checked" />
이미지가 있는 글만 보기
</label>
<label>
<input type="checkbox" id="filter-has-video" />
동영상이 있는 글만 보기
</label>
</div>
<script>
jQuery(function($) {
// 이미지 필터
$('#filter-has-image').change(function() {
filterGalleryItems();
});
$('#filter-has-video').change(function() {
filterGalleryItems();
});
function filterGalleryItems() {
var showImage = $('#filter-has-image').is(':checked');
var showVideo = $('#filter-has-video').is(':checked');
$('.gallery-item').each(function() {
var $item = $(this);
var hasImage = $item.find('.thumbnail img').attr('src').indexOf('no-image') === -1;
var hasVideo = $item.hasClass('has-video');
var show = true;
if(showImage && !hasImage) show = false;
if(showVideo && !hasVideo) show = false;
$item.toggle(show);
});
}
});
</script>
라이트박스 연동¶
이미지 확대 보기¶
<!-- Photoswipe 연동 -->
<div class="gallery-list" id="gallery">
<div loop="$document_list=>$no,$document" class="gallery-item">
{@
$thumbnail = $document->getThumbnail(300, 200);
$original = '';
// 원본 이미지 찾기
if($document->hasUploadedFiles()) {
$files = $document->getUploadedFiles();
foreach($files as $file) {
if($file->direct_download == 'Y' && preg_match('/\.(jpg|jpeg|png|gif)$/i', $file->source_filename)) {
$original = $file->download_url;
break;
}
}
}
}
<a href="{$original ?: $thumbnail}"
data-size="{$file->width}x{$file->height}"
data-title="{$document->getTitle()}"
class="gallery-link">
<img src="{$thumbnail}" alt="{$document->getTitle()}" />
</a>
<div class="item-info">
<h3>{$document->getTitle()}</h3>
</div>
</div>
</div>
<!-- PhotoSwipe 구조 -->
<div class="pswp" tabindex="-1" role="dialog" aria-hidden="true">
<div class="pswp__bg"></div>
<div class="pswp__scroll-wrap">
<div class="pswp__container">
<div class="pswp__item"></div>
<div class="pswp__item"></div>
<div class="pswp__item"></div>
</div>
<div class="pswp__ui pswp__ui--hidden">
<!-- UI 요소들 -->
</div>
</div>
</div>
<script>
// PhotoSwipe 초기화
function initPhotoSwipe() {
var $gallery = $('#gallery');
$gallery.on('click', '.gallery-link', function(e) {
e.preventDefault();
var items = [];
var index = 0;
var clickedIndex = 0;
$gallery.find('.gallery-link').each(function(i) {
var $link = $(this);
var size = $link.data('size').split('x');
items.push({
src: $link.attr('href'),
w: parseInt(size[0], 10),
h: parseInt(size[1], 10),
title: $link.data('title')
});
if(this === e.currentTarget) {
clickedIndex = i;
}
});
var options = {
index: clickedIndex,
bgOpacity: 0.9,
showHideOpacity: true
};
var gallery = new PhotoSwipe($('.pswp')[0], PhotoSwipeUI_Default, items, options);
gallery.init();
});
}
jQuery(function() {
initPhotoSwipe();
});
</script>
무한 스크롤¶
자동 로딩 구현¶
<div class="gallery-list" id="gallery-container">
<!-- 갤러리 아이템들 -->
</div>
<div class="loading-indicator" style="display:none;">
<i class="xi-spinner xi-spin"></i> 로딩 중...
</div>
<script>
var currentPage = 1;
var isLoading = false;
var hasMore = true;
jQuery(function($) {
// 스크롤 이벤트
$(window).scroll(function() {
if(isLoading || !hasMore) return;
var scrollHeight = $(document).height();
var scrollPosition = $(window).height() + $(window).scrollTop();
if((scrollHeight - scrollPosition) < 200) {
loadMoreItems();
}
});
function loadMoreItems() {
isLoading = true;
$('.loading-indicator').show();
var params = {
module_srl: window.module_srl,
page: currentPage + 1,
list_count: 20
};
exec_xml('board', 'getBoardList', params, function(ret) {
if(ret.data) {
var html = '';
$.each(ret.data, function(i, item) {
html += renderGalleryItem(item);
});
$('#gallery-container').append(html);
currentPage++;
if(ret.total_page <= currentPage) {
hasMore = false;
}
}
isLoading = false;
$('.loading-indicator').hide();
});
}
function renderGalleryItem(item) {
return '<div class="gallery-item">' +
'<img src="' + item.thumbnail + '" />' +
'<h3>' + item.title + '</h3>' +
'</div>';
}
});
</script>
이미지 레이지 로딩¶
성능 최적화¶
<!-- Lazy loading 적용 -->
<div class="gallery-item">
<img class="lazy"
data-src="{$thumbnail}"
src="/modules/board/skins/gallery/img/placeholder.gif"
alt="{$document->getTitle()}" />
<noscript>
<img src="{$thumbnail}" alt="{$document->getTitle()}" />
</noscript>
</div>
<script>
// Intersection Observer를 이용한 lazy loading
document.addEventListener('DOMContentLoaded', function() {
var lazyImages = [].slice.call(document.querySelectorAll('img.lazy'));
if('IntersectionObserver' in window) {
var lazyImageObserver = new IntersectionObserver(function(entries, observer) {
entries.forEach(function(entry) {
if(entry.isIntersecting) {
var lazyImage = entry.target;
lazyImage.src = lazyImage.dataset.src;
lazyImage.classList.remove('lazy');
lazyImage.classList.add('loaded');
lazyImageObserver.unobserve(lazyImage);
}
});
});
lazyImages.forEach(function(lazyImage) {
lazyImageObserver.observe(lazyImage);
});
} else {
// 폴백: 스크롤 이벤트 사용
var lazyLoad = function() {
lazyImages.forEach(function(img) {
if(isInViewport(img)) {
img.src = img.dataset.src;
img.classList.remove('lazy');
}
});
};
window.addEventListener('scroll', lazyLoad);
window.addEventListener('resize', lazyLoad);
}
});
function isInViewport(el) {
var rect = el.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
</script>
필터 및 정렬¶
다양한 정렬 옵션¶
<!-- 정렬 옵션 -->
<div class="sort-options">
<button class="sort-btn active" data-sort="newest">최신순</button>
<button class="sort-btn" data-sort="popular">인기순</button>
<button class="sort-btn" data-sort="commented">댓글순</button>
<button class="sort-btn" data-sort="random">랜덤</button>
</div>
<!-- 카테고리 필터 -->
<div class="category-filter">
<button class="cat-btn active" data-category="">전체</button>
<button loop="$category_list=>$cat" class="cat-btn" data-category="{$cat->category_srl}">
{$cat->title}
</button>
</div>
<script>
jQuery(function($) {
// 정렬
$('.sort-btn').click(function() {
$('.sort-btn').removeClass('active');
$(this).addClass('active');
var sort = $(this).data('sort');
loadGallery({sort_index: getSortIndex(sort)});
});
// 카테고리 필터
$('.cat-btn').click(function() {
$('.cat-btn').removeClass('active');
$(this).addClass('active');
var category = $(this).data('category');
loadGallery({category_srl: category});
});
function getSortIndex(sort) {
var map = {
'newest': 'regdate',
'popular': 'readed_count',
'commented': 'comment_count',
'random': 'list_order'
};
return map[sort] || 'regdate';
}
function loadGallery(params) {
$('#gallery-container').addClass('loading');
params = $.extend({
module_srl: window.module_srl,
page: 1
}, params);
exec_xml('board', 'getBoardList', params, function(ret) {
renderGallery(ret.data);
$('#gallery-container').removeClass('loading');
});
}
});
</script>
모범 사례¶
- 이미지 최적화: 썸네일 크기 적절히 설정
- 레이지 로딩: 초기 로딩 속도 개선
- 반응형 디자인: 다양한 디바이스 대응
- 캐싱 활용: 이미지 캐싱으로 성능 향상
- 접근성: alt 텍스트 필수 제공
다음 단계¶
갤러리형 게시판을 구현했다면, 카테고리 관리를 학습하세요.