게시판 글보기 템플릿 (view.html)
기본 글보기 구조
표준 글보기 레이아웃
<include target="_header.html" />
<!-- 글 정보 및 도구 -->
<div class="document-header">
<div class="document-info">
<!-- 카테고리 -->
<span class="category" cond="$oDocument->get('category_srl')">
<a href="{getUrl('category', $oDocument->get('category_srl'))}">
{$category_list[$oDocument->get('category_srl')]->title}
</a>
</span>
<!-- 제목 -->
<h1 class="document-title">
{$oDocument->getTitle()}
<!-- 상태 아이콘 -->
<span class="status-icons">
<span class="secret-icon" cond="$oDocument->get('status') == 'SECRET'">
<i class="icon-lock" title="비밀글"></i>
</span>
<span class="attach-icon" cond="$oDocument->getAttachedFileCount() > 0">
<i class="icon-attachment" title="첨부파일"></i>
</span>
</span>
</h1>
<!-- 글 메타 정보 -->
<div class="document-meta">
<div class="meta-left">
<!-- 작성자 -->
<span class="author">
<!--@if($oDocument->getProfileImage())-->
<img src="{$oDocument->getProfileImage()}" alt="" class="author-avatar" />
<!--@endif-->
<span class="author-name">{$oDocument->getNickName()}</span>
<!-- 회원 레벨 표시 -->
<span class="author-level" cond="$oDocument->getMemberSrl() > 0">
Lv.{$oDocument->get('member_level')}
</span>
</span>
<!-- 작성일 -->
<span class="reg-date">
<i class="icon-calendar"></i>
{zdate($oDocument->getRegdate(), 'Y년 m월 d일 H:i')}
</span>
<!-- 수정일 (수정된 경우) -->
<span class="update-date" cond="$oDocument->getUpdate() != $oDocument->getRegdate()">
<i class="icon-edit"></i>
수정: {zdate($oDocument->getUpdate(), 'Y.m.d H:i')}
</span>
</div>
<div class="meta-right">
<!-- 조회수 -->
<span class="read-count">
<i class="icon-eye"></i>
조회 {number_format($oDocument->getReadedCount())}
</span>
<!-- 추천수 -->
<span class="vote-count" cond="$module_info->use_vote == 'Y'">
<i class="icon-thumbs-up"></i>
추천 {$oDocument->getVotedCount()}
</span>
<!-- 댓글수 -->
<span class="comment-count" cond="$oDocument->getCommentCount() > 0">
<i class="icon-comment"></i>
댓글 {$oDocument->getCommentCount()}
</span>
</div>
</div>
</div>
<!-- 글 관리 도구 -->
<div class="document-tools">
<!-- 작성자나 관리자용 도구 -->
<div class="author-tools" cond="$oDocument->isGranted() || $grant->manager">
<a href="{getUrl('act', 'dispBoardModify', 'document_srl', $oDocument->document_srl)}"
class="btn btn-sm btn-outline-primary">
<i class="icon-edit"></i> 수정
</a>
<a href="{getUrl('act', 'dispBoardDelete', 'document_srl', $oDocument->document_srl)}"
class="btn btn-sm btn-outline-danger">
<i class="icon-trash"></i> 삭제
</a>
</div>
<!-- 일반 사용자용 도구 -->
<div class="user-tools">
<!-- 목록 버튼 -->
<a href="{getUrl('document_srl', '')}" class="btn btn-sm btn-outline-secondary">
<i class="icon-list"></i> 목록
</a>
<!-- 답글 버튼 -->
<a href="{getUrl('act', 'dispBoardWrite', 'document_srl', $oDocument->document_srl)}"
class="btn btn-sm btn-outline-info" cond="$grant->write">
<i class="icon-reply"></i> 답글
</a>
<!-- 스크랩 버튼 -->
<button type="button" class="btn btn-sm btn-outline-warning"
onclick="doScrap({$oDocument->document_srl})" cond="$logged_info">
<i class="icon-bookmark"></i> 스크랩
</button>
<!-- 신고 버튼 -->
<button type="button" class="btn btn-sm btn-outline-danger"
onclick="doBlame({$oDocument->document_srl})" cond="$logged_info">
<i class="icon-flag"></i> 신고
</button>
</div>
</div>
</div>
<!-- 첨부파일 목록 -->
<div class="attached-files" cond="$oDocument->getAttachedFileCount() > 0">
<h3 class="files-title">
<i class="icon-attachment"></i>
첨부파일 ({$oDocument->getAttachedFileCount()}개)
</h3>
<ul class="file-list">
<li loop="$oDocument->getAttachedFiles() => $file" class="file-item">
<!-- 이미지 파일 -->
<!--@if($file->isImage())-->
<div class="image-file">
<a href="{$file->uploaded_filename}" target="_blank" class="image-link">
<img src="{$file->thumbnail_filename}" alt="{$file->source_filename}" />
</a>
<div class="image-info">
<span class="filename">{$file->source_filename}</span>
<span class="filesize">({$file->file_size_text})</span>
</div>
</div>
<!--@else-->
<!-- 일반 파일 -->
<div class="general-file">
<a href="{getUrl('act', 'procFileDownload', 'file_srl', $file->file_srl)}"
class="file-download">
<i class="icon-download"></i>
<span class="filename">{$file->source_filename}</span>
<span class="filesize">({$file->file_size_text})</span>
<span class="download-count">다운로드 {number_format($file->download_count)}</span>
</a>
</div>
<!--@endif-->
</li>
</ul>
</div>
<!-- 글 내용 -->
<div class="document-content">
<div class="content-body">
{$oDocument->getContent(false)}
</div>
<!-- 추가 첨부 이미지 (본문에 없는 것들) -->
{@
$attached_images = array();
foreach($oDocument->getAttachedFiles() as $file) {
if($file->isImage() && !strpos($oDocument->getContent(), $file->uploaded_filename)) {
$attached_images[] = $file;
}
}
}
<div class="additional-images" cond="count($attached_images) > 0">
<h4>추가 이미지</h4>
<div class="image-gallery">
<div class="image-item" loop="$attached_images => $image">
<a href="{$image->uploaded_filename}" data-lightbox="gallery">
<img src="{$image->thumbnail_filename}" alt="{$image->source_filename}" />
</a>
</div>
</div>
</div>
</div>
<!-- 추천/비추천 영역 -->
<div class="vote-section" cond="$module_info->use_vote == 'Y' && $logged_info">
<div class="vote-buttons">
<button type="button" class="btn-vote btn-vote-up"
onclick="doVote({$oDocument->document_srl}, 'up')">
<i class="icon-thumbs-up"></i>
<span>추천</span>
<span class="vote-count">{$oDocument->getVotedCount()}</span>
</button>
<button type="button" class="btn-vote btn-vote-down"
onclick="doVote({$oDocument->document_srl}, 'down')">
<i class="icon-thumbs-down"></i>
<span>비추천</span>
<span class="vote-count">{$oDocument->get('blamed_count')}</span>
</button>
</div>
<!-- 투표 결과 표시 -->
<div class="vote-result" cond="$oDocument->getVotedCount() > 0 || $oDocument->get('blamed_count') > 0">
<div class="vote-bar">
{@
$total_votes = $oDocument->getVotedCount() + $oDocument->get('blamed_count');
$up_percent = $total_votes > 0 ? ($oDocument->getVotedCount() / $total_votes) * 100 : 0;
$down_percent = $total_votes > 0 ? ($oDocument->get('blamed_count') / $total_votes) * 100 : 0;
}
<div class="vote-bar-up" style="width: {$up_percent}%"></div>
<div class="vote-bar-down" style="width: {$down_percent}%"></div>
</div>
<div class="vote-stats">
<span class="up-votes">추천 {$oDocument->getVotedCount()}</span>
<span class="down-votes">비추천 {$oDocument->get('blamed_count')}</span>
</div>
</div>
</div>
<!-- 태그 -->
<div class="document-tags" cond="$oDocument->getTags()">
<h4 class="tags-title">태그</h4>
<ul class="tag-list">
<li loop="$oDocument->getTags() => $tag" class="tag-item">
<a href="{getUrl('search_target', 'tag', 'search_keyword', $tag)}" class="tag-link">
#{$tag}
</a>
</li>
</ul>
</div>
<!-- 이전/다음 글 -->
<div class="document-navigation">
<!-- 이전 글 -->
<div class="nav-item nav-prev" cond="$prev_document">
<div class="nav-label">
<i class="icon-chevron-up"></i>
이전 글
</div>
<div class="nav-content">
<a href="{getUrl('document_srl', $prev_document->document_srl)}" class="nav-title">
{cut_str($prev_document->getTitle(), 50)}
</a>
<div class="nav-meta">
<span class="nav-author">{$prev_document->getNickName()}</span>
<span class="nav-date">{zdate($prev_document->getRegdate(), 'Y.m.d')}</span>
</div>
</div>
</div>
<!-- 다음 글 -->
<div class="nav-item nav-next" cond="$next_document">
<div class="nav-label">
<i class="icon-chevron-down"></i>
다음 글
</div>
<div class="nav-content">
<a href="{getUrl('document_srl', $next_document->document_srl)}" class="nav-title">
{cut_str($next_document->getTitle(), 50)}
</a>
<div class="nav-meta">
<span class="nav-author">{$next_document->getNickName()}</span>
<span class="nav-date">{zdate($next_document->getRegdate(), 'Y.m.d')}</span>
</div>
</div>
</div>
</div>
<!-- 댓글 영역 -->
<div class="comment-section">
<!-- 댓글 작성 폼 -->
<include target="comment_form.html" />
<!-- 댓글 목록 -->
<include target="comment.html" />
</div>
<!-- 하단 도구 -->
<div class="document-footer">
<div class="footer-buttons">
<a href="{getUrl('document_srl', '')}" class="btn btn-secondary">목록</a>
<a href="{getUrl('act', 'dispBoardWrite')}" class="btn btn-primary" cond="$grant->write">글쓰기</a>
</div>
</div>
</div> <!-- board-container -->
이미지 갤러리형 글보기
이미지 중심 레이아웃
<div class="gallery-view">
<!-- 메인 이미지 영역 -->
<div class="main-image-area" cond="$oDocument->getAttachedFiles()">
{@
$image_files = array();
foreach($oDocument->getAttachedFiles() as $file) {
if($file->isImage()) {
$image_files[] = $file;
}
}
}
<div class="image-viewer" cond="count($image_files) > 0">
<!-- 메인 이미지 -->
<div class="main-image">
<img src="{$image_files[0]->uploaded_filename}"
alt="{$oDocument->getTitle()}"
id="mainImage" />
<!-- 이미지 네비게이션 -->
<div class="image-nav" cond="count($image_files) > 1">
<button type="button" class="nav-btn prev-btn" onclick="prevImage()">
<i class="icon-chevron-left"></i>
</button>
<button type="button" class="nav-btn next-btn" onclick="nextImage()">
<i class="icon-chevron-right"></i>
</button>
</div>
<!-- 이미지 카운터 -->
<div class="image-counter" cond="count($image_files) > 1">
<span id="currentImageIndex">1</span> / {count($image_files)}
</div>
</div>
<!-- 썸네일 목록 -->
<div class="thumbnail-list" cond="count($image_files) > 1">
<div class="thumbnail-item" loop="$image_files => $index, $file">
<img src="{$file->thumbnail_filename}"
alt=""
onclick="showImage({$index})"
class="thumbnail active"|cond="$index == 0" />
</div>
</div>
</div>
</div>
<!-- 글 정보 -->
<div class="gallery-info">
<!-- 제목과 메타 정보는 위와 동일 -->
<h1 class="gallery-title">{$oDocument->getTitle()}</h1>
<div class="gallery-meta">
<span class="author">{$oDocument->getNickName()}</span>
<span class="date">{zdate($oDocument->getRegdate(), 'Y.m.d H:i')}</span>
<span class="views">조회 {$oDocument->getReadedCount()}</span>
</div>
<!-- 글 내용 -->
<div class="gallery-content" cond="trim(strip_tags($oDocument->getContent()))">
{$oDocument->getContent(false)}
</div>
</div>
</div>
<script>
// 이미지 갤러리 스크립트
var imageFiles = [
<block loop="$image_files => $index, $file">
'{$file->uploaded_filename}'<block cond="$index < count($image_files) - 1">,</block>
</block>
];
var currentIndex = 0;
function showImage(index) {
currentIndex = index;
document.getElementById('mainImage').src = imageFiles[index];
document.getElementById('currentImageIndex').textContent = index + 1;
// 썸네일 활성화
var thumbnails = document.querySelectorAll('.thumbnail');
thumbnails.forEach(function(thumb, i) {
thumb.classList.toggle('active', i === index);
});
}
function prevImage() {
var prevIndex = currentIndex > 0 ? currentIndex - 1 : imageFiles.length - 1;
showImage(prevIndex);
}
function nextImage() {
var nextIndex = currentIndex < imageFiles.length - 1 ? currentIndex + 1 : 0;
showImage(nextIndex);
}
// 키보드 네비게이션
document.addEventListener('keydown', function(e) {
if (e.key === 'ArrowLeft') prevImage();
if (e.key === 'ArrowRight') nextImage();
});
</script>
모바일 최적화 글보기
반응형 레이아웃
{@ $is_mobile = Mobile::isFromMobilePhone() }
<div class="document-view {$is_mobile ? 'mobile' : 'desktop'}">
<!-- 모바일용 헤더 -->
<!--@if($is_mobile)-->
<div class="mobile-header">
<button type="button" class="back-btn" onclick="history.back()">
<i class="icon-arrow-left"></i>
</button>
<span class="mobile-title">{cut_str($oDocument->getTitle(), 20)}</span>
<button type="button" class="menu-btn" onclick="toggleMobileMenu()">
<i class="icon-menu"></i>
</button>
</div>
<!-- 모바일 메뉴 -->
<div class="mobile-menu" id="mobileMenu">
<div class="menu-item" cond="$oDocument->isGranted()">
<a href="{getUrl('act', 'dispBoardModify', 'document_srl', $oDocument->document_srl)}">
<i class="icon-edit"></i> 수정
</a>
</div>
<div class="menu-item" cond="$oDocument->isGranted()">
<a href="{getUrl('act', 'dispBoardDelete', 'document_srl', $oDocument->document_srl)}">
<i class="icon-trash"></i> 삭제
</a>
</div>
<div class="menu-item">
<a href="{getUrl('document_srl', '')}">
<i class="icon-list"></i> 목록
</a>
</div>
<div class="menu-item" cond="$grant->write">
<a href="{getUrl('act', 'dispBoardWrite', 'document_srl', $oDocument->document_srl)}">
<i class="icon-reply"></i> 답글
</a>
</div>
</div>
<!--@endif-->
<!-- 글 내용 (모바일/데스크탑 공통) -->
<article class="document-article">
<!-- 제목과 메타 정보 -->
<header class="article-header">
<h1 class="article-title">{$oDocument->getTitle()}</h1>
<div class="article-meta">
<div class="author-info">
<span class="author-name">{$oDocument->getNickName()}</span>
<span class="publish-date">{zdate($oDocument->getRegdate(), 'Y.m.d H:i')}</span>
</div>
<div class="article-stats">
<span class="views">
<i class="icon-eye"></i> {$oDocument->getReadedCount()}
</span>
<span class="comments" cond="$oDocument->getCommentCount() > 0">
<i class="icon-comment"></i> {$oDocument->getCommentCount()}
</span>
</div>
</div>
</header>
<!-- 글 본문 -->
<div class="article-body">
{$oDocument->getContent(false)}
</div>
<!-- 첨부파일 (모바일 최적화) -->
<div class="article-attachments" cond="$oDocument->getAttachedFileCount() > 0">
<h3>첨부파일</h3>
<div class="attachment-list">
<div class="attachment-item" loop="$oDocument->getAttachedFiles() => $file">
<!--@if($file->isImage())-->
<div class="image-attachment">
<img src="{$file->thumbnail_filename}"
alt="{$file->source_filename}"
onclick="showFullImage('{$file->uploaded_filename}')" />
</div>
<!--@else-->
<div class="file-attachment">
<a href="{getUrl('act', 'procFileDownload', 'file_srl', $file->file_srl)}">
<i class="icon-download"></i>
<span class="filename">{cut_str($file->source_filename, 30)}</span>
<span class="filesize">({$file->file_size_text})</span>
</a>
</div>
<!--@endif-->
</div>
</div>
</div>
</article>
</div>
<!-- 전체화면 이미지 뷰어 (모바일용) -->
<div class="fullscreen-viewer" id="fullscreenViewer" onclick="closeFullImage()">
<img src="" alt="" id="fullscreenImage" />
<button type="button" class="close-btn" onclick="closeFullImage()">×</button>
</div>
<script>
function toggleMobileMenu() {
var menu = document.getElementById('mobileMenu');
menu.classList.toggle('active');
}
function showFullImage(src) {
var viewer = document.getElementById('fullscreenViewer');
var image = document.getElementById('fullscreenImage');
image.src = src;
viewer.style.display = 'flex';
}
function closeFullImage() {
var viewer = document.getElementById('fullscreenViewer');
viewer.style.display = 'none';
}
// 메뉴 외부 클릭 시 닫기
document.addEventListener('click', function(e) {
var menu = document.getElementById('mobileMenu');
if (!e.target.closest('.mobile-menu') && !e.target.closest('.menu-btn')) {
menu.classList.remove('active');
}
});
</script>
CSS 스타일링
글보기 기본 스타일
/* 글보기 기본 레이아웃 */
.document-header {
border-bottom: 2px solid #eee;
padding-bottom: 20px;
margin-bottom: 30px;
}
.document-title {
font-size: 24px;
font-weight: 600;
margin: 0 0 15px;
line-height: 1.4;
color: #333;
}
.document-meta {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
font-size: 14px;
color: #666;
}
.author-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
margin-right: 8px;
vertical-align: middle;
}
.author-level {
background: #007bff;
color: white;
padding: 2px 6px;
border-radius: 10px;
font-size: 11px;
margin-left: 5px;
}
.meta-right span {
margin-left: 15px;
}
.meta-right i {
margin-right: 3px;
color: #999;
}
/* 글 도구 */
.document-tools {
display: flex;
justify-content: space-between;
margin-top: 15px;
gap: 10px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 8px 15px;
border: 1px solid #ddd;
border-radius: 4px;
text-decoration: none;
font-size: 13px;
transition: all 0.2s;
}
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.btn-primary {
background: #007bff;
color: white;
border-color: #007bff;
}
.btn-outline-primary {
color: #007bff;
border-color: #007bff;
}
.btn-outline-primary:hover {
background: #007bff;
color: white;
}
/* 첨부파일 */
.attached-files {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
}
.files-title {
font-size: 16px;
margin: 0 0 15px;
color: #495057;
}
.file-list {
list-style: none;
padding: 0;
margin: 0;
}
.file-item {
margin-bottom: 15px;
}
.image-file img {
max-width: 200px;
max-height: 150px;
border-radius: 4px;
margin-bottom: 5px;
}
.file-download {
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
text-decoration: none;
color: #333;
transition: all 0.2s;
}
.file-download:hover {
background: #f8f9fa;
border-color: #007bff;
}
/* 글 내용 */
.document-content {
margin: 30px 0;
line-height: 1.8;
font-size: 15px;
}
.content-body {
min-height: 200px;
}
.content-body img {
max-width: 100%;
height: auto;
border-radius: 4px;
margin: 10px 0;
}
/* 추천 영역 */
.vote-section {
text-align: center;
padding: 30px 0;
border-top: 1px solid #eee;
border-bottom: 1px solid #eee;
margin: 30px 0;
}
.vote-buttons {
display: flex;
justify-content: center;
gap: 20px;
margin-bottom: 20px;
}
.btn-vote {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding: 15px 20px;
background: white;
border: 2px solid #ddd;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
min-width: 80px;
}
.btn-vote-up:hover {
border-color: #28a745;
color: #28a745;
}
.btn-vote-down:hover {
border-color: #dc3545;
color: #dc3545;
}
.vote-bar {
display: flex;
height: 20px;
background: #f8f9fa;
border-radius: 10px;
overflow: hidden;
margin-bottom: 10px;
}
.vote-bar-up {
background: #28a745;
}
.vote-bar-down {
background: #dc3545;
}
/* 이전/다음 글 */
.document-navigation {
border: 1px solid #eee;
border-radius: 8px;
overflow: hidden;
margin: 30px 0;
}
.nav-item {
display: flex;
align-items: center;
padding: 15px 20px;
border-bottom: 1px solid #eee;
}
.nav-item:last-child {
border-bottom: none;
}
.nav-label {
display: flex;
align-items: center;
gap: 5px;
min-width: 80px;
font-size: 13px;
color: #666;
}
.nav-content {
flex: 1;
margin-left: 20px;
}
.nav-title {
display: block;
color: #333;
text-decoration: none;
font-weight: 500;
margin-bottom: 5px;
}
.nav-title:hover {
color: #007bff;
}
.nav-meta {
font-size: 12px;
color: #999;
}
.nav-meta span {
margin-right: 10px;
}
/* 모바일 스타일 */
@media (max-width: 768px) {
.document-title {
font-size: 20px;
}
.document-meta {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.meta-right {
display: flex;
gap: 15px;
}
.document-tools {
flex-direction: column;
}
.author-tools,
.user-tools {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.btn {
font-size: 12px;
padding: 6px 12px;
}
.vote-buttons {
gap: 10px;
}
.btn-vote {
min-width: 60px;
padding: 10px 15px;
}
.nav-item {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.nav-content {
margin-left: 0;
}
}
/* 갤러리 뷰 스타일 */
.gallery-view {
max-width: 800px;
margin: 0 auto;
}
.image-viewer {
margin-bottom: 30px;
}
.main-image {
position: relative;
text-align: center;
margin-bottom: 15px;
}
.main-image img {
max-width: 100%;
max-height: 600px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.image-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 100%;
display: flex;
justify-content: space-between;
padding: 0 20px;
pointer-events: none;
}
.nav-btn {
background: rgba(0,0,0,0.5);
color: white;
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
cursor: pointer;
pointer-events: auto;
transition: all 0.3s;
}
.nav-btn:hover {
background: rgba(0,0,0,0.7);
}
.image-counter {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0,0,0,0.7);
color: white;
padding: 5px 10px;
border-radius: 15px;
font-size: 12px;
}
.thumbnail-list {
display: flex;
gap: 10px;
overflow-x: auto;
padding: 10px 0;
}
.thumbnail-item {
flex-shrink: 0;
}
.thumbnail {
width: 80px;
height: 60px;
object-fit: cover;
border-radius: 4px;
cursor: pointer;
opacity: 0.6;
transition: all 0.3s;
}
.thumbnail.active,
.thumbnail:hover {
opacity: 1;
border: 2px solid #007bff;
}