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