WIP Web Polls
Got initial Poll table and UI started: * Polls can be attached to any NEW forum post (can't edit poll details after creation) * Max 100 options (theoretically unlimited), expiration time. * UI: shows radio button list on posts having a poll, no submit handler yet created.
This commit is contained in:
parent
8e4bb85934
commit
bb79b5cbf3
|
@ -96,6 +96,11 @@ const (
|
||||||
// rapidly it does not increment the view counter more.
|
// rapidly it does not increment the view counter more.
|
||||||
ThreadViewDebounceRedisKey = "debounce-view/user=%d/thr=%d"
|
ThreadViewDebounceRedisKey = "debounce-view/user=%d/thr=%d"
|
||||||
ThreadViewDebounceCooldown = 1 * time.Hour
|
ThreadViewDebounceCooldown = 1 * time.Hour
|
||||||
|
|
||||||
|
// Max number of responses to accept for a poll (how many form
|
||||||
|
// values the app will read in). NOTE: also enforced in frontend
|
||||||
|
// UX in new_post.html, update there if you change this.
|
||||||
|
PollMaxAnswers = 100
|
||||||
)
|
)
|
||||||
|
|
||||||
// Variables set by main.go to make them readily available.
|
// Variables set by main.go to make them readily available.
|
||||||
|
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"code.nonshy.com/nonshy/website/pkg/markdown"
|
"code.nonshy.com/nonshy/website/pkg/markdown"
|
||||||
"code.nonshy.com/nonshy/website/pkg/models"
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
|
@ -45,6 +46,11 @@ func NewPost() http.HandlerFunc {
|
||||||
// well (pinned, explicit, noreply)
|
// well (pinned, explicit, noreply)
|
||||||
isOriginalComment bool
|
isOriginalComment bool
|
||||||
|
|
||||||
|
// Polls
|
||||||
|
pollOptions = []string{}
|
||||||
|
pollExpires = 3
|
||||||
|
isPoll bool
|
||||||
|
|
||||||
// Attached photo object.
|
// Attached photo object.
|
||||||
commentPhoto *models.CommentPhoto
|
commentPhoto *models.CommentPhoto
|
||||||
)
|
)
|
||||||
|
@ -171,6 +177,15 @@ func NewPost() http.HandlerFunc {
|
||||||
|
|
||||||
// Submitting the form.
|
// Submitting the form.
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
|
// Polls: parse form parameters into a neat list of answers.
|
||||||
|
pollExpires, _ = strconv.Atoi(r.FormValue("poll_expires"))
|
||||||
|
for i := 0; i < config.PollMaxAnswers; i++ {
|
||||||
|
if value := r.FormValue(fmt.Sprintf("answer%d", i)); value != "" {
|
||||||
|
pollOptions = append(pollOptions, value)
|
||||||
|
isPoll = len(pollOptions) >= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Is a photo coming along?
|
// Is a photo coming along?
|
||||||
if forum.PermitPhotos {
|
if forum.PermitPhotos {
|
||||||
// Removing or replacing?
|
// Removing or replacing?
|
||||||
|
@ -356,6 +371,21 @@ func NewPost() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Are we attaching a poll to this new thread?
|
||||||
|
if isPoll {
|
||||||
|
log.Info("It's a Poll! Options: %+v", pollOptions)
|
||||||
|
poll := models.CreatePoll(pollOptions, pollExpires)
|
||||||
|
if err := poll.Save(); err != nil {
|
||||||
|
session.FlashError(w, r, "Error creating poll: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach it to this thread.
|
||||||
|
thread.PollID = &poll.ID
|
||||||
|
if err := thread.Save(); err != nil {
|
||||||
|
log.Error("Couldn't save PollID onto thread! %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Subscribe the current user to responses on this thread.
|
// Subscribe the current user to responses on this thread.
|
||||||
if _, err := models.SubscribeTo(currentUser, "threads", thread.ID); err != nil {
|
if _, err := models.SubscribeTo(currentUser, "threads", thread.ID); err != nil {
|
||||||
log.Error("Couldn't subscribe user %d to forum thread %d: %s", currentUser.ID, thread.ID, err)
|
log.Error("Couldn't subscribe user %d to forum thread %d: %s", currentUser.ID, thread.ID, err)
|
||||||
|
@ -381,6 +411,9 @@ func NewPost() http.HandlerFunc {
|
||||||
"IsExplicit": isExplicit,
|
"IsExplicit": isExplicit,
|
||||||
"IsNoReply": isNoReply,
|
"IsNoReply": isNoReply,
|
||||||
|
|
||||||
|
// Polls
|
||||||
|
"PollOptions": pollOptions,
|
||||||
|
|
||||||
// Attached photo.
|
// Attached photo.
|
||||||
"CommentPhoto": commentPhoto,
|
"CommentPhoto": commentPhoto,
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,4 +24,5 @@ func AutoMigrate() {
|
||||||
DB.AutoMigrate(&Notification{})
|
DB.AutoMigrate(&Notification{})
|
||||||
DB.AutoMigrate(&Subscription{})
|
DB.AutoMigrate(&Subscription{})
|
||||||
DB.AutoMigrate(&CommentPhoto{})
|
DB.AutoMigrate(&CommentPhoto{})
|
||||||
|
DB.AutoMigrate(&Poll{})
|
||||||
}
|
}
|
||||||
|
|
50
pkg/models/poll.go
Normal file
50
pkg/models/poll.go
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Poll table for user surveys posted in the forums.
|
||||||
|
type Poll struct {
|
||||||
|
ID uint64 `gorm:"primaryKey"`
|
||||||
|
|
||||||
|
// Poll options
|
||||||
|
Choices string // line-separated choices
|
||||||
|
MultipleChoice bool // User can vote multiple choices
|
||||||
|
CustomAnswers bool // Users can contribute a custom response
|
||||||
|
|
||||||
|
Expires bool // if it stops accepting new votes
|
||||||
|
ExpiresAt time.Time // when it stops accepting new votes
|
||||||
|
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePoll initializes a poll.
|
||||||
|
//
|
||||||
|
// expires is in days (0 = doesn't expire)
|
||||||
|
func CreatePoll(choices []string, expires int) *Poll {
|
||||||
|
return &Poll{
|
||||||
|
Choices: strings.Join(choices, "\n"),
|
||||||
|
ExpiresAt: time.Now().Add(time.Duration(expires) * 24 * time.Hour),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPoll by ID.
|
||||||
|
func GetPoll(id uint64) (*Poll, error) {
|
||||||
|
m := &Poll{}
|
||||||
|
result := DB.First(&m, id)
|
||||||
|
return m, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options returns a conveniently formatted listing of the options.
|
||||||
|
func (p *Poll) Options() []string {
|
||||||
|
return strings.Split(p.Choices, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save Poll.
|
||||||
|
func (p *Poll) Save() error {
|
||||||
|
result := DB.Save(p)
|
||||||
|
return result.Error
|
||||||
|
}
|
14
pkg/models/poll_votes.go
Normal file
14
pkg/models/poll_votes.go
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// PollVote table records answers to polls.
|
||||||
|
type PollVote struct {
|
||||||
|
ID uint64 `gorm:"primaryKey"`
|
||||||
|
PollID uint64 `gorm:"index"`
|
||||||
|
Poll Poll
|
||||||
|
UserID uint64 `gorm:"index"`
|
||||||
|
Answer string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
|
@ -23,6 +23,8 @@ type Thread struct {
|
||||||
Title string
|
Title string
|
||||||
CommentID uint64 `gorm:"index"`
|
CommentID uint64 `gorm:"index"`
|
||||||
Comment Comment // first comment of the thread
|
Comment Comment // first comment of the thread
|
||||||
|
PollID *uint64 `gorm:"poll_id"`
|
||||||
|
Poll Poll // if the thread has a poll attachment
|
||||||
Views uint64
|
Views uint64
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
|
@ -30,7 +32,7 @@ type Thread struct {
|
||||||
|
|
||||||
// Preload related tables for the forum (classmethod).
|
// Preload related tables for the forum (classmethod).
|
||||||
func (f *Thread) Preload() *gorm.DB {
|
func (f *Thread) Preload() *gorm.DB {
|
||||||
return DB.Preload("Forum").Preload("Comment.User.ProfilePhoto")
|
return DB.Preload("Forum").Preload("Comment.User.ProfilePhoto").Preload("Poll")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetThread by ID.
|
// GetThread by ID.
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
@ -28,6 +29,7 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
|
||||||
"ComputeAge": utility.Age,
|
"ComputeAge": utility.Age,
|
||||||
"Split": strings.Split,
|
"Split": strings.Split,
|
||||||
"ToMarkdown": ToMarkdown,
|
"ToMarkdown": ToMarkdown,
|
||||||
|
"ToJSON": ToJSON,
|
||||||
"PhotoURL": photo.URLPath,
|
"PhotoURL": photo.URLPath,
|
||||||
"Now": time.Now,
|
"Now": time.Now,
|
||||||
"PrettyTitle": func() template.HTML {
|
"PrettyTitle": func() template.HTML {
|
||||||
|
@ -77,6 +79,15 @@ func ToMarkdown(input string) template.HTML {
|
||||||
return template.HTML(markdown.Render(input))
|
return template.HTML(markdown.Render(input))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ToJSON will stringify any json-serializable object.
|
||||||
|
func ToJSON(v any) template.JS {
|
||||||
|
bin, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return template.JS(err.Error())
|
||||||
|
}
|
||||||
|
return template.JS(string(bin))
|
||||||
|
}
|
||||||
|
|
||||||
// Pluralize text based on a quantity number. Provide up to 2 labels for the
|
// Pluralize text based on a quantity number. Provide up to 2 labels for the
|
||||||
// singular and plural cases, or the defaults are "", "s"
|
// singular and plural cases, or the defaults are "", "s"
|
||||||
func Pluralize[V Number](count V, labels ...string) string {
|
func Pluralize[V Number](count V, labels ...string) string {
|
||||||
|
|
16081
web/static/js/vue-3.2.45.js
Normal file
16081
web/static/js/vue-3.2.45.js
Normal file
File diff suppressed because it is too large
Load Diff
|
@ -1,4 +1,5 @@
|
||||||
{{define "title"}}Untitled{{end}}
|
{{define "title"}}Untitled{{end}}
|
||||||
|
{{define "scripts"}}{{end}}
|
||||||
{{define "base"}}
|
{{define "base"}}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
@ -306,6 +307,8 @@
|
||||||
|
|
||||||
<script type="text/javascript" src="/static/js/bulma.js?build={{.BuildHash}}"></script>
|
<script type="text/javascript" src="/static/js/bulma.js?build={{.BuildHash}}"></script>
|
||||||
<script type="text/javascript" src="/static/js/likes.js?build={{.BuildHash}}"></script>
|
<script type="text/javascript" src="/static/js/likes.js?build={{.BuildHash}}"></script>
|
||||||
|
<script type="text/javascript" src="/static/js/vue-3.2.45.js"></script>
|
||||||
|
{{template "scripts" .}}
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="container">
|
<div class="container" id="app">
|
||||||
<section class="hero is-info is-bold">
|
<section class="hero is-info is-bold">
|
||||||
<div class="hero-body">
|
<div class="hero-body">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
@ -81,6 +81,70 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{if not .EditCommentID}}
|
||||||
|
<div class="field block">
|
||||||
|
<label for="message" class="label">Poll <span class="tag is-success">NEW!</span></label>
|
||||||
|
<div v-if="pollVisible">
|
||||||
|
<!-- Answer rows -->
|
||||||
|
<div v-for="(answer, i) in answers"
|
||||||
|
v-bind:key="i"
|
||||||
|
class="columns mb-0 is-mobile">
|
||||||
|
<div class="column pr-0">
|
||||||
|
<input type="text"
|
||||||
|
class="input"
|
||||||
|
v-model="answer.value"
|
||||||
|
:name="'answer'+i"
|
||||||
|
:placeholder="`Option ${i+1}`">
|
||||||
|
</div>
|
||||||
|
<div class="column pl-2 is-narrow">
|
||||||
|
<button type="button"
|
||||||
|
class="button is-danger is-outlined"
|
||||||
|
@click="removeOption(i)">
|
||||||
|
<i class="fa fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add option -->
|
||||||
|
<div v-if="answers.length < answersLimit">
|
||||||
|
<button type="button"
|
||||||
|
class="button is-small is-success is-outlined"
|
||||||
|
@click="addOption()">
|
||||||
|
<i class="fa fa-plus mr-3"></i>
|
||||||
|
Add another option
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings -->
|
||||||
|
<div class="columns mt-2 is-mobile">
|
||||||
|
<div class="column is-narrow">
|
||||||
|
Poll expires:
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<div class="select is-small is-fullwidth">
|
||||||
|
<select v-model="expires"
|
||||||
|
name="poll_expires">
|
||||||
|
<option :value="0">Never</option>
|
||||||
|
<option :value="1">1 Day</option>
|
||||||
|
<option :value="2">2 Days</option>
|
||||||
|
<option :value="3">3 Days</option>
|
||||||
|
<option :value="4">4 Days</option>
|
||||||
|
<option :value="5">5 Days</option>
|
||||||
|
<option :value="6">6 Days</option>
|
||||||
|
<option :value="7">7 Days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<button type="button"
|
||||||
|
class="button is-success is-small"
|
||||||
|
@click="pollVisible = true">Make this a poll</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<!-- Photo attachment widget -->
|
<!-- Photo attachment widget -->
|
||||||
{{if .Forum.PermitPhotos}}
|
{{if .Forum.PermitPhotos}}
|
||||||
<!-- Intent: upload, remove, or replace -->
|
<!-- Intent: upload, remove, or replace -->
|
||||||
|
@ -133,6 +197,8 @@
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
{{if or (not .Thread) .EditThreadSettings}}
|
{{if or (not .Thread) .EditThreadSettings}}
|
||||||
<div class="field block">
|
<div class="field block">
|
||||||
{{if or .CurrentUser.IsAdmin (and .Forum (eq .Forum.OwnerID .CurrentUser.ID))}}
|
{{if or .CurrentUser.IsAdmin (and .Forum (eq .Forum.OwnerID .CurrentUser.ID))}}
|
||||||
|
@ -278,3 +344,63 @@
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script type="text/javascript">
|
||||||
|
const { createApp } = Vue;
|
||||||
|
|
||||||
|
// Some help from backend..
|
||||||
|
const pollOptions = {{ ToJSON .PollOptions }};
|
||||||
|
|
||||||
|
const app = createApp({
|
||||||
|
delimiters: ['[[', ']]'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
pollVisible: true,
|
||||||
|
answers: [
|
||||||
|
{value: ""},
|
||||||
|
{value: ""},
|
||||||
|
],
|
||||||
|
expires: 3, // days
|
||||||
|
answersLimit: 100,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
// Set the posted poll options.
|
||||||
|
if (pollOptions.length > 0) {
|
||||||
|
this.answers = [];
|
||||||
|
for (let row of pollOptions) {
|
||||||
|
this.answers.push({value: row});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
addOption() {
|
||||||
|
console.log(this.answers);
|
||||||
|
if (this.answers.length < this.answersLimit) {
|
||||||
|
this.answers.push({value: ""});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeOption(idx) {
|
||||||
|
if (this.answers.length <= 2) {
|
||||||
|
if (!window.confirm("A poll needs at least two options. Remove the poll?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.removePoll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.answers.splice(idx, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
removePoll() {
|
||||||
|
this.answers = [
|
||||||
|
{value: ""},
|
||||||
|
{value: ""},
|
||||||
|
],
|
||||||
|
this.pollVisible = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
app.mount("#app");
|
||||||
|
</script>
|
||||||
|
{{end}}
|
|
@ -119,17 +119,17 @@
|
||||||
|
|
||||||
{{$Root := .}}
|
{{$Root := .}}
|
||||||
<div class="block p-2">
|
<div class="block p-2">
|
||||||
{{range .Comments}}
|
{{range $i, $c := .Comments}}
|
||||||
<div class="box has-background-link-light">
|
<div class="box has-background-link-light">
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column is-2 has-text-centered">
|
<div class="column is-2 has-text-centered">
|
||||||
<div>
|
<div>
|
||||||
<a href="/u/{{.User.Username}}">
|
<a href="/u/{{$c.User.Username}}">
|
||||||
{{template "avatar-96x96" .User}}
|
{{template "avatar-96x96" $c.User}}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<a href="/u/{{.User.Username}}">{{.User.Username}}</a>
|
<a href="/u/{{$c.User.Username}}">{{$c.User.Username}}</a>
|
||||||
{{if .User.IsAdmin}}
|
{{if $c.User.IsAdmin}}
|
||||||
<div class="is-size-7 mt-1">
|
<div class="is-size-7 mt-1">
|
||||||
<span class="tag is-danger is-light">
|
<span class="tag is-danger is-light">
|
||||||
<span class="icon"><i class="fa fa-gavel"></i></span>
|
<span class="icon"><i class="fa fa-gavel"></i></span>
|
||||||
|
@ -139,18 +139,46 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="column content">
|
<div class="column content">
|
||||||
{{ToMarkdown .Message}}
|
{{ToMarkdown $c.Message}}
|
||||||
|
|
||||||
{{if .IsEdited}}
|
{{if $c.IsEdited}}
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<em title="{{.UpdatedAt.Format "2006-01-02 15:04:05"}}">
|
<em title="{{$c.UpdatedAt.Format "2006-01-02 15:04:05"}}">
|
||||||
<small>Edited {{SincePrettyCoarse .UpdatedAt}} ago</small>
|
<small>Edited {{SincePrettyCoarse $c.UpdatedAt}} ago</small>
|
||||||
</em>
|
</em>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Poll attachment? -->
|
||||||
|
{{if and (eq $i 0) $Root.Thread.PollID}}
|
||||||
|
<h2>Poll</h2>
|
||||||
|
|
||||||
|
<form name="ballot" action="/poll/vote" method="POST">
|
||||||
|
{{InputCSRF}}
|
||||||
|
<input type="hidden" name="poll_id" value="{{$Root.Thread.PollID}}">
|
||||||
|
<input type="hidden" name="from_thread_id" value="{{$Root.Thread.ID}}">
|
||||||
|
|
||||||
|
|
||||||
|
{{range $Root.Thread.Poll.Options}}
|
||||||
|
<div class="control">
|
||||||
|
<label class="radio box nonshy-fullwidth p-3 mb-3">
|
||||||
|
<input type="radio"
|
||||||
|
name="answer"
|
||||||
|
value="{{.}}">
|
||||||
|
{{.}}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="button is-primary is-outline">
|
||||||
|
Submit response
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<!-- Photo attachments? -->
|
<!-- Photo attachments? -->
|
||||||
{{$Photos := $Root.PhotoMap.Get .ID}}
|
{{$Photos := $Root.PhotoMap.Get $c.ID}}
|
||||||
{{if $Photos}}
|
{{if $Photos}}
|
||||||
{{range $Photos}}
|
{{range $Photos}}
|
||||||
{{if not .ExpiredAt.IsZero}}
|
{{if not .ExpiredAt.IsZero}}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user