Landed the topic list page (no topics yet, so appearances are deceiving)

This commit is contained in:
2025-10-28 16:32:05 -06:00
parent 86540e00b1
commit 086954f7b0
9 changed files with 443 additions and 5 deletions
+89 -1
View File
@@ -12,6 +12,7 @@ package main
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
"git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/database"
@@ -34,7 +35,7 @@ func conferencesPrequel(ctxt ui.AmContext) (string, any, error) {
ctxt.SetRC(http.StatusNotFound) ctxt.SetRC(http.StatusNotFound)
return ui.ErrorPage(ctxt, errors.New("this community does not use conferencing services")) return ui.ErrorPage(ctxt, errors.New("this community does not use conferencing services"))
} }
if comm.MembersOnly && !ctxt.IsMember() { if comm.MembersOnly && !ctxt.IsMember() && !ctxt.TestPermission("Community.NoJoinRequired") {
ctxt.SetRC(http.StatusForbidden) ctxt.SetRC(http.StatusForbidden)
return ui.ErrorPage(ctxt, errors.New("you are not a member of this community")) return ui.ErrorPage(ctxt, errors.New("you are not a member of this community"))
} }
@@ -45,6 +46,29 @@ func conferencesPrequel(ctxt ui.AmContext) (string, any, error) {
return "", nil, nil return "", nil, nil
} }
func singleConferencePrequel(ctxt ui.AmContext) (string, any, error) {
cmd, arg, err := conferencesPrequel(ctxt)
if cmd != "" {
return cmd, arg, err
}
var conf *database.Conference
conf, err = database.AmGetConferenceByAliasInCommunity(ctxt.CurrentCommunity().Id, ctxt.URLParam("confid"))
if err != nil {
return ui.ErrorPage(ctxt, err)
}
m, lvl, err := conf.Membership(ctxt.CurrentUser())
if err != nil {
return ui.ErrorPage(ctxt, err)
}
myLevel := ctxt.EffectiveLevel()
if m && lvl > myLevel {
myLevel = lvl
}
ctxt.SetScratch("currentConference", conf)
ctxt.SetScratch("levelInConference", myLevel)
return "", nil, nil
}
/* Conferences displayes the list of conferences in a community. /* Conferences displayes the list of conferences in a community.
* Parameters: * Parameters:
* ctxt - The AmContext for the request. * ctxt - The AmContext for the request.
@@ -70,3 +94,67 @@ func Conferences(ctxt ui.AmContext) (string, any, error) {
ctxt.VarMap().Set("conferences", clist) ctxt.VarMap().Set("conferences", clist)
return "framed_template", "conflist.jet", err return "framed_template", "conflist.jet", err
} }
func Topics(ctxt ui.AmContext) (string, any, error) {
cmd, arg, err := singleConferencePrequel(ctxt)
if cmd != "" {
return cmd, arg, err
}
prefs, err := ctxt.CurrentUser().Prefs()
if err != nil {
return ui.ErrorPage(ctxt, err)
}
comm := ctxt.CurrentCommunity()
conf := ctxt.GetScratch("currentConference").(*database.Conference)
myLevel := ctxt.GetScratch("levelInConference").(uint16)
if !conf.TestPermission("Conference.Read", myLevel) {
ctxt.SetRC(http.StatusForbidden)
return ui.ErrorPage(ctxt, errors.New("you are not permitted to read this conference"))
}
// Get view and sort parameters from query, session, or defaults. Store to session.
trustSessionValues := false
if ctxt.IsSession("topic.conf") {
v := ctxt.GetSession("topic.conf").(int32)
if v == conf.ConfId {
trustSessionValues = true
} else {
ctxt.SetSession("topic.conf", conf.ConfId)
}
}
view := database.TopicViewActive
if trustSessionValues && ctxt.IsSession("topic.view") {
view = ctxt.GetSession("topic.view").(int)
}
view = ctxt.QueryParamInt("view", view)
ctxt.SetSession("topic.view", view)
sort := database.TopicSortNumber
if trustSessionValues && ctxt.IsSession("topic.sort") {
sort = ctxt.GetSession("topic.sort").(int)
}
sort = ctxt.QueryParamInt("sort", sort)
ctxt.SetSession("topic.sort", sort)
topics, err := database.AmListTopics(conf.ConfId, ctxt.CurrentUserId(), view, sort, false)
if err != nil {
return ui.ErrorPage(ctxt, err)
}
tz := prefs.Location()
loc := prefs.Localizer()
fdate := make([]string, len(topics))
for i, t := range topics {
fdate[i] = loc.Strftime("%x %X", t.LastUpdate.In(tz))
}
ctxt.VarMap().Set("conferenceName", conf.Name)
ctxt.VarMap().Set("urlBack", fmt.Sprintf("/comm/%s/conf", comm.Alias))
ctxt.VarMap().Set("urlStem", fmt.Sprintf("/comm/%s/conf/%s", comm.Alias, ctxt.URLParam("confid")))
ctxt.VarMap().Set("permalink", "TODO")
ctxt.VarMap().Set("view", view)
ctxt.VarMap().Set("sort", sort)
ctxt.VarMap().Set("topics", topics)
ctxt.VarMap().Set("formattedDate", fdate)
ctxt.VarMap().Set("amsterdam_pageTitle", "Topics in "+conf.Name)
return "framed_template", "topiclist.jet", nil
}
+23
View File
@@ -10,6 +10,7 @@
package database package database
import ( import (
"errors"
"fmt" "fmt"
"sync" "sync"
"time" "time"
@@ -204,6 +205,28 @@ func AmGetConferenceByAlias(alias string) (*Conference, error) {
return AmGetConference(int32(confid.(int))) return AmGetConference(int32(confid.(int)))
} }
/* AmGetConferenceByAliasInCommunity returns a conference in a community given its alias.
* Parameters:
* cid - The community to look inside.
* alias - The alias to look up.
* Returns:
* Pointer to the conference, or nil.
* Standard Go error status.
*/
func AmGetConferenceByAliasInCommunity(cid int32, alias string) (*Conference, error) {
rs, err := amdb.Query(`SELECT c.confid FROM commtoconf c, confalias a WHERE c.confid = a.confid
AND c.commid = ? AND a.alias = ?`, cid, alias)
if err != nil {
return nil, err
}
if !rs.Next() {
return nil, errors.New("conference not found")
}
var confid int32
rs.Scan(&confid)
return AmGetConference(confid)
}
/* AmGetCommunityConferences returns all conferences for a given community. /* AmGetCommunityConferences returns all conferences for a given community.
* Parameters: * Parameters:
* cid - Community ID to get conferences for. * cid - Community ID to get conferences for.
+160 -1
View File
@@ -9,8 +9,13 @@
// The database package contains database management and storage logic. // The database package contains database management and storage logic.
package database package database
import "time" import (
"errors"
"strings"
"time"
)
// Topic is the top-level structure detailing topics.
type Topic struct { type Topic struct {
TopicId int32 `db:"topicid"` TopicId int32 `db:"topicid"`
ConfId int32 `db:"confid"` ConfId int32 `db:"confid"`
@@ -25,6 +30,7 @@ type Topic struct {
Name string `db:"name"` Name string `db:"name"`
} }
// TopicSettings contains per-user settings for topics, including the "last read" message pointer.
type TopicSettings struct { type TopicSettings struct {
TopicId int32 `db:"topicid"` TopicId int32 `db:"topicid"`
Uid int32 `db:"uid"` Uid int32 `db:"uid"`
@@ -34,3 +40,156 @@ type TopicSettings struct {
LastPost *time.Time `db:"last_post"` LastPost *time.Time `db:"last_post"`
Subscribe bool `db:"subscribe"` Subscribe bool `db:"subscribe"`
} }
// TopicSummary is a smaller data structure that gets topic information to create the topic list display.
type TopicSummary struct {
TopicID int32
Number int16
Name string
Unread int32
Total int32
LastUpdate time.Time
Frozen bool
Archived bool
Subscribed bool
}
// View and sort constants for AmListTopics.
const (
TopicViewAll = 0
TopicViewNew = 1
TopicViewActive = 2
TopicViewAllVisible = 3
TopicViewHidden = 4
TopicViewArchive = 5
TopicSortID = 0
TopicSortNumber = 1
TopicSortName = 2
TopicSortUnread = 3
TopicSortTotal = 4
TopicSortDate = 5
)
/* AmListTopics produces a list of topic summary information according to specific options.
* Parameters:
* confid - The ID of the conference to list topics in.
* uid - The UID of the user to consider the settings of.
* viewOption - One of the following constants:
* TopicViewAll - List all topics.
* TopicViewNew - List only visible topics with new messages.
* TopicViewActive - List only visible topics, with "active" ones coming first.
* TopicViewAllVisible - List only visible topics.
* TopicViewHidden - List only hidden topics (including archived ones).
* TopicViewArchive - List only archived, non-hidden topics.
* sortOption - One of the following constants:
* TopicSortID - Sort by topic ID.
* TopicSortNumber - Sort by topic number in the conference. May be negated to sort in reverse order.
* TopicSortName - Sort by topic name. May be negated to sort in reverse order.
* TopicSortUnread - Sort by number of unread messages. May be negated to sort in reverse order.
* TopicSortTotal - Sort by total number of messages. May be negated to sort in reverse order.
* TopicSortDate - Sort by last topic update date. May be negated to sort in reverse order.
* ignoreSticky - If false, sticky topics will precede nonsticky ones; if true, stickiness is ignored.
* Returns:
* List of TopicSummary pointers.
* Standard Go error status.
*/
func AmListTopics(confid int32, uid int32, viewOption int, sortOption int, ignoreSticky bool) ([]*TopicSummary, error) {
// Decode the viewOption into a WHERE clause.
var whereClause string
switch viewOption {
case TopicViewAll:
whereClause = ""
case TopicViewNew:
tail := "t.top_message > IFNULL(s.last_message,-1)"
if !ignoreSticky {
tail = "(t.sticky = 1 OR " + tail + ")"
}
whereClause = "t.archived = 0 AND IFNULL(s.hidden,0) = 0 AND " + tail
case TopicViewActive, TopicViewAllVisible:
whereClause = "t.archived = 0 AND IFNULL(s.hidden,0) = 0"
case TopicViewHidden:
whereClause = "IFNULL(s.hidden,0) = 1"
case TopicViewArchive:
whereClause = "t.archived = 1 AND IFNULL(s.hidden,0) = 0"
default:
return nil, errors.New("invalid view option specified")
}
// Decode the sortOption into an ORDER BY clause.
var reverse bool = false
if sortOption < 0 {
reverse = true
sortOption = -sortOption
}
var orderByClause string
switch sortOption {
case TopicSortID:
orderByClause = "t.topicid ASC"
case TopicSortNumber:
if reverse {
orderByClause = "t.num DESC"
} else {
orderByClause = "t.num ASC"
}
case TopicSortName:
if reverse {
orderByClause = "t.name DESC, t.num DESC"
} else {
orderByClause = "t.name ASC, t.num ASC"
}
case TopicSortUnread:
if reverse {
orderByClause = "unread ASC, t.num DESC"
} else {
orderByClause = "unread DESC, t.num ASC"
}
case TopicSortTotal:
if reverse {
orderByClause = "total ASC, t.num DESC"
} else {
orderByClause = "total DESC, t.num ASC"
}
case TopicSortDate:
if reverse {
orderByClause = "t.lastupdate ASC, t.num DESC"
} else {
orderByClause = "t.lastupdate DESC, t.num ASC"
}
default:
return nil, errors.New("invalid sort option specified")
}
// Build the full SQL statement
var fullStatement strings.Builder
fullStatement.WriteString("SELECT t.topicid, t.num, t.name, (t.top_message - IFNULL(s.last_message,-1)) AS unread, ")
fullStatement.WriteString("(t.top_message + 1) AS total, t.lastupdate, t.frozen, t.archived, IFNULL(s.subscribe,0) AS subscribe, ")
fullStatement.WriteString("t.sticky, GREATEST(SIGN(t.top_message - IFNULL(s.last_message,-1)),0) AS newflag ")
fullStatement.WriteString("FROM topics t LEFT JOIN topicsettings s ON t.topicid = s.topicid AND s.uid = ? WHERE t.confid = ? ")
if whereClause != "" {
fullStatement.WriteString("AND ")
fullStatement.WriteString(whereClause)
}
fullStatement.WriteString(" ORDER BY ")
if ignoreSticky {
fullStatement.WriteString("t.sticky DESC, ")
}
if viewOption == TopicViewActive {
fullStatement.WriteString("newflag DESC, ")
}
fullStatement.WriteString(orderByClause)
// Execute and capture results
rs, err := amdb.Query(fullStatement.String(), uid, confid)
if err != nil {
return nil, err
}
rc := make([]*TopicSummary, 0)
for rs.Next() {
var rec TopicSummary
rs.Scan(&rec.TopicID, &rec.Number, &rec.Name, &rec.Unread, &rec.Total, &rec.LastUpdate, &rec.Frozen, &rec.Archived,
&rec.Subscribed)
rc = append(rc, &rec)
}
return rc, nil
}
+1
View File
@@ -84,6 +84,7 @@ func setupEcho() *echo.Echo {
e.GET("/comm/:cid/admin/logo", ui.AmWrap(CommunityLogoForm)) e.GET("/comm/:cid/admin/logo", ui.AmWrap(CommunityLogoForm))
e.POST("/comm/:cid/admin/logo", ui.AmWrap(EditCommunityLogo)) e.POST("/comm/:cid/admin/logo", ui.AmWrap(EditCommunityLogo))
e.GET("/comm/:cid/conf", ui.AmWrap(Conferences)) e.GET("/comm/:cid/conf", ui.AmWrap(Conferences))
e.GET("/comm/:cid/conf/:confid", ui.AmWrap(Topics))
return e return e
} }
+2 -2
View File
@@ -1009,7 +1009,7 @@ INSERT INTO commmember (commid, uid, granted_lvl, locked)
# (CONFID = 2) # (CONFID = 2)
INSERT INTO confs (confid, createdate, read_lvl, post_lvl, create_lvl, hide_lvl, nuke_lvl, change_lvl, INSERT INTO confs (confid, createdate, read_lvl, post_lvl, create_lvl, hide_lvl, nuke_lvl, change_lvl,
delete_lvl, top_topic, name, descr) delete_lvl, top_topic, name, descr)
VALUES (2, '2000-12-01 00:00:00', 6500, 6500, 6500, 52500, 52500, 52500, 58000, 0, 'General Discussion', VALUES (2, '2000-12-01 00:00:00', 2500, 2500, 2500, 60500, 60500, 60500, 61000, 0, 'General Discussion',
'Your place for general discussion about the system and general topics.'); 'Your place for general discussion about the system and general topics.');
INSERT INTO commtoconf (commid, confid, sequence) VALUES (2, 2, 10); INSERT INTO commtoconf (commid, confid, sequence) VALUES (2, 2, 10);
INSERT INTO confalias (confid, alias) VALUES (2, 'General'); INSERT INTO confalias (confid, alias) VALUES (2, 'General');
@@ -1022,7 +1022,7 @@ INSERT INTO confmember (confid, uid, granted_lvl) VALUES (2, 2, 52500);
# (CONFID = 3) # (CONFID = 3)
INSERT INTO confs (confid, createdate, read_lvl, post_lvl, create_lvl, hide_lvl, nuke_lvl, change_lvl, INSERT INTO confs (confid, createdate, read_lvl, post_lvl, create_lvl, hide_lvl, nuke_lvl, change_lvl,
delete_lvl, top_topic, name, descr) delete_lvl, top_topic, name, descr)
VALUES (3, '2000-12-01 00:00:00', 6500, 6500, 6500, 52500, 52500, 52500, 58000, 0, 'Test Postings', VALUES (3, '2000-12-01 00:00:00', 2500, 2500, 2500, 60500, 60500, 60500, 61000, 0, 'Test Postings',
'Use this conference to test the conferencing system.'); 'Use this conference to test the conferencing system.');
INSERT INTO commtoconf (commid, confid, sequence) VALUES (2, 3, 20); INSERT INTO commtoconf (commid, confid, sequence) VALUES (2, 3, 20);
INSERT INTO confalias (confid, alias) VALUES (3, 'Test'); INSERT INTO confalias (confid, alias) VALUES (3, 'Test');
+14
View File
@@ -50,6 +50,7 @@ type AmContext interface {
RC() int RC() int
OutputType() string OutputType() string
Parameter(string) string Parameter(string) string
QueryParamInt(string, int) int
RemoteIP() string RemoteIP() string
ReplaceUser(*database.User) ReplaceUser(*database.User)
SaveSession() error SaveSession() error
@@ -241,6 +242,19 @@ func (c *amContext) Parameter(name string) string {
return rc return rc
} }
// QueryParamInt returns the value of a query parameter as an integer, with a default.
func (c *amContext) QueryParamInt(name string, defval int) int {
s := c.echoContext.QueryParam(name)
if s == "" {
return defval
}
rc, err := strconv.Atoi(s)
if err != nil {
return defval
}
return rc
}
// RemoteIP returns the remote IP address. // RemoteIP returns the remote IP address.
func (c *amContext) RemoteIP() string { func (c *amContext) RemoteIP() string {
return c.echoContext.RealIP() return c.echoContext.RealIP()
+10
View File
@@ -96,6 +96,15 @@ func makeYearRange(a jet.Arguments) reflect.Value {
} }
} }
func immediateIf(a jet.Arguments) reflect.Value {
cond := a.Get(0).Convert(reflect.TypeFor[bool]()).Bool()
if cond {
return a.Get(1)
} else {
return a.Get(2)
}
}
// extractCommunityLogo extracts a community logo URL from a community. // extractCommunityLogo extracts a community logo URL from a community.
func extractCommunityLogo(a jet.Arguments) reflect.Value { func extractCommunityLogo(a jet.Arguments) reflect.Value {
rc := "/img/builtin/default-community.jpg" rc := "/img/builtin/default-community.jpg"
@@ -189,6 +198,7 @@ func SetupTemplates() {
views.AddGlobal("AmsterdamVersion", config.AMSTERDAM_VERSION) views.AddGlobal("AmsterdamVersion", config.AMSTERDAM_VERSION)
views.AddGlobal("AmsterdamCopyright", config.AMSTERDAM_COPYRIGHT) views.AddGlobal("AmsterdamCopyright", config.AMSTERDAM_COPYRIGHT)
views.AddGlobal("GlobalConfig", config.GlobalConfig) views.AddGlobal("GlobalConfig", config.GlobalConfig)
views.AddGlobalFunc("iif", immediateIf)
views.AddGlobalFunc("MakeIntRange", makeIntRange) views.AddGlobalFunc("MakeIntRange", makeIntRange)
views.AddGlobalFunc("MakeYearRange", makeYearRange) views.AddGlobalFunc("MakeYearRange", makeYearRange)
views.AddGlobalFunc("ExtractCommunityLogo", extractCommunityLogo) views.AddGlobalFunc("ExtractCommunityLogo", extractCommunityLogo)
+1 -1
View File
@@ -23,7 +23,7 @@
<span class="text-lg pt-0.5 flex-shrink-0">🟣</span> <span class="text-lg pt-0.5 flex-shrink-0">🟣</span>
<div class="flex-1"> <div class="flex-1">
<div class="mb-2"> <div class="mb-2">
<a href="/TODO/comm/{{ commAlias }}/conf/{{ c.AliasesQ()[0] }}" <a href="/comm/{{ commAlias }}/conf/{{ c.AliasesQ()[0] }}"
class="text-blue-700 hover:text-blue-900 font-bold text-lg">{{ c.Name }}</a> class="text-blue-700 hover:text-blue-900 font-bold text-lg">{{ c.Name }}</a>
<span class="text-gray-600 text-sm ml-2">- Latest activity: {{ DisplayActivity(c.LastUpdate, .) }}</span> <span class="text-gray-600 text-sm ml-2">- Latest activity: {{ DisplayActivity(c.LastUpdate, .) }}</span>
</div> </div>
+143
View File
@@ -0,0 +1,143 @@
{*
* Amsterdam Web Communities System
* Copyright (c) 2025 Erbosoft Metaverse Design Solutions, All Rights Reserved
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*}
<div class="p-4">
<!-- Page Title -->
<div class="mb-6">
<h1 class="text-blue-800 text-4xl font-bold mb-2">Topics in {{ conferenceName }}</h1>
<hr class="border-2 border-gray-400 w-4/5 mb-6">
</div>
<!-- Action Buttons -->
<div class="max-w-6xl mb-6">
<div class="flex gap-2 flex-wrap mb-4">
<a href="{{ urlBack }}"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm font-medium transition-colors">
Conference List
</a>
<a href="/TODO{{ urlStem }}/new_topic"
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded text-sm font-medium transition-colors">
Add Topic
</a>
<a href="/TODO/find"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm font-medium transition-colors">
Find
</a>
<a href="/TODO{{ urlStem }}/manage"
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded text-sm font-medium transition-colors">
Manage
</a>
</div>
<div class="text-xs text-gray-600">
<a href="{{ permalink }}"
class="text-blue-700 hover:text-blue-900">
[Permalink to this conference]
</a>
</div>
</div>
<!-- Topics Table -->
<div class="max-w-6xl">
<div class="bg-white border border-gray-300 rounded-lg overflow-hidden">
<table class="w-full">
<thead class="bg-gray-100 border-b border-gray-300">
<tr>
{{ sp := iif(sort == 1, -1, 1) }}
<th class="px-4 py-3 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
<a href="{{ urlStem }}?sort={{ sp }}"
class="text-blue-700 hover:text-blue-900">#</a>
</th>
{{ sp = iif(sort == 2, -2, 2) }}
<th class="px-4 py-3 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
<a href="{{ urlStem }}?sort={{ sp }}"
class="text-blue-700 hover:text-blue-900">Topic Name</a>
</th>
{{ sp = iif(sort == 3, -3, 3) }}
<th class="px-4 py-3 text-right text-xs font-bold text-gray-700 uppercase tracking-wider">
<a href="{{ urlStem }}?sort={{ sp }}"
class="text-blue-700 hover:text-blue-900">New</a>
</th>
{{ sp = iif(sort == 4, -4, 4) }}
<th class="px-4 py-3 text-right text-xs font-bold text-gray-700 uppercase tracking-wider">
<a href="{{ urlStem }}?sort={{ sp }}"
class="text-blue-700 hover:text-blue-900">Total</a>
</th>
{{ sp = iif(sort == 5, -5, 5) }}
<th class="px-4 py-3 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">
<a href="{{ urlStem }}?sort={{ sp }}"
class="text-blue-700 hover:text-blue-900">Last Response</a>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{{ range i, t := topics }}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 whitespace-nowrap text-sm">
<a href="{{ urlStem }}/topic/{{ t.Number }}?new=1"
class="text-blue-700 hover:text-blue-900 font-medium">{{ t.Number }}</a>
</td>
<td class="px-4 py-3 text-sm">
<a href="{{ urlStem }}/topic/{{ t.Number }}?new=1"
class="text-blue-700 hover:text-blue-900 font-medium">{{ t.Name }}</a>
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-right">
<a href="{{ urlStem }}/topic/{{ t.Number }}?new=1"
class="text-blue-700 hover:text-blue-900">{{ t.Unread }}</a>
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-right">
<a href="{{ urlStem }}/topic/{{ t.Number }}?st=0&en=-1"
class="text-blue-700 hover:text-blue-900">{{ t.Total }}</a>
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm">
<a href="{{ urlStem }}/topic/{{ t.Number }}?new=1"
class="text-blue-700 hover:text-blue-900">{{ formattedDate[i] }}</a>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
<!-- View Filter -->
<div class="mt-6 text-center">
<div class="inline-flex items-center gap-2 text-sm">
<span class="font-bold">[</span>
{{ if view == 1 }}
<span class="font-bold">New</span>
{{ else }}
<a href="{{ urlStem }}?view=1" class="text-blue-700 hover:text-blue-900">New</a>
{{ end }}
<span class="font-bold">|</span>
{{ if view == 2 }}
<span class="font-bold">Active</span>
{{ else }}
<a href="{{ urlStem }}?view=2" class="text-blue-700 hover:text-blue-900">Active</a>
{{ end }}
<span class="font-bold">|</span>
{{ if view == 3 }}
<span class="font-bold">All</span>
{{ else }}
<a href="{{ urlStem }}?view=3" class="text-blue-700 hover:text-blue-900">All</a>
{{ end }}
<span class="font-bold">|</span>
{{ if view == 4 }}
<span class="font-bold">Hidden</span>
{{ else }}
<a href="{{ urlStem }}?view=4" class="text-blue-700 hover:text-blue-900">Hidden</a>
{{ end }}
<span class="font-bold">|</span>
{{ if view == 5 }}
<span class="font-bold">Archived</span>
{{ else }}
<a href="{{ urlStem }}?view=5" class="text-blue-700 hover:text-blue-900">Archived</a>
{{ end }}
<span class="font-bold">]</span>
</div>
</div>
</div>
</div>