갤러리형 게시판 개발¶
이미지 중심의 갤러리형 게시판을 개발하는 방법을 단계별로 설명합니다.
📋 기본 구조¶
갤러리형 게시판은 다음과 같은 특징을 가집니다:
- 썸네일 이미지 중심의 목록 표시
- 그리드 레이아웃
- 이미지 확대/축소 기능
- 라이트박스 효과
🔧 목록 템플릿 (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');
});
});
}