Wiki source code of KBCategoryLanding

Version 1.1 by Isaac Mejia on 2026/06/08 20:07

Hide last authors
Isaac Mejia 1.1 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}}