Wiki source code of KBCategoryLanding
Last modified by Isaac Mejia on 2026/06/08 20:12
Show last authors
| author | version | line-number | content |
|---|---|---|---|
| 1 | {{velocity}} | ||
| 2 | ## ------------------------------------------------------------ | ||
| 3 | ## Category landing page for eFit subwiki | ||
| 4 | ## URL pattern: /Main/<Category>/WebHome | ||
| 5 | ## e.g. /Main/Payment_Management/WebHome | ||
| 6 | ## ------------------------------------------------------------ | ||
| 7 | |||
| 8 | ## Extract category from URL - grab segment after "Main" | ||
| 9 | #set ($uri = $request.getRequestURI()) | ||
| 10 | #set ($baseSpace = '') | ||
| 11 | #set ($catDisplayName = '') | ||
| 12 | |||
| 13 | #set ($viewMarker = '/view/') | ||
| 14 | #if ($uri.contains($viewMarker)) | ||
| 15 | #set ($afterView = $uri.substring($uri.indexOf($viewMarker) + $viewMarker.length())) | ||
| 16 | #set ($uriParts = $afterView.split('/')) | ||
| 17 | ## uriParts: [0]=Main, [1]=Payment_Management, [2]=WebHome | ||
| 18 | #if ($uriParts.size() > 1) | ||
| 19 | #set ($baseSpace = $uriParts.get(1)) | ||
| 20 | #end | ||
| 21 | #end | ||
| 22 | |||
| 23 | ## Fallback | ||
| 24 | #if ("$!baseSpace" == "" || $baseSpace == 'WebHome' || $baseSpace == 'Main') | ||
| 25 | #set ($baseSpace = $doc.space.replace('Main.', '')) | ||
| 26 | #end | ||
| 27 | |||
| 28 | ## Full XWiki space reference e.g. "Main.Payment_Management" | ||
| 29 | #set ($fullSpace = "Main." + $baseSpace) | ||
| 30 | |||
| 31 | ## Display name - replace underscores and hyphens with spaces | ||
| 32 | #set ($catDisplayName = $baseSpace.replace('_', ' ').replace('-', '-')) | ||
| 33 | |||
| 34 | ## ----------------------------- | ||
| 35 | ## Read query params safely | ||
| 36 | ## ----------------------------- | ||
| 37 | #set ($rawFilter = $request.getParameter('filter')) | ||
| 38 | #set ($filter = "$!rawFilter") | ||
| 39 | #set ($filter = $stringtool.trim($filter)) | ||
| 40 | #set ($hasFilter = $filter != "") | ||
| 41 | |||
| 42 | #set ($rawP = $request.getParameter('p')) | ||
| 43 | #set ($pStr = "$!rawP") | ||
| 44 | #set ($pStr = $stringtool.trim($pStr)) | ||
| 45 | |||
| 46 | #set ($pageSize = 20) | ||
| 47 | #set ($pageNum = 1) | ||
| 48 | |||
| 49 | #if ($pStr != "" && $pStr.matches("^[0-9]+$")) | ||
| 50 | #set ($pageNum = $numbertool.toNumber($pStr).intValue()) | ||
| 51 | #end | ||
| 52 | #if ($pageNum < 1) #set ($pageNum = 1) #end | ||
| 53 | |||
| 54 | #set ($offset = ($pageNum - 1) * $pageSize) | ||
| 55 | |||
| 56 | #set ($baseUrl = $doc.getURL('view')) | ||
| 57 | #set ($encodedFilter = $escapetool.url($filter)) | ||
| 58 | #set ($filterPrefix = "") | ||
| 59 | #if ($hasFilter) | ||
| 60 | #set ($filterPrefix = "filter=$encodedFilter&") | ||
| 61 | #end | ||
| 62 | |||
| 63 | ## ----------------------------- | ||
| 64 | ## XWQL WHERE | ||
| 65 | ## ----------------------------- | ||
| 66 | #set ($where = | ||
| 67 | "where doc.space = :fullSpace " + | ||
| 68 | "and doc.name <> 'WebHome' " + | ||
| 69 | "and doc.hidden <> true " | ||
| 70 | ) | ||
| 71 | #if ($hasFilter) | ||
| 72 | #set ($where = $where + | ||
| 73 | "and (lower(doc.title) like :q or lower(doc.content) like :q) " | ||
| 74 | ) | ||
| 75 | #end | ||
| 76 | |||
| 77 | ## ----------------------------- | ||
| 78 | ## Count total | ||
| 79 | ## ----------------------------- | ||
| 80 | #set ($countXwql = | ||
| 81 | "select count(doc.fullName) " + | ||
| 82 | "from XWikiDocument doc " + | ||
| 83 | $where | ||
| 84 | ) | ||
| 85 | |||
| 86 | #set ($countQuery = $services.query.xwql($countXwql)) | ||
| 87 | #set ($discard = $countQuery.bindValue('fullSpace', $fullSpace)) | ||
| 88 | #if ($hasFilter) | ||
| 89 | #set ($discard = $countQuery.bindValue('q', "%" + $filter.toLowerCase() + "%")) | ||
| 90 | #end | ||
| 91 | |||
| 92 | #set ($countRows = $countQuery.execute()) | ||
| 93 | #set ($totalCount = 0) | ||
| 94 | #if ($countRows && $countRows.size() > 0) | ||
| 95 | #set ($totalCount = $countRows.get(0)) | ||
| 96 | #end | ||
| 97 | |||
| 98 | ## Pagination math | ||
| 99 | #set ($totalPages = 1) | ||
| 100 | #if ($totalCount > 0) | ||
| 101 | #set ($totalPages = (($totalCount + $pageSize - 1) / $pageSize)) | ||
| 102 | #end | ||
| 103 | #if ($pageNum > $totalPages) | ||
| 104 | #set ($pageNum = $totalPages) | ||
| 105 | #set ($offset = ($pageNum - 1) * $pageSize) | ||
| 106 | #end | ||
| 107 | |||
| 108 | #set ($prev = $pageNum - 1) | ||
| 109 | #set ($next = $pageNum + 1) | ||
| 110 | |||
| 111 | ## ----------------------------- | ||
| 112 | ## Fetch paged results | ||
| 113 | ## ----------------------------- | ||
| 114 | #set ($listXwql = | ||
| 115 | "select doc.fullName " + | ||
| 116 | "from XWikiDocument doc " + | ||
| 117 | $where + | ||
| 118 | "order by lower(doc.title)" | ||
| 119 | ) | ||
| 120 | |||
| 121 | #set ($listQuery = $services.query.xwql($listXwql)) | ||
| 122 | #set ($discard = $listQuery.bindValue('fullSpace', $fullSpace)) | ||
| 123 | #if ($hasFilter) | ||
| 124 | #set ($discard = $listQuery.bindValue('q', "%" + $filter.toLowerCase() + "%")) | ||
| 125 | #end | ||
| 126 | |||
| 127 | #set ($discard = $listQuery.setLimit($pageSize)) | ||
| 128 | #set ($discard = $listQuery.setOffset($offset)) | ||
| 129 | #set ($rows = $listQuery.execute()) | ||
| 130 | |||
| 131 | #set ($articles = []) | ||
| 132 | #foreach ($name in $rows) | ||
| 133 | #set ($articleDoc = $xwiki.getDocument($name)) | ||
| 134 | #set ($discard = $articles.add($articleDoc)) | ||
| 135 | #end | ||
| 136 | |||
| 137 | #set ($articleCountLabel = "${totalCount} article") | ||
| 138 | #if ($totalCount != 1) | ||
| 139 | #set ($articleCountLabel = "${totalCount} articles") | ||
| 140 | #end | ||
| 141 | |||
| 142 | ## Pager window | ||
| 143 | #set ($start = $pageNum - 2) | ||
| 144 | #set ($end = $pageNum + 2) | ||
| 145 | #if ($start < 1) #set ($start = 1) #end | ||
| 146 | #if ($end > $totalPages) #set ($end = $totalPages) #end | ||
| 147 | |||
| 148 | {{html clean="false"}} | ||
| 149 | <div class="kb-category-page"> | ||
| 150 | |||
| 151 | <div class="kb-hero"> | ||
| 152 | <h1 class="kb-hero-title">$escapetool.xml($catDisplayName)</h1> | ||
| 153 | <p class="kb-hero-subtitle">Browse all articles in $escapetool.xml($catDisplayName).</p> | ||
| 154 | |||
| 155 | <form class="kb-category-search" action="$baseUrl" method="get"> | ||
| 156 | <input | ||
| 157 | type="text" | ||
| 158 | name="filter" | ||
| 159 | value="$escapetool.html($filter)" | ||
| 160 | placeholder="Search within this category…" | ||
| 161 | class="kb-category-search-input" | ||
| 162 | /> | ||
| 163 | <button type="submit" class="kb-category-search-button">Search</button> | ||
| 164 | #if ($hasFilter) | ||
| 165 | <a class="kb-category-clear" href="$baseUrl">Clear</a> | ||
| 166 | #end | ||
| 167 | </form> | ||
| 168 | </div> | ||
| 169 | |||
| 170 | <div class="kb-category-columns"> | ||
| 171 | |||
| 172 | <!-- Popular --> | ||
| 173 | <aside class="kb-category-column kb-sidebar"> | ||
| 174 | <h2 class="kb-section-title">Popular articles</h2> | ||
| 175 | |||
| 176 | <ul class="kb-category-list"> | ||
| 177 | #if (!$hasFilter) | ||
| 178 | #set ($popXwql = | ||
| 179 | "select doc.fullName from XWikiDocument doc " + | ||
| 180 | "where doc.space = :fullSpace and doc.name <> 'WebHome' and doc.hidden <> true " + | ||
| 181 | "order by lower(doc.title)" | ||
| 182 | ) | ||
| 183 | #set ($popQuery = $services.query.xwql($popXwql)) | ||
| 184 | #set ($discard = $popQuery.bindValue('fullSpace', $fullSpace)) | ||
| 185 | #set ($discard = $popQuery.setLimit(4)) | ||
| 186 | #set ($popRows = $popQuery.execute()) | ||
| 187 | |||
| 188 | #if ($popRows && $popRows.size() > 0) | ||
| 189 | #foreach ($n in $popRows) | ||
| 190 | #set ($pdoc = $xwiki.getDocument($n)) | ||
| 191 | <li><a href="$pdoc.getURL('view')" class="kb-footer-link">$escapetool.xml($pdoc.displayTitle)</a></li> | ||
| 192 | #end | ||
| 193 | #else | ||
| 194 | <li><span>No articles yet.</span></li> | ||
| 195 | #end | ||
| 196 | #else | ||
| 197 | <li><span class="kb-muted">Popular list hidden while searching.</span></li> | ||
| 198 | #end | ||
| 199 | </ul> | ||
| 200 | </aside> | ||
| 201 | |||
| 202 | <!-- All articles --> | ||
| 203 | <main class="kb-category-column kb-main"> | ||
| 204 | <div class="kb-category-header-row"> | ||
| 205 | <h2 class="kb-section-title">All articles in $escapetool.xml($catDisplayName)</h2> | ||
| 206 | <span class="kb-article-count-badge">$escapetool.xml($articleCountLabel)</span> | ||
| 207 | </div> | ||
| 208 | |||
| 209 | <!-- Pager (top) --> | ||
| 210 | #if ($totalPages > 1) | ||
| 211 | <div class="kb-pager"> | ||
| 212 | #if ($pageNum > 1) | ||
| 213 | <a class="kb-pager-btn" href="$baseUrl?$filterPrefix" | ||
| 214 | onclick="this.href='$baseUrl?$filterPrefix' + 'p=$prev';">← Prev</a> | ||
| 215 | #else | ||
| 216 | <span class="kb-pager-btn is-disabled">← Prev</span> | ||
| 217 | #end | ||
| 218 | |||
| 219 | <div class="kb-pager-pages"> | ||
| 220 | #set ($p1 = $start) | ||
| 221 | #set ($p2 = $start + 1) | ||
| 222 | #set ($p3 = $start + 2) | ||
| 223 | #set ($p4 = $start + 3) | ||
| 224 | #set ($p5 = $start + 4) | ||
| 225 | |||
| 226 | #macro(renderPage $pn) | ||
| 227 | #if ($pn >= $start && $pn <= $end) | ||
| 228 | #if ($pn == $pageNum) | ||
| 229 | <span class="kb-pager-page is-current">$pn</span> | ||
| 230 | #else | ||
| 231 | <a class="kb-pager-page" | ||
| 232 | href="$baseUrl?$filterPrefix" | ||
| 233 | onclick="this.href='$baseUrl?$filterPrefix' + 'p=$pn';">$pn</a> | ||
| 234 | #end | ||
| 235 | #end | ||
| 236 | #end | ||
| 237 | |||
| 238 | #renderPage($p1) | ||
| 239 | #renderPage($p2) | ||
| 240 | #renderPage($p3) | ||
| 241 | #renderPage($p4) | ||
| 242 | #renderPage($p5) | ||
| 243 | </div> | ||
| 244 | |||
| 245 | #if ($pageNum < $totalPages) | ||
| 246 | <a class="kb-pager-btn" href="$baseUrl?$filterPrefix" | ||
| 247 | onclick="this.href='$baseUrl?$filterPrefix' + 'p=$next';">Next →</a> | ||
| 248 | #else | ||
| 249 | <span class="kb-pager-btn is-disabled">Next →</span> | ||
| 250 | #end | ||
| 251 | </div> | ||
| 252 | #end | ||
| 253 | |||
| 254 | <ul class="kb-category-card-grid" id="kbCategoryGrid"> | ||
| 255 | #foreach ($article in $articles) | ||
| 256 | #set ($lastUpdatedLabel = "") | ||
| 257 | #if ($article.date) | ||
| 258 | #set ($lastUpdatedLabel = $xwiki.formatDate($article.date, "MMM d, yyyy")) | ||
| 259 | #end | ||
| 260 | |||
| 261 | <li class="kb-article-card" data-title="$escapetool.xml($article.displayTitle.toLowerCase())"> | ||
| 262 | <a href="$article.getURL('view')" class="kb-article-card-title"> | ||
| 263 | $escapetool.xml($article.displayTitle) | ||
| 264 | </a> | ||
| 265 | |||
| 266 | #if ($lastUpdatedLabel != "") | ||
| 267 | <div class="kb-article-card-meta"> | ||
| 268 | Updated $escapetool.xml($lastUpdatedLabel) | ||
| 269 | </div> | ||
| 270 | #end | ||
| 271 | </li> | ||
| 272 | #end | ||
| 273 | |||
| 274 | #if ($articles.isEmpty()) | ||
| 275 | <li class="kb-article-card kb-article-card-empty"> | ||
| 276 | <div class="kb-article-card-title"> | ||
| 277 | #if ($hasFilter) | ||
| 278 | No results for "$escapetool.xml($filter)". | ||
| 279 | #else | ||
| 280 | No articles yet. | ||
| 281 | #end | ||
| 282 | </div> | ||
| 283 | </li> | ||
| 284 | #end | ||
| 285 | </ul> | ||
| 286 | |||
| 287 | <!-- Pager (bottom) --> | ||
| 288 | #if ($totalPages > 1) | ||
| 289 | <div class="kb-pager kb-pager-bottom"> | ||
| 290 | <span class="kb-pager-status"> | ||
| 291 | Page <strong>$pageNum</strong> of <strong>$totalPages</strong> | ||
| 292 | </span> | ||
| 293 | </div> | ||
| 294 | #end | ||
| 295 | </main> | ||
| 296 | |||
| 297 | </div> | ||
| 298 | </div> | ||
| 299 | |||
| 300 | <style> | ||
| 301 | .kb-category-page { max-width: 1200px; margin: 0 auto; padding: 0 1rem 3rem; } | ||
| 302 | .kb-hero { text-align: center; padding: 2.2rem 0 1rem; } | ||
| 303 | .kb-hero-title { font-size: 2.2rem; font-weight: 800; margin: 0; } | ||
| 304 | .kb-hero-subtitle { color: #6b7280; margin: 0.6rem 0 1.2rem; } | ||
| 305 | .kb-category-search { display: flex; gap: 0.6rem; justify-content: center; align-items: center; flex-wrap: wrap; } | ||
| 306 | .kb-category-search-input { width: min(680px, 92vw); padding: 0.9rem 1.1rem; border: 1px solid #d1d5db; border-radius: 999px; font-size: 1.05rem; } | ||
| 307 | .kb-category-search-button { padding: 0.9rem 1.1rem; border-radius: 999px; border: none; background: #2563eb; color: #fff; font-weight: 700; } | ||
| 308 | .kb-category-clear { color: #6b7280; text-decoration: none; font-weight: 700; } | ||
| 309 | .kb-category-clear:hover { text-decoration: underline; } | ||
| 310 | .kb-muted { color: #9ca3af; } | ||
| 311 | .kb-category-columns { display: grid; grid-template-columns: 1fr; gap: 2rem; align-items: start; } | ||
| 312 | @media (min-width: 1100px) { .kb-category-columns { grid-template-columns: 340px minmax(0, 1fr); gap: 2.5rem; } } | ||
| 313 | @media (max-width: 1099px) { .kb-sidebar { order: 2; } .kb-main { order: 1; } } | ||
| 314 | .kb-sidebar { border: 1px solid #e5e7eb; border-radius: 1rem; padding: 1rem 1.1rem; background: #fff; box-shadow: 0 4px 12px rgba(15,23,42,0.06); } | ||
| 315 | .kb-category-list { list-style: none; padding: 0; margin: 0.8rem 0 0; } | ||
| 316 | .kb-category-list li { margin: 0.5rem 0; } | ||
| 317 | .kb-category-header-row { display: flex; gap: 0.75rem; justify-content: space-between; align-items: baseline; flex-wrap: wrap; } | ||
| 318 | .kb-article-count-badge { font-size: 0.85rem; padding: 0.15rem 0.55rem; border-radius: 999px; border: 1px solid #d4d4d8; background: #f4f4f5; color: #4b5563; } | ||
| 319 | .kb-pager { display: flex; gap: 0.75rem; align-items: center; justify-content: space-between; margin: 1rem 0 1.25rem; flex-wrap: wrap; } | ||
| 320 | .kb-pager-pages { display: flex; gap: 0.35rem; align-items: center; flex-wrap: wrap; justify-content: center; } | ||
| 321 | .kb-pager-btn { padding: 0.55rem 0.9rem; border-radius: 999px; border: 1px solid #e5e7eb; background: #fff; text-decoration: none; font-weight: 800; color: #111827; } | ||
| 322 | .kb-pager-btn.is-disabled { opacity: 0.45; cursor: not-allowed; } | ||
| 323 | .kb-pager-page { padding: 0.4rem 0.7rem; border-radius: 999px; border: 1px solid #e5e7eb; text-decoration: none; color: #111827; font-weight: 800; } | ||
| 324 | .kb-pager-page.is-current { background: #111827; color: #fff; border-color: #111827; } | ||
| 325 | .kb-pager-status { color: #6b7280; font-weight: 800; } | ||
| 326 | .kb-pager-bottom { justify-content: center; margin-top: 1.25rem; } | ||
| 327 | .kb-category-card-grid { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.9rem; grid-template-columns: 1fr; } | ||
| 328 | @media (min-width: 860px) { .kb-category-card-grid { grid-template-columns: 1fr 1fr; } } | ||
| 329 | .kb-article-card { border: 1px solid #e5e7eb; border-radius: 0.95rem; padding: 0.9rem 1rem; background: #fff; box-shadow: 0 4px 12px rgba(15,23,42,0.06); } | ||
| 330 | .kb-article-card-title { display: inline-block; font-size: 1.05rem; font-weight: 900; color: #2563eb; text-decoration: none; line-height: 1.25; } | ||
| 331 | .kb-article-card-title:hover { text-decoration: underline; } | ||
| 332 | .kb-article-card-meta { margin-top: 0.35rem; color: #6b7280; font-size: 0.86rem; } | ||
| 333 | .kb-article-card-empty { text-align: center; padding: 1.4rem; } | ||
| 334 | </style> | ||
| 335 | |||
| 336 | <script> | ||
| 337 | (function () { | ||
| 338 | const input = document.querySelector('.kb-category-search-input'); | ||
| 339 | const grid = document.getElementById('kbCategoryGrid'); | ||
| 340 | if (!input || !grid) return; | ||
| 341 | input.addEventListener('input', function () { | ||
| 342 | const q = (input.value || '').trim().toLowerCase(); | ||
| 343 | const cards = grid.querySelectorAll('.kb-article-card[data-title]'); | ||
| 344 | cards.forEach(card => { | ||
| 345 | const t = (card.getAttribute('data-title') || ''); | ||
| 346 | card.style.display = (!q || t.includes(q)) ? '' : 'none'; | ||
| 347 | }); | ||
| 348 | }); | ||
| 349 | })(); | ||
| 350 | </script> | ||
| 351 | {{/html}} | ||
| 352 | {{/velocity}} |