라이믹스 스킨 제작 완전 가이드¶
XE 스킨 제작 매뉴얼을 라이믹스 환경에 맞게 업데이트한 종합 가이드입니다.
📋 목차¶
1. 스킨 제작 개요¶
1.1 라이믹스 스킨이란¶
스킨은 라이믹스로 생성한 데이터를 사용자 화면에 표현하는 양식입니다. 모듈, 레이아웃, 위젯에는 스킨이 하나 이상 있습니다.
스킨의 저장 위치:
- 모듈 스킨: /modules/{모듈명}/skins/{스킨명}/
- 레이아웃 스킨: /layouts/{레이아웃명}/
- 위젯 스킨: /widgets/{위젯명}/skins/{스킨명}/
1.2 스킨 제작에 필요한 기술¶
HTML5 (HyperText Markup Language)¶
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>라이믹스 사이트</title>
</head>
<body>
<main>{$content}</main>
</body>
</html>
CSS3 (Cascading Style Sheets)¶
/* 반응형 디자인 */
@media (max-width: 768px) {
.container {
padding: 10px;
}
}
/* Flexbox 레이아웃 */
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
자바스크립트와 jQuery¶
라이믹스는 여전히 jQuery를 내장하고 있어 쉽게 활용할 수 있습니다.
jQuery(document).ready(function($) {
$('.menu-toggle').on('click', function() {
$('.nav-menu').toggleClass('active');
});
});
라이믹스 템플릿 문법¶
XE 템플릿 문법과 호환되며, 추가 기능이 제공됩니다.
2. 스킨 제작의 기초¶
2.1 HTML5 기준¶
2.1.1 시맨틱 HTML 사용¶
<header>
<nav>
<ul>
<li><a href="/">홈</a></li>
<li><a href="/about">소개</a></li>
</ul>
</nav>
</header>
<main>
<article>
<h1>게시글 제목</h1>
<section>내용</section>
</article>
</main>
<footer>
<p>© 2024 My Site</p>
</footer>
2.1.2 접근성 고려¶
<!-- ARIA 레이블 사용 -->
<button aria-label="메뉴 열기" class="menu-toggle">
<span class="sr-only">메뉴</span>
☰
</button>
<!-- 스킵 네비게이션 -->
<a href="#main-content" class="skip-to-content">본문 바로가기</a>
2.2 현대적 CSS¶
2.2.1 CSS Grid와 Flexbox¶
/* Grid 레이아웃 */
.layout {
display: grid;
grid-template-areas:
"header header"
"sidebar main"
"footer footer";
grid-template-columns: 250px 1fr;
min-height: 100vh;
}
.header { grid-area: header; }
.sidebar { grid-area: sidebar; }
.main { grid-area: main; }
.footer { grid-area: footer; }
/* Flexbox 네비게이션 */
.nav {
display: flex;
gap: 1rem;
}
2.2.2 CSS 변수 활용¶
:root {
--primary-color: #007bff;
--secondary-color: #6c757d;
--border-radius: 0.375rem;
--spacing-unit: 1rem;
}
.btn {
background-color: var(--primary-color);
border-radius: var(--border-radius);
padding: var(--spacing-unit);
}
2.3 라이믹스 템플릿 문법¶
2.3.1 기본 변수¶
<!-- 사용자 정보 -->
<div cond="$is_logged">
안녕하세요, {$logged_info->nick_name}님!
</div>
<!-- 모듈 정보 -->
<h1>{$module_info->browser_title}</h1>
<p>{$module_info->description}</p>
2.3.2 조건문 (향상된 문법)¶
<!-- 기존 XE 문법 -->
<!--@if($is_logged)-->
<p>로그인 상태</p>
<!--@end-->
<!-- 라이믹스 권장 문법 -->
<p cond="$is_logged">로그인 상태</p>
<!-- 복합 조건 -->
<div cond="$is_logged && $logged_info->is_admin == 'Y'">
관리자 메뉴
</div>
2.3.3 반복문¶
<!-- 메뉴 출력 -->
<nav>
<ul>
<li loop="$menu_list=>$menu" class="active"|cond="$menu['selected']">
<a href="{$menu['href']}">{$menu['text']}</a>
</li>
</ul>
</nav>
<!-- 게시물 목록 -->
<div class="post-grid">
<article loop="$document_list=>$document" class="post-card">
<h2><a href="{getUrl('document_srl', $document->document_srl)}">{$document->getTitle()}</a></h2>
<p>{cut_str($document->getContentText(), 100, '...')}</p>
</article>
</div>
2.3.4 파일 참조 (라이믹스 스타일)¶
<!-- CSS 파일 -->
<load target="./css/layout.css" />
<load target="./css/responsive.css" media="screen" />
<!-- JS 파일 -->
<load target="./js/common.js" />
<load target="./js/mobile.js" type="body" />
<!-- 조건부 로딩 -->
<load target="./css/ie-fix.css" targetie="lte IE 9" />
3. 레이아웃 스킨 만들기¶
3.1 레이아웃 스킨 구조¶
3.1.1 디렉토리 구조¶
/layouts/my_layout/
├── conf/
│ └── info.xml
├── css/
│ ├── layout.css
│ └── responsive.css
├── js/
│ └── layout.js
├── images/
│ └── logo.png
├── layout.html
└── thumbnail.png
3.1.2 info.xml 작성¶
<?xml version="1.0" encoding="UTF-8"?>
<layout version="0.2">
<title xml:lang="ko">모던 레이아웃</title>
<title xml:lang="en">Modern Layout</title>
<description xml:lang="ko">라이믹스용 현대적 반응형 레이아웃</description>
<description xml:lang="en">Modern responsive layout for Rhymix</description>
<version>2.0.0</version>
<date>2024-01-01</date>
<author email_address="developer@example.com">
<name xml:lang="ko">개발자</name>
<name xml:lang="en">Developer</name>
</author>
<license>MIT</license>
<!-- 메뉴 정의 -->
<menus>
<menu name="main_menu" maxdepth="3" default="true">
<title xml:lang="ko">주 메뉴</title>
<title xml:lang="en">Main Menu</title>
</menu>
<menu name="footer_menu" maxdepth="1">
<title xml:lang="ko">푸터 메뉴</title>
<title xml:lang="en">Footer Menu</title>
</menu>
</menus>
<!-- 설정 변수 -->
<extra_vars>
<var name="color_scheme" type="select">
<title xml:lang="ko">색상 테마</title>
<description xml:lang="ko">사이트의 기본 색상 테마를 선택하세요</description>
<options value="blue">블루</options>
<options value="green">그린</options>
<options value="purple">퍼플</options>
</var>
<var name="layout_width" type="select">
<title xml:lang="ko">레이아웃 너비</title>
<options value="fluid">유동형</options>
<options value="fixed">고정형</options>
</var>
<var name="show_sidebar" type="select">
<title xml:lang="ko">사이드바 표시</title>
<options value="Y">표시</options>
<options value="N">숨김</options>
</var>
</extra_vars>
</layout>
3.2 현대적 레이아웃 HTML¶
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!-- SEO 메타태그 -->
<meta name="description" content="{$module_info->description}">
<meta name="keywords" content="{$module_info->keywords}">
<!-- 오픈그래프 -->
<meta property="og:title" content="{$module_info->browser_title}">
<meta property="og:description" content="{$module_info->description}">
<meta property="og:type" content="website">
<meta property="og:url" content="{$current_url}">
<title>{$module_info->browser_title}</title>
<!-- CSS 로드 -->
<load target="./css/layout.css" />
<load target="./css/theme-{$layout_info->color_scheme|default:'blue'}.css" />
<load target="./css/responsive.css" />
<!-- JS 로드 -->
<load target="./js/layout.js" type="body" />
</head>
<body class="layout-{$layout_info->layout_width|default:'fluid'} theme-{$layout_info->color_scheme|default:'blue'}">
<!-- 스킵 네비게이션 -->
<a href="#main-content" class="skip-to-content">본문 바로가기</a>
<!-- 헤더 -->
<header class="site-header" role="banner">
<div class="container">
<!-- 로고 -->
<div class="site-branding">
<h1 class="site-title">
<a href="{getUrl()}">{$module_info->browser_title}</a>
</h1>
<p class="site-description" cond="$module_info->description">
{$module_info->description}
</p>
</div>
<!-- 주 메뉴 -->
<nav class="main-navigation" role="navigation" aria-label="주 메뉴">
<button class="menu-toggle" aria-controls="primary-menu" aria-expanded="false">
<span class="sr-only">메뉴</span>
<span class="hamburger"></span>
</button>
<ul class="menu" id="primary-menu">
<li loop="$main_menu->list=>$menu1" class="menu-item {$menu1['class']}">
<a href="{$menu1['href']}"
target="_blank"|cond="$menu1['open_window']=='Y'"
class="menu-link">
{$menu1['text']}
</a>
<!-- 서브메뉴 -->
<ul cond="$menu1['list']" class="sub-menu">
<li loop="$menu1['list']=>$menu2" class="sub-menu-item {$menu2['class']}">
<a href="{$menu2['href']}"
target="_blank"|cond="$menu2['open_window']=='Y'"
class="sub-menu-link">
{$menu2['text']}
</a>
</li>
</ul>
</li>
</ul>
</nav>
<!-- 사용자 메뉴 -->
<div class="user-menu">
<!-- 검색 -->
<form action="{getUrl()}" method="get" class="search-form" role="search">
<input type="hidden" name="mid" value="{$mid}">
<input type="hidden" name="act" value="IS">
<label class="sr-only" for="search-input">검색</label>
<input type="search"
id="search-input"
name="is_keyword"
value="{$is_keyword}"
placeholder="검색어 입력"
class="search-input">
<button type="submit" class="search-submit" aria-label="검색 실행">
🔍
</button>
</form>
<!-- 로그인 정보 -->
<div class="account-info">
<div cond="$is_logged" class="logged-in">
<a href="{getUrl('act','dispMemberInfo')}" class="user-profile">
<img cond="$logged_info->profile_image"
src="{$logged_info->profile_image}"
alt="{$logged_info->nick_name} 프로필"
class="profile-image">
<span class="user-name">{$logged_info->nick_name}</span>
</a>
<a href="{getUrl('act','dispMemberLogout')}" class="logout-link">로그아웃</a>
</div>
<div cond="!$is_logged" class="logged-out">
<a href="{getUrl('act','dispMemberLoginForm')}" class="login-link">로그인</a>
<a href="{getUrl('act','dispMemberSignUpForm')}" class="signup-link">회원가입</a>
</div>
</div>
</div>
</div>
</header>
<!-- 메인 콘텐츠 -->
<div class="site-content">
<div class="container">
<main id="main-content" class="content-area" role="main">
{$content}
</main>
<!-- 사이드바 -->
<aside cond="$layout_info->show_sidebar != 'N'" class="sidebar" role="complementary">
<!-- 위젯 영역 -->
<div class="widget-area">
<!-- 최근 게시물 위젯 -->
<div class="widget recent-posts">
<h3 class="widget-title">최근 게시물</h3>
<div class="widget-content">
<!-- 위젯 내용 -->
</div>
</div>
<!-- 인기 태그 위젯 -->
<div class="widget popular-tags">
<h3 class="widget-title">인기 태그</h3>
<div class="widget-content">
<!-- 위젯 내용 -->
</div>
</div>
</div>
</aside>
</div>
</div>
<!-- 푸터 -->
<footer class="site-footer" role="contentinfo">
<div class="container">
<!-- 푸터 메뉴 -->
<nav class="footer-navigation" cond="$footer_menu->list">
<ul class="footer-menu">
<li loop="$footer_menu->list=>$menu" class="footer-menu-item">
<a href="{$menu['href']}" class="footer-menu-link">
{$menu['text']}
</a>
</li>
</ul>
</nav>
<!-- 저작권 -->
<div class="site-info">
<p>© {date('Y')} {$module_info->browser_title}. All rights reserved.</p>
<p>Powered by <a href="https://rhymix.org">Rhymix</a></p>
</div>
</div>
</footer>
</body>
</html>
3.3 반응형 CSS¶
/* CSS 변수 */
:root {
--primary-color: #007bff;
--secondary-color: #6c757d;
--success-color: #28a745;
--warning-color: #ffc107;
--danger-color: #dc3545;
--font-size-base: 1rem;
--font-size-lg: 1.25rem;
--font-size-sm: 0.875rem;
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 3rem;
--border-radius: 0.375rem;
--box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--container-max-width: 1200px;
--header-height: 70px;
}
/* 기본 스타일 */
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: var(--container-max-width);
margin: 0 auto;
padding: 0 var(--spacing-md);
}
/* 스킵 네비게이션 */
.skip-to-content {
position: absolute;
top: -40px;
left: 6px;
background: var(--primary-color);
color: white;
padding: 8px;
text-decoration: none;
border-radius: var(--border-radius);
z-index: 1000;
}
.skip-to-content:focus {
top: 6px;
}
/* 헤더 */
.site-header {
background: white;
box-shadow: var(--box-shadow);
position: sticky;
top: 0;
z-index: 100;
height: var(--header-height);
}
.site-header .container {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
}
.site-branding {
flex: 0 0 auto;
}
.site-title {
margin: 0;
font-size: var(--font-size-lg);
}
.site-title a {
text-decoration: none;
color: var(--primary-color);
}
/* 네비게이션 */
.main-navigation {
flex: 1;
margin: 0 var(--spacing-lg);
}
.menu-toggle {
display: none;
background: none;
border: none;
cursor: pointer;
padding: var(--spacing-sm);
}
.menu {
display: flex;
list-style: none;
margin: 0;
padding: 0;
gap: var(--spacing-md);
}
.menu-item {
position: relative;
}
.menu-link {
display: block;
padding: var(--spacing-sm) var(--spacing-md);
text-decoration: none;
color: #333;
border-radius: var(--border-radius);
transition: background-color 0.3s ease;
}
.menu-link:hover,
.menu-item.active .menu-link {
background-color: var(--primary-color);
color: white;
}
/* 서브메뉴 */
.sub-menu {
position: absolute;
top: 100%;
left: 0;
background: white;
box-shadow: var(--box-shadow);
border-radius: var(--border-radius);
min-width: 200px;
list-style: none;
margin: 0;
padding: var(--spacing-sm);
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all 0.3s ease;
}
.menu-item:hover .sub-menu {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
/* 사용자 메뉴 */
.user-menu {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.search-form {
display: flex;
align-items: center;
background: #f8f9fa;
border-radius: var(--border-radius);
padding: var(--spacing-xs);
}
.search-input {
border: none;
background: none;
padding: var(--spacing-sm);
outline: none;
width: 200px;
}
.search-submit {
background: none;
border: none;
cursor: pointer;
padding: var(--spacing-sm);
}
/* 메인 콘텐츠 */
.site-content {
min-height: calc(100vh - var(--header-height) - 100px);
padding: var(--spacing-lg) 0;
}
.site-content .container {
display: grid;
grid-template-columns: 1fr 300px;
gap: var(--spacing-xl);
}
.layout-fluid .site-content .container {
max-width: 100%;
}
/* 사이드바 숨김 시 */
.site-content .container:not(:has(.sidebar)) {
grid-template-columns: 1fr;
}
/* 위젯 */
.widget {
background: white;
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
}
.widget-title {
margin: 0 0 var(--spacing-md) 0;
font-size: var(--font-size-lg);
color: var(--primary-color);
}
/* 푸터 */
.site-footer {
background: #f8f9fa;
padding: var(--spacing-xl) 0;
border-top: 1px solid #dee2e6;
}
.footer-menu {
display: flex;
list-style: none;
margin: 0 0 var(--spacing-lg) 0;
padding: 0;
gap: var(--spacing-lg);
justify-content: center;
}
.footer-menu-link {
text-decoration: none;
color: var(--secondary-color);
}
.site-info {
text-align: center;
color: var(--secondary-color);
font-size: var(--font-size-sm);
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.site-header .container {
flex-wrap: wrap;
}
.menu-toggle {
display: block;
}
.main-navigation {
flex: 1 1 100%;
order: 3;
}
.menu {
display: none;
flex-direction: column;
width: 100%;
background: white;
box-shadow: var(--box-shadow);
border-radius: var(--border-radius);
padding: var(--spacing-md);
margin-top: var(--spacing-md);
}
.menu.active {
display: flex;
}
.sub-menu {
position: static;
opacity: 1;
visibility: visible;
transform: none;
box-shadow: none;
background: #f8f9fa;
margin-top: var(--spacing-sm);
}
.site-content .container {
grid-template-columns: 1fr;
}
.sidebar {
order: -1;
}
.search-input {
width: 150px;
}
.footer-menu {
flex-direction: column;
align-items: center;
gap: var(--spacing-sm);
}
}
@media (max-width: 480px) {
.container {
padding: 0 var(--spacing-sm);
}
.user-menu {
flex-direction: column;
align-items: stretch;
gap: var(--spacing-sm);
}
.search-form {
width: 100%;
}
.search-input {
width: 100%;
}
}
/* 테마별 색상 */
.theme-blue {
--primary-color: #007bff;
}
.theme-green {
--primary-color: #28a745;
}
.theme-purple {
--primary-color: #6f42c1;
}
/* 접근성 */
.sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
/* 포커스 스타일 */
:focus {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
a:focus,
button:focus {
outline-color: var(--primary-color);
}
4. 게시판 스킨 만들기¶
4.1 라이믹스 게시판 스킨 구조¶
4.1.1 필수 파일 목록¶
/modules/board/skins/modern_board/
├── conf/
│ └── skin.xml
├── css/
│ ├── board.css
│ └── responsive.css
├── js/
│ └── board.js
├── images/
│ └── icons/
├── list.html
├── view.html
├── write_form.html
├── comment_form.html
├── delete_form.html
├── delete_comment_form.html
├── input_password_form.html
├── message.html
├── _header.html
└── _footer.html
4.1.2 skin.xml 작성¶
<?xml version="1.0" encoding="UTF-8"?>
<skin version="0.2">
<title xml:lang="ko">모던 게시판</title>
<title xml:lang="en">Modern Board</title>
<description xml:lang="ko">라이믹스용 현대적 게시판 스킨</description>
<description xml:lang="en">Modern board skin for Rhymix</description>
<version>2.0.0</version>
<date>2024-01-01</date>
<author email_address="developer@example.com">
<name xml:lang="ko">개발자</name>
<name xml:lang="en">Developer</name>
</author>
<license>MIT</license>
<extra_vars>
<var name="board_style" type="select">
<title xml:lang="ko">게시판 스타일</title>
<description xml:lang="ko">게시판의 표시 스타일을 선택하세요</description>
<options value="list">목록형</options>
<options value="card">카드형</options>
<options value="gallery">갤러리형</options>
</var>
<var name="show_category" type="select">
<title xml:lang="ko">카테고리 표시</title>
<options value="Y">표시</options>
<options value="N">숨김</options>
</var>
<var name="posts_per_page" type="text">
<title xml:lang="ko">페이지당 게시물 수</title>
<description xml:lang="ko">한 페이지에 표시할 게시물 수 (기본값: 20)</description>
</var>
<var name="thumbnail_width" type="text">
<title xml:lang="ko">썸네일 너비</title>
<description xml:lang="ko">갤러리형일 때 썸네일 너비 (기본값: 200)</description>
</var>
</extra_vars>
</skin>
4.2 현대적 게시판 목록 (list.html)¶
<load target="./css/board.css" />
<load target="./css/responsive.css" />
<load target="./js/board.js" type="body" />
<div class="modern-board board-style-{$skin_info->board_style|default:'list'}">
<!-- 게시판 헤더 -->
<div class="board-header" cond="$module_info->title || $module_info->description">
<div class="board-title-area">
<h1 class="board-title">
<a href="{getUrl('', 'mid', $mid)}">{$module_info->title}</a>
</h1>
<p cond="$module_info->description" class="board-description">
{$module_info->description}
</p>
</div>
<!-- 게시판 통계 -->
<div class="board-stats">
<div class="stat-item">
<span class="stat-label">전체 게시물</span>
<span class="stat-value">{number_format($total_count)}</span>
</div>
<div class="stat-item" cond="$page > 1">
<span class="stat-label">현재 페이지</span>
<span class="stat-value">{$page}/{$page_navigation->last_page}</span>
</div>
</div>
</div>
<!-- 카테고리 및 필터 -->
<div class="board-filters" cond="$category_list || $grant->list">
<!-- 카테고리 -->
<div cond="$category_list && $skin_info->show_category != 'N'" class="category-filter">
<button type="button" class="category-btn {!$category ? 'active' : ''}"
onclick="location.href='{getUrl('category', '')}'">
전체
</button>
<button loop="$category_list => $category_item"
type="button"
class="category-btn {$category == $category_item->category_srl ? 'active' : ''}"
onclick="location.href='{getUrl('category', $category_item->category_srl)}'">
{$category_item->title}
<span class="category-count">({number_format($category_item->count)})</span>
</button>
</div>
<!-- 정렬 옵션 -->
<div class="sort-options">
<label for="sort-select" class="sr-only">정렬 방식</label>
<select id="sort-select" onchange="changeSortOrder(this.value)" class="sort-select">
<option value="regdate" {$order_type == 'regdate' ? 'selected' : ''}>최신순</option>
<option value="title" {$order_type == 'title' ? 'selected' : ''}>제목순</option>
<option value="readed_count" {$order_type == 'readed_count' ? 'selected' : ''}>조회순</option>
<option value="voted_count" {$order_type == 'voted_count' ? 'selected' : ''}>추천순</option>
<option value="comment_count" {$order_type == 'comment_count' ? 'selected' : ''}>댓글순</option>
</select>
</div>
<!-- 보기 방식 선택 -->
<div class="view-toggle">
<button type="button"
class="view-btn {$skin_info->board_style == 'list' ? 'active' : ''}"
onclick="changeViewStyle('list')"
aria-label="목록형 보기">
📄
</button>
<button type="button"
class="view-btn {$skin_info->board_style == 'card' ? 'active' : ''}"
onclick="changeViewStyle('card')"
aria-label="카드형 보기">
📋
</button>
<button type="button"
class="view-btn {$skin_info->board_style == 'gallery' ? 'active' : ''}"
onclick="changeViewStyle('gallery')"
aria-label="갤러리형 보기">
🖼️
</button>
</div>
</div>
<!-- 게시물이 없을 때 -->
<div cond="!$document_list && !$notice_list" class="empty-board">
<div class="empty-icon">📝</div>
<h3>등록된 게시물이 없습니다</h3>
<p>첫 번째 게시물을 작성해보세요!</p>
<a cond="$grant->write_document"
href="{getUrl('act', 'dispBoardWrite')}"
class="btn btn-primary">
글쓰기
</a>
</div>
<!-- 공지사항 -->
<div cond="$notice_list" class="notice-list">
<h2 class="notice-title">📌 공지사항</h2>
<div class="notice-items">
<article loop="$notice_list => $document" class="notice-item">
<div class="post-category" cond="$document->get('category_srl')">
{$category_list[$document->get('category_srl')]->title}
</div>
<h3 class="post-title">
<a href="{getUrl('document_srl', $document->document_srl)}">
{$document->getTitle()}
<span cond="$document->getCommentCount()" class="comment-count">
[{$document->getCommentCount()}]
</span>
</a>
</h3>
<div class="post-meta">
<span class="author">{$document->getNickName()}</span>
<time class="date" datetime="{$document->getRegdate('c')}">
{$document->getRegdate('Y.m.d')}
</time>
<span class="views">조회 {number_format($document->get('readed_count'))}</span>
</div>
</article>
</div>
</div>
<!-- 일반 게시물 목록 -->
<div cond="$document_list" class="document-list">
<!-- 목록형 -->
<div cond="$skin_info->board_style == 'list'" class="list-view">
<div class="list-header">
<div class="col-number">번호</div>
<div class="col-category" cond="$category_list && $skin_info->show_category != 'N'">분류</div>
<div class="col-title">제목</div>
<div class="col-author">작성자</div>
<div class="col-date">작성일</div>
<div class="col-views">조회</div>
</div>
<article loop="$document_list => $no => $document" class="list-item">
<div class="col-number">{$no}</div>
<div class="col-category" cond="$category_list && $skin_info->show_category != 'N'">
<span cond="$document->get('category_srl')" class="category-tag">
{$category_list[$document->get('category_srl')]->title}
</span>
</div>
<div class="col-title">
<h3>
<a href="{getUrl('document_srl', $document->document_srl)}">
{$document->getTitle()}
<span cond="$document->getCommentCount()" class="comment-count">
[{$document->getCommentCount()}]
</span>
</a>
</h3>
<div cond="$document->getThumbnail()" class="post-thumbnail">
<img src="{$document->getThumbnail(50, 50)}" alt="" loading="lazy">
</div>
</div>
<div class="col-author">
<span class="author-name">{$document->getNickName()}</span>
<img cond="$document->getProfileImage()"
src="{$document->getProfileImage()}"
alt="{$document->getNickName()} 프로필"
class="author-avatar">
</div>
<div class="col-date">
<time datetime="{$document->getRegdate('c')}">
{$document->getRegdate('Y.m.d')}
</time>
</div>
<div class="col-views">{number_format($document->get('readed_count'))}</div>
</article>
</div>
<!-- 카드형 -->
<div cond="$skin_info->board_style == 'card'" class="card-view">
<article loop="$document_list => $document" class="card-item">
<div cond="$document->getThumbnail()" class="card-thumbnail">
<img src="{$document->getThumbnail(300, 200)}" alt="" loading="lazy">
</div>
<div class="card-content">
<div class="card-header">
<div cond="$document->get('category_srl')" class="card-category">
{$category_list[$document->get('category_srl')]->title}
</div>
<time class="card-date" datetime="{$document->getRegdate('c')}">
{$document->getRegdate('Y.m.d')}
</time>
</div>
<h3 class="card-title">
<a href="{getUrl('document_srl', $document->document_srl)}">
{$document->getTitle()}
</a>
</h3>
<p cond="$document->getContentText()" class="card-excerpt">
{cut_str(strip_tags($document->getContentText()), 100, '...')}
</p>
<div class="card-footer">
<div class="card-author">
<img cond="$document->getProfileImage()"
src="{$document->getProfileImage()}"
alt="{$document->getNickName()} 프로필"
class="author-avatar">
<span class="author-name">{$document->getNickName()}</span>
</div>
<div class="card-stats">
<span class="views">👁 {number_format($document->get('readed_count'))}</span>
<span cond="$document->getCommentCount()" class="comments">
💬 {$document->getCommentCount()}
</span>
<span cond="$document->get('voted_count')" class="likes">
❤️ {$document->get('voted_count')}
</span>
</div>
</div>
</div>
</article>
</div>
<!-- 갤러리형 -->
<div cond="$skin_info->board_style == 'gallery'" class="gallery-view">
<article loop="$document_list => $document" class="gallery-item">
<a href="{getUrl('document_srl', $document->document_srl)}" class="gallery-link">
<div class="gallery-thumbnail">
<img cond="$document->getThumbnail()"
src="{$document->getThumbnail($skin_info->thumbnail_width|default:200, $skin_info->thumbnail_width|default:200)}"
alt="{$document->getTitle()}"
loading="lazy">
<div cond="!$document->getThumbnail()" class="no-image">
🖼️
</div>
<!-- 오버레이 정보 -->
<div class="gallery-overlay">
<h3 class="gallery-title">{cut_str($document->getTitle(), 30, '...')}</h3>
<div class="gallery-stats">
<span>👁 {$document->get('readed_count')}</span>
<span cond="$document->getCommentCount()">💬 {$document->getCommentCount()}</span>
</div>
</div>
</div>
</a>
</article>
</div>
</div>
<!-- 페이지네이션 -->
<nav cond="$page_navigation" class="pagination" aria-label="페이지 네비게이션">
<a cond="$page_navigation->first_page < $page"
href="{getUrl('page', $page_navigation->first_page)}"
class="page-link page-first"
aria-label="첫 페이지">
⏮️
</a>
<a cond="$page_navigation->prev_page"
href="{getUrl('page', $page_navigation->prev_page)}"
class="page-link page-prev"
aria-label="이전 페이지">
◀️
</a>
<span loop="$page_navigation->page_list => $page_no" class="page-item">
<strong cond="$page == $page_no" class="page-current" aria-current="page">
{$page_no}
</strong>
<a cond="$page != $page_no"
href="{getUrl('page', $page_no)}"
class="page-link">
{$page_no}
</a>
</span>
<a cond="$page_navigation->next_page"
href="{getUrl('page', $page_navigation->next_page)}"
class="page-link page-next"
aria-label="다음 페이지">
▶️
</a>
<a cond="$page_navigation->last_page > $page"
href="{getUrl('page', $page_navigation->last_page)}"
class="page-link page-last"
aria-label="마지막 페이지">
⏭️
</a>
</nav>
<!-- 하단 액션 -->
<div class="board-actions">
<div class="board-search">
<form action="{getUrl()}" method="get" class="search-form" role="search">
<input type="hidden" name="mid" value="{$mid}">
<input type="hidden" name="category" value="{$category}">
<label for="search-target" class="sr-only">검색 대상</label>
<select id="search-target" name="search_target" class="search-target">
<option value="title_content" {$search_target == 'title_content' ? 'selected' : ''}>
제목+내용
</option>
<option value="title" {$search_target == 'title' ? 'selected' : ''}>
제목
</option>
<option value="content" {$search_target == 'content' ? 'selected' : ''}>
내용
</option>
<option value="nick_name" {$search_target == 'nick_name' ? 'selected' : ''}>
작성자
</option>
<option value="tags" {$search_target == 'tags' ? 'selected' : ''}>
태그
</option>
</select>
<label for="search-keyword" class="sr-only">검색어</label>
<input type="search"
id="search-keyword"
name="search_keyword"
value="{htmlspecialchars($search_keyword)}"
placeholder="검색어를 입력하세요"
class="search-input">
<button type="submit" class="search-submit" aria-label="검색 실행">
🔍
</button>
</form>
</div>
<div class="write-actions">
<a cond="$grant->write_document"
href="{getUrl('act', 'dispBoardWrite')}"
class="btn btn-primary write-btn">
✏️ 글쓰기
</a>
</div>
</div>
</div>
<script>
// 정렬 변경
function changeSortOrder(orderType) {
location.href = getUrl('order_type', orderType, 'page', '');
}
// 보기 방식 변경
function changeViewStyle(style) {
// AJAX로 스킨 설정 변경 또는 쿠키 저장
document.cookie = 'board_style=' + style + '; path=/';
location.reload();
}
// URL 생성 함수 (기존 파라미터 유지)
function getUrl(key, value, ...args) {
const url = new URL(location.href);
url.searchParams.set(key, value);
// 추가 파라미터 설정
for (let i = 0; i < args.length; i += 2) {
if (args[i + 1] === '') {
url.searchParams.delete(args[i]);
} else {
url.searchParams.set(args[i], args[i + 1]);
}
}
return url.toString();
}
</script>
5. 라이믹스 업데이트 사항¶
5.1 XE 대비 개선점¶
5.1.1 성능 향상¶
- PHP 8+ 지원: 더 빠른 실행 속도
- 템플릿 캐싱: 컴파일된 템플릿 캐시로 성능 향상
- 최적화된 쿼리: 데이터베이스 성능 개선
5.1.2 보안 강화¶
- CSRF 토큰: 자동 CSRF 보호
- XSS 방지: 개선된 입력 필터링
- SQL 인젝션 방지: 강화된 쿼리 보안
5.1.3 현대적 웹 표준¶
- HTML5 완전 지원: 시맨틱 마크업
- CSS3 활용: Flexbox, Grid 레이아웃
- 반응형 웹: 모바일 퍼스트 접근
5.2 마이그레이션 가이드¶
5.2.1 XE 스킨 호환성¶
<!-- XE 1.x 문법 (여전히 지원됨) -->
<!--@if($is_logged)-->
<p>환영합니다!</p>
<!--@end-->
<!-- 라이믹스 권장 문법 -->
<p cond="$is_logged">환영합니다!</p>
5.2.2 새로운 템플릿 기능¶
<!-- 조건부 클래스 -->
<div class="menu-item {$selected ? 'active' : ''}">메뉴</div>
<!-- 필터 함수 -->
<p>{$content|strip_tags|cut_str:100}</p>
<!-- 기본값 설정 -->
<h1>{$title|default:'제목 없음'}</h1>
<!-- 배열 접근 -->
<span>{$data['key']}</span>
5.2.3 개선된 보안 기능¶
<!-- 자동 CSRF 토큰 -->
<form action="./" method="post">
{$security_token}
<!-- 폼 내용 -->
</form>
<!-- XSS 방지 -->
<p>{htmlspecialchars($user_input)}</p>
<!-- 안전한 URL 생성 -->
<a href="{getUrl('mid', $mid, 'document_srl', $document_srl)}">링크</a>
5.3 개발 도구¶
5.3.1 디버깅 기능¶
// 개발 모드에서 템플릿 디버깅
{@debug($variable)}
// 성능 프로파일링
{@profile_start('template_section')}
<!-- 템플릿 코드 -->
{@profile_end('template_section')}
5.3.2 개발자 도구¶
# 라이믹스 CLI 도구 (가상의 예시)
rhymix make:skin board my_board_skin
rhymix make:layout my_layout
rhymix cache:clear
rhymix optimize:templates
참고 자료¶
공식 문서¶
웹 표준¶
접근성¶
라이선스: MIT
최종 수정: 2024년 1월 1일