Panduan lengkap membangun tampilan cover forum dengan layout 2 kolom (gambar + info di kiri, daftar thread terbaru di kanan) — mirip gaya Invision Community / IPS, tapi natif di XenForo. Tanpa beli style komersial.
Estimasi waktu: 30–45 menit Tingkat: Menengah (perlu nyaman edit file PHP via FTP dan template XenForo)
Konsep tampilan akhir: cover di kiri, daftar thread terbaru di kanan, border atas warna berbeda per kategori.
Lihat demo: https://forum.intutekno.com/
1. Yang Akan Dibuat
Setiap forum di halaman utama akan menampilkan:
Kolom kiri (30% lebar):
Cover image 3:2 (SVG/WebP/JPG)
Judul forum (link ke forum)
Deskripsi forum
Statistik (jumlah threads & messages)
Kolom kanan (70% lebar):
5 thread terbaru dengan avatar, judul, username, tanggal, jumlah replies
Atau ikon + pesan "Belum ada thread" kalau forum masih kosong
Bonus: Border-top tiap card forum bisa diwarnai berbeda berdasarkan kategori induk-nya.
2. Prasyarat
XenForo 2.3.x (panduan ini diuji di 2.3.10)
Akses AdminCP
Akses FTP/File Manager ke server (untuk upload PHP files)
Akses cPanel atau setara untuk backup database (opsional tapi disarankan)
Pemahaman dasar HTML/CSS akan membantu
3. Backup Dulu
Sebelum apa pun, lakukan dua backup:
Backup database:
cPanel → phpMyAdmin → pilih database forum → Export → Quick → Go
Backup file:
File Manager cPanel → kompres folder forum jadi
.zip→ download
Kalau ada yang salah saat ngoprek, kita bisa balik ke kondisi awal.
4. Aktifkan Development Mode
Buka /src/config.php lewat File Manager atau FTP. Tambahkan dua baris ini di paling bawah:
$config['development']['enabled'] = true;
$config['development']['defaultAddOn'] = 'Intutekno/CoverForum';
Ganti Intutekno/CoverForum sesuai nama yang Anda inginkan (format: VendorName/AddOnName). Setelah save, refresh AdminCP — menu Development dan Add-ons akan muncul di sidebar.
5. Bikin Add-on PHP
Add-on ini bertugas menjalankan satu fungsi: mengambil 5 thread terbaru dari sebuah forum dan menyediakannya untuk dipakai di template.
5.1. Buat folder add-on
Via FTP, buat struktur folder:
/src/addons/Intutekno/CoverForum/
5.2. Buat file addon.json
Isi:
{
"title": "Intutekno Cover Forum",
"version_string": "1.0.0",
"version_id": 1000070,
"dev": "Intutekno",
"supported": true
}
5.3. Buat file Listener.php
<?php
namespace Intutekno\CoverForum;
class Listener
{
public static function templaterSetup(\XF\Container $container, \XF\Template\Templater &$templater)
{
$templater->addFunction('latest_forum_threads',
['Intutekno\CoverForum\TemplateHelper', 'templaterFunction']
);
}
}
5.4. Buat file TemplateHelper.php
<?php
namespace Intutekno\CoverForum;
class TemplateHelper
{
public static function templaterFunction($templater, &$escape, $nodeId, $limit = 5)
{
$escape = false;
return self::getLatestThreads($nodeId, $limit);
}
public static function getLatestThreads($nodeId, $limit = 5)
{
$finder = \XF::finder('XF:Thread')
->where('node_id', $nodeId)
->where('discussion_state', 'visible')
->where('discussion_type', '<>', 'redirect')
->with('User')
->with('Prefix')
->order('last_post_date', 'DESC')
->limit($limit);
return $finder->fetch();
}
}
Struktur akhir folder:
/src/addons/Intutekno/CoverForum/
├── addon.json
├── Listener.php
└── TemplateHelper.php
6. Install Add-on
Di AdminCP:
Klik menu Add-ons di sidebar
Cari "Intutekno Cover Forum" di list
Klik tombol Install di sampingnya
Confirm
Status add-on harus jadi Active (toggle hijau).
7. Register Code Event Listener
Ini langkah yang menghubungkan add-on PHP kita ke template engine XenForo.
AdminCP → Development → Code event listeners
Klik + Add code event listener di pojok kanan atas
Isi form:
Field | Isi |
|---|---|
Listen to event |
|
Event hint | (kosongkan) |
Execute callback: Class |
|
Execute callback: Method |
|
Callback execution order |
|
Enable callback execution | ✅ centang |
Description |
|
Add-on |
|
Klik Save
8. Edit Template node_list_forum
AdminCP → Appearance → Templates → cari template node_list_forum → Edit.
Ganti seluruh isi template dengan kode berikut:
<xf:macro id="depth1" arg-node="!" arg-extras="!" arg-children="!" arg-childExtras="!" arg-depth="1">
<div class="block">
<div class="block-container">
<div class="block-body">
<xf:macro id="forum"
arg-node="{$node}"
arg-extras="{$extras}"
arg-children="{$children}"
arg-childExtras="{$childExtras}"
arg-depth="{$depth}" />
</div>
</div>
</div>
</xf:macro>
<xf:macro id="depth2" arg-node="!" arg-extras="!" arg-children="!" arg-childExtras="!" arg-depth="1">
<xf:macro id="forum"
arg-node="{$node}"
arg-extras="{$extras}"
arg-children="{$children}"
arg-childExtras="{$childExtras}"
arg-depth="{$depth}" />
</xf:macro>
<xf:macro id="depthN" arg-node="!" arg-extras="!" arg-children="!" arg-childExtras="!" arg-depth="1">
<li>
<a href="{{ link('forums', $node) }}" class="subNodeLink subNodeLink--forum {{ $extras.hasNew ? 'subNodeLink--unread' : '' }}">
<xf:fa icon="{{ $node.Data.TypeHandler.getTypeIconClass() ?: 'fa-comments' }}" class="subNodeLink-icon" />{$node.title}
</a>
<xf:macro id="forum_list::sub_node_list"
arg-children="{$children}"
arg-childExtras="{$childExtras}"
arg-depth="{{ $depth + 1 }}" />
</li>
</xf:macro>
<xf:macro id="forum"
arg-node="!"
arg-extras="!"
arg-children="!"
arg-childExtras="!"
arg-depth="!"
arg-chooseName=""
arg-bonusInfo="">
<xf:if is="$depth == 1 OR $depth == 2">
<!-- ===== COVER STYLE (forum utama) ===== -->
<div class="node node--id{$node.node_id} node--depth{$depth} node--forum intuteknoCover intuteknoCover--cat{$node.parent_node_id} {{ $extras.hasNew ? 'node--unread' : 'node--read' }}">
<div class="intuteknoCover-body">
<div class="intuteknoCover-side">
<a href="{{ link('forums', $node) }}" class="intuteknoCover-img">
<img src="/data/forum_covers/{$node.node_id}.svg"
onerror="this.src='/data/forum_covers/default.svg'"
alt="{$node.title}" loading="lazy" />
</a>
<h3 class="intuteknoCover-title">
<a href="{{ link('forums', $node) }}">{$node.title}</a>
</h3>
<xf:if is="$node.description">
<div class="intuteknoCover-desc">{$node.description|raw}</div>
</xf:if>
<div class="intuteknoCover-stats">
<span><b>{{ phrase('threads') }}:</b> {$extras.discussion_count|number_short(1)}</span>
<span><b>{{ phrase('messages') }}:</b> {$extras.message_count|number_short(1)}</span>
</div>
</div>
<div class="intuteknoCover-threads">
<xf:set var="$latest" value="{{ latest_forum_threads($node.node_id, 5) }}" />
<xf:if is="$latest is not empty">
<ul class="intuteknoThread-list">
<xf:foreach loop="$latest" value="$thread">
<li class="intuteknoThread-item">
<xf:avatar user="{$thread.User}" size="xxs" defaultname="{$thread.username}" class="intuteknoThread-avatar" />
<div class="intuteknoThread-main">
<div class="intuteknoThread-title">
<xf:if is="$thread.prefix_id">{{ prefix('thread', $thread) }}</xf:if>
<a href="{{ link('threads', $thread) }}">{$thread.title}</a>
</div>
<div class="intuteknoThread-meta">
<a href="{{ link('members', $thread.User) }}" class="username">{$thread.username}</a>
<span>·</span>
<xf:date time="{$thread.last_post_date}" />
<span>·</span>
<span><xf:fa icon="fa-comment-alt" /> {$thread.reply_count|number_short(1)}</span>
</div>
</div>
</li>
</xf:foreach>
</ul>
<xf:else />
<div class="intuteknoThread-empty">
<xf:fa icon="fa-comments" />
<span>Belum ada thread di forum ini</span>
</div>
</xf:if>
</div>
</div>
</div>
<xf:else />
<!-- ===== ORIGINAL STYLE (sub-forum, depth >= 3) ===== -->
<div class="node node--id{$node.node_id} node--depth{$depth} node--forum {{ $extras.hasNew ? 'node--unread' : 'node--read' }}">
<div class="node-body">
<span class="node-icon" aria-hidden="true">
<xf:fa icon="{{ $node.Data.TypeHandler.getTypeIconClass() ?: 'fa-comments' }}" />
</span>
<div class="node-main js-nodeMain">
<xf:if is="$chooseName">
<xf:checkbox standalone="true">
<xf:option labelclass="u-pullRight" class="js-chooseItem" name="{$chooseName}[]" value="{$node.node_id}" />
</xf:checkbox>
</xf:if>
<xf:set var="$descriptionDisplay" value="{{ property('nodeListDescriptionDisplay') }}" />
<h3 class="node-title">
<a href="{{ link('forums', $node) }}" data-xf-init="{{ $descriptionDisplay == 'tooltip' ? 'element-tooltip' : '' }}" data-shortcut="node-description">{$node.title}</a>
</h3>
<xf:if is="$descriptionDisplay != 'none' && $node.description">
<div class="node-description {{ $descriptionDisplay == 'tooltip' ? 'node-description--tooltip js-nodeDescTooltip' : '' }}">{$node.description|raw}</div>
</xf:if>
<div class="node-meta">
<xf:if is="!{$extras.privateInfo}">
<div class="node-statsMeta">
<dl class="pairs pairs--inline">
<dt>{{ phrase('threads') }}</dt>
<dd>{$extras.discussion_count|number_short(1)}</dd>
</dl>
<dl class="pairs pairs--inline">
<dt>{{ phrase('messages') }}</dt>
<dd>{$extras.message_count|number_short(1)}</dd>
</dl>
</div>
</xf:if>
<xf:if is="$depth == 2 AND property('nodeListSubDisplay') == 'menu'">
<xf:macro id="forum_list::sub_nodes_menu"
arg-children="{$children}"
arg-childExtras="{$childExtras}"
arg-depth="{{ $depth + 1 }}" />
</xf:if>
</div>
<xf:if is="$depth == 2 AND property('nodeListSubDisplay') == 'flat'">
<xf:macro id="forum_list::sub_nodes_flat"
arg-children="{$children}"
arg-childExtras="{$childExtras}"
arg-depth="{{ $depth + 1 }}" />
</xf:if>
<xf:if is="$bonusInfo is not empty">
<div class="node-bonus">{$bonusInfo}</div>
</xf:if>
</div>
<xf:if is="!{$extras.privateInfo}">
<div class="node-stats">
<dl class="pairs pairs--rows">
<dt>{{ phrase('threads') }}</dt>
<dd>{$extras.discussion_count|number_short(1)}</dd>
</dl>
<dl class="pairs pairs--rows">
<dt>{{ phrase('messages') }}</dt>
<dd>{$extras.message_count|number_short(1)}</dd>
</dl>
</div>
</xf:if>
<div class="node-extra">
<xf:if is="{$extras.privateInfo}">
<span class="node-extra-placeholder">{{ phrase('private') }}</span>
<xf:elseif is="{$extras.LastThread}" />
<div class="node-extra-icon">
<xf:if is="$xf.visitor.isIgnoring($extras.last_post_user_id)">
<xf:avatar user="{{ null }}" size="xs" />
<xf:else />
<xf:avatar user="{$extras.LastPostUser}" defaultname="{$extras.last_post_username}" size="xs" />
</xf:if>
</div>
<div class="node-extra-row">
<xf:if is="$extras.LastThread.isUnread()">
<a href="{{ link('threads/unread', $extras.LastThread) }}" class="node-extra-title" title="{$extras.LastThread.title}">{{ prefix('thread', $extras.LastThread) }}{$extras.LastThread.title}</a>
<xf:else />
<a href="{{ link('threads/post', $extras.LastThread, {'post_id': $extras.last_post_id}) }}" class="node-extra-title" title="{$extras.LastThread.title}">{{ prefix('thread', $extras.LastThread) }}{$extras.LastThread.title}</a>
</xf:if>
</div>
<div class="node-extra-row">
<ul class="listInline listInline--bullet">
<li><xf:date time="{$extras.last_post_date}" class="node-extra-date" /></li>
<xf:if is="$xf.visitor.isIgnoring($extras.last_post_user_id)">
<li class="node-extra-user">{{ phrase('ignored_member') }}</li>
<xf:else />
<li class="node-extra-user"><xf:username user="{$extras.LastPostUser}" defaultname="{$extras.last_post_username}" /></li>
</xf:if>
</ul>
</div>
<xf:else />
<span class="node-extra-placeholder">{{ phrase('none') }}</span>
</xf:if>
</div>
</div>
</div>
</xf:if>
<xf:if is="{$depth} == 1">
<xf:macro id="forum_list::node_list"
arg-children="{$children}"
arg-extras="{$childExtras}"
arg-depth="{{ $depth + 1 }}" />
</xf:if>
</xf:macro>
Save.
Penjelasan: Conditional
$depth == 1 OR $depth == 2membuat cover style berlaku untuk forum di root maupun di dalam kategori. Sub-forum (depth ≥ 3) tetap pakai layout original supaya rapi.
9. Tambahkan CSS
AdminCP → Appearance → Style properties → Extra LESS template → tambahkan blok ini:
/* ============================================
Intutekno Cover Forum Style
============================================ */
.intuteknoCover {
background: @xf-contentBg;
border: 1px solid @xf-borderColor;
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
border-top: 5px solid #06b6d4; /* default fallback */
}
.intuteknoCover-body {
display: flex;
gap: 0;
}
/* === LEFT: Cover + Info === */
.intuteknoCover-side {
flex: 0 0 30%;
max-width: 30%;
padding: 14px;
border-right: 1px solid @xf-borderColor;
background: rgba(0,0,0,0.02);
}
.intuteknoCover-img {
display: block;
width: 100%;
aspect-ratio: 3 / 2;
overflow: hidden;
border-radius: 6px;
margin-bottom: 12px;
}
.intuteknoCover-img img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform .3s ease;
}
.intuteknoCover-img:hover img {
transform: scale(1.05);
}
.intuteknoCover-title {
margin: 0 0 8px;
font-size: 17px;
font-weight: 700;
}
.intuteknoCover-title a {
color: @xf-linkColor;
}
.intuteknoCover-desc {
font-size: 13px;
color: @xf-textColorMuted;
line-height: 1.5;
margin-bottom: 12px;
}
.intuteknoCover-stats {
display: flex;
gap: 14px;
padding-top: 10px;
border-top: 2px solid #e74c3c;
font-size: 13px;
}
.intuteknoCover-stats b {
color: @xf-textColor;
}
/* === RIGHT: Daftar Thread === */
.intuteknoCover-threads {
flex: 1;
padding: 8px 14px;
}
.intuteknoThread-list {
list-style: none;
margin: 0;
padding: 0;
}
.intuteknoThread-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-bottom: 1px solid @xf-borderColorLight;
}
.intuteknoThread-item:last-child {
border-bottom: 0;
}
.intuteknoThread-item .intuteknoThread-avatar {
flex: 0 0 32px;
width: 32px;
height: 32px;
margin: 0;
}
.intuteknoThread-item .intuteknoThread-avatar.avatar img,
.intuteknoThread-item .intuteknoThread-avatar img {
width: 32px !important;
height: 32px !important;
border-radius: 50%;
}
.intuteknoThread-item .intuteknoThread-avatar.avatar {
font-size: 14px !important;
line-height: 32px !important;
}
.intuteknoThread-main {
flex: 1;
min-width: 0;
}
.intuteknoThread-title {
font-size: 14px;
margin-bottom: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.intuteknoThread-meta {
font-size: 12px;
color: @xf-textColorMuted;
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
.intuteknoThread-meta .username {
font-weight: 600;
color: @xf-linkColor;
}
.intuteknoThread-empty {
padding: 50px 20px;
text-align: center;
color: @xf-textColorMuted;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
height: 100%;
justify-content: center;
}
.intuteknoThread-empty .fa {
font-size: 36px;
opacity: 0.25;
}
/* === Responsive Mobile === */
@media (max-width: 768px) {
.intuteknoCover-body {
flex-direction: column;
}
.intuteknoCover-side,
.intuteknoCover-threads {
flex: 1 1 100%;
max-width: 100%;
border-right: 0;
border-bottom: 1px solid @xf-borderColor;
}
}
Save.
10. Setup Cover Image
10.1. Buat folder cover
Via File Manager cPanel atau FTP, buat folder:
/data/forum_covers/
Set permission 755.
10.2. Cari node_id setiap forum
AdminCP → Forums → klik nama forum → lihat URL:
.../admin.php?nodes/nama-forum.5/edit
↑
node_id = 5
10.3. Upload cover image
Upload file SVG/WebP/JPG ke /data/forum_covers/ dengan nama sesuai node_id:
/data/forum_covers/
├── default.svg ← fallback wajib
├── 5.svg ← forum dengan node_id 5
├── 6.svg
├── 7.svg
└── ...
Spesifikasi cover ideal:
Aspect ratio 3:2
Ukuran 600×400 px
Format SVG (paling ringan dan scalable) atau WebP
Catatan: Template di langkah 8 sudah pakai ekstensi
.svg. Kalau Anda pakai.webpatau.jpg, ubah dua tempat di template dari.svgmenjadi ekstensi yang Anda pakai.
11. Customize Border per Kategori
Border-top tiap card forum bisa diwarnai berbeda berdasarkan kategori induk-nya. Berkat class intuteknoCover--cat{X} yang otomatis disisipkan di langkah 8, kita tinggal target via CSS.
11.1. Cari node_id kategori
Sama seperti forum: AdminCP → Forums → klik kategori → lihat node_id di URL.
11.2. Tambahkan rules CSS
Tambahkan ke Extra LESS:
/* === Border Color per Kategori === */
.intuteknoCover--cat3 { border-top-color: #3b82f6; } /* Tutorial - biru */
.intuteknoCover--cat4 { border-top-color: #f59e0b; } /* Info - amber */
.intuteknoCover--cat5 { border-top-color: #10b981; } /* hijau */
.intuteknoCover--cat6 { border-top-color: #ec4899; } /* pink */
/* tambahkan sesuai kategori yang dimiliki */
Sesuaikan angka di --cat{X} dengan node_id kategori, dan warna sesuai branding masing-masing.
Save dan refresh
Setelah semua perubahan CSS:
Save Extra LESS
Tools → Rebuild caches → centang semua → Rebuild
Buka homepage forum → Ctrl+F5
12. Troubleshooting
Error Function "latest_forum_threads" is not callable
Listener belum aktif atau salah class path. Cek:
Add-ons — add-on Status harus Active
Code event listeners — entry untuk
templater_setupharus enabled, Class dan Method harus persis sama dengan file PHPTools → Rebuild caches → centang semua
Cover tidak muncul, ada celah putih di kiri
Berarti gambar gagal dimuat. Cek:
File ada di
/data/forum_covers/?Permission folder 755, file 644?
Nama file sesuai node_id?
Path di template sesuai ekstensi file?
Ada file
default.svgsebagai fallback?
Layout tidak berubah, masih style asli
Pastikan template
node_list_forumter-save dengan kode lengkap di langkah 8Rebuild templates wajib
Ctrl+F5 untuk bypass cache browser
Cek dengan inspect element — class
intuteknoCoverharus muncul di div
Sub-forum jadi style cover juga (tidak diinginkan)
Cek conditional di template:
<xf:if is="$depth == 1 OR $depth == 2">
Sub-forum biasanya depth ≥ 3, jadi seharusnya tidak ter-cover. Kalau forum Anda strukturnya berbeda, sesuaikan condition.
Avatar ada celah ke kiri
Pastikan template pakai sintaks ini (avatar inline, bukan dibungkus <a>):
<xf:avatar user="{$thread.User}" size="xxs" defaultname="{$thread.username}" class="intuteknoThread-avatar" />
Class intuteknoCover--cat{id} tidak muncul di HTML
Berarti template belum ter-update. Edit ulang, save, dan rebuild templates.
13. Penutup
Setelah semua langkah di atas, halaman utama forum akan menampilkan card cover untuk setiap forum dengan layout 2 kolom dan border atas warna per kategori.
Pengembangan Selanjutnya
Beberapa peningkatan yang bisa dilakukan kapan-kapan:
Caching thread terbaru lewat XF registry supaya tidak query 5 thread per forum setiap homepage di-load
Custom field untuk upload cover via AdminCP (tanpa FTP)
Hover effect halus di card dan row thread
Lazy load cover image dengan blur placeholder
Struktur File Akhir
/src/addons/Intutekno/CoverForum/
├── addon.json
├── Listener.php
└── TemplateHelper.php
/data/forum_covers/
├── default.svg
├── 5.svg
├── 6.svg
└── ... (sesuai jumlah forum)
AdminCP → Templates: node_list_forum (modified)
AdminCP → Style properties → Extra LESS (modified)
AdminCP → Code event listeners: templater_setup → Listener::templaterSetup
Tutorial dibuat berdasarkan implementasi nyata di forum.intutekno.com
Lisensi: silakan adaptasi dan bagikan ulang. Kalau bermanfaat, sebutkan sumber sebagai amal jariah — pengetahuan yang terus bermanfaat.
Bagaimana pendapat Anda mengenai topik ini? Mari bagikan perspektif Anda di bawah!
Recommended Comments