Web polls #12
|
@ -96,11 +96,26 @@ 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
|
||||||
|
)
|
||||||
|
|
||||||
|
// Poll settings
|
||||||
|
var (
|
||||||
// Max number of responses to accept for a poll (how many form
|
// Max number of responses to accept for a poll (how many form
|
||||||
// values the app will read in). NOTE: also enforced in frontend
|
// values the app will read in). NOTE: also enforced in frontend
|
||||||
// UX in new_post.html, update there if you change this.
|
// UX in new_post.html, update there if you change this.
|
||||||
PollMaxAnswers = 100
|
PollMaxAnswers = 100
|
||||||
|
|
||||||
|
// Poll color CSS classes (Bulma). Plugged in to templates like:
|
||||||
|
// <progress class="$CLASS">
|
||||||
|
// Values will wrap around for long polls.
|
||||||
|
PollProgressBarClasses = []string{
|
||||||
|
"progress is-success",
|
||||||
|
"progress is-link",
|
||||||
|
"progress is-warning",
|
||||||
|
"progress is-danger",
|
||||||
|
"progress is-primary",
|
||||||
|
"progress is-info",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Variables set by main.go to make them readily available.
|
// Variables set by main.go to make them readily available.
|
||||||
|
|
|
@ -47,9 +47,10 @@ func NewPost() http.HandlerFunc {
|
||||||
isOriginalComment bool
|
isOriginalComment bool
|
||||||
|
|
||||||
// Polls
|
// Polls
|
||||||
pollOptions = []string{}
|
pollOptions = []string{}
|
||||||
pollExpires = 3
|
pollExpires = 3
|
||||||
isPoll bool
|
pollMultipleChoice = r.FormValue("poll_multiple_choice") == "true"
|
||||||
|
isPoll bool
|
||||||
|
|
||||||
// Attached photo object.
|
// Attached photo object.
|
||||||
commentPhoto *models.CommentPhoto
|
commentPhoto *models.CommentPhoto
|
||||||
|
@ -179,13 +180,27 @@ func NewPost() http.HandlerFunc {
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
// Polls: parse form parameters into a neat list of answers.
|
// Polls: parse form parameters into a neat list of answers.
|
||||||
pollExpires, _ = strconv.Atoi(r.FormValue("poll_expires"))
|
pollExpires, _ = strconv.Atoi(r.FormValue("poll_expires"))
|
||||||
|
var distinctPollChoices = map[string]interface{}{}
|
||||||
for i := 0; i < config.PollMaxAnswers; i++ {
|
for i := 0; i < config.PollMaxAnswers; i++ {
|
||||||
if value := r.FormValue(fmt.Sprintf("answer%d", i)); value != "" {
|
if value := r.FormValue(fmt.Sprintf("answer%d", i)); value != "" {
|
||||||
pollOptions = append(pollOptions, value)
|
pollOptions = append(pollOptions, value)
|
||||||
isPoll = len(pollOptions) >= 2
|
isPoll = len(pollOptions) >= 2
|
||||||
|
|
||||||
|
// Make sure every option is distinct!
|
||||||
|
if _, ok := distinctPollChoices[value]; ok {
|
||||||
|
session.FlashError(w, r, "Your poll options must all be unique! Duplicate option '%s' seen in your post!", value)
|
||||||
|
intent = "preview" // do not continue to submit
|
||||||
|
}
|
||||||
|
distinctPollChoices[value] = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If only one poll option, warn about it.
|
||||||
|
if len(pollOptions) == 1 {
|
||||||
|
session.FlashError(w, r, "Your poll should have at least two choices.")
|
||||||
|
intent = "preview" // do not continue to submit
|
||||||
|
}
|
||||||
|
|
||||||
// Is a photo coming along?
|
// Is a photo coming along?
|
||||||
if forum.PermitPhotos {
|
if forum.PermitPhotos {
|
||||||
// Removing or replacing?
|
// Removing or replacing?
|
||||||
|
@ -375,6 +390,7 @@ func NewPost() http.HandlerFunc {
|
||||||
if isPoll {
|
if isPoll {
|
||||||
log.Info("It's a Poll! Options: %+v", pollOptions)
|
log.Info("It's a Poll! Options: %+v", pollOptions)
|
||||||
poll := models.CreatePoll(pollOptions, pollExpires)
|
poll := models.CreatePoll(pollOptions, pollExpires)
|
||||||
|
poll.MultipleChoice = pollMultipleChoice
|
||||||
if err := poll.Save(); err != nil {
|
if err := poll.Save(); err != nil {
|
||||||
session.FlashError(w, r, "Error creating poll: %s", err)
|
session.FlashError(w, r, "Error creating poll: %s", err)
|
||||||
}
|
}
|
||||||
|
|
91
pkg/controller/poll/vote.go
Normal file
91
pkg/controller/poll/vote.go
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
package poll
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/models"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Vote controller for polls.
|
||||||
|
func Vote() http.HandlerFunc {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
// Form parameters
|
||||||
|
pollID uint64
|
||||||
|
fromThreadID uint64
|
||||||
|
answers = r.Form["answer"] // a slice in case of MultipleChoice
|
||||||
|
nextURL string
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parse integer params.
|
||||||
|
if value, err := strconv.Atoi(r.FormValue("poll_id")); err != nil {
|
||||||
|
session.FlashError(w, r, "Invalid poll ID")
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
pollID = uint64(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currently polls only exist in forum threads, require the thread ID.
|
||||||
|
if value, err := strconv.Atoi(r.FormValue("from_thread_id")); err != nil {
|
||||||
|
session.FlashError(w, r, "Invalid thread ID")
|
||||||
|
templates.Redirect(w, "/")
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
fromThreadID = uint64(value)
|
||||||
|
nextURL = fmt.Sprintf("/forum/thread/%d", fromThreadID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST request only.
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
session.FlashError(w, r, "POST requests only.")
|
||||||
|
templates.Redirect(w, nextURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// An answer is required.
|
||||||
|
if len(answers) == 0 || len(answers) == 1 && answers[0] == "" {
|
||||||
|
session.FlashError(w, r, "An answer to this poll is required for voting.")
|
||||||
|
templates.Redirect(w, nextURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current user.
|
||||||
|
user, err := session.CurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Unexpected error: couldn't get CurrentUser")
|
||||||
|
templates.Redirect(w, nextURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the poll.
|
||||||
|
poll, err := models.GetPoll(pollID)
|
||||||
|
if err != nil {
|
||||||
|
session.FlashError(w, r, "Poll not found.")
|
||||||
|
templates.Redirect(w, nextURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is it accepting responses?
|
||||||
|
result := poll.Result(user)
|
||||||
|
if !result.AcceptingVotes {
|
||||||
|
session.FlashError(w, r, "This poll is not accepting your vote at this time.")
|
||||||
|
templates.Redirect(w, nextURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cast the vote!
|
||||||
|
if err := poll.CastVote(user, answers); err != nil {
|
||||||
|
session.FlashError(w, r, "Couldn't cast the vote: %s", err)
|
||||||
|
templates.Redirect(w, nextURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Flash(w, r, "Your vote has been recorded!")
|
||||||
|
templates.Redirect(w, nextURL)
|
||||||
|
})
|
||||||
|
}
|
|
@ -35,7 +35,7 @@ func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]
|
||||||
|
|
||||||
// Hide explicit forum if user hasn't opted into it.
|
// Hide explicit forum if user hasn't opted into it.
|
||||||
if !user.Explicit && !user.IsAdmin {
|
if !user.Explicit && !user.IsAdmin {
|
||||||
wheres = append(wheres, "explicit = false")
|
wheres = append(wheres, "forums.explicit = false")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the page of recent forum comment IDs of all time.
|
// Get the page of recent forum comment IDs of all time.
|
||||||
|
|
|
@ -25,4 +25,5 @@ func AutoMigrate() {
|
||||||
DB.AutoMigrate(&Subscription{})
|
DB.AutoMigrate(&Subscription{})
|
||||||
DB.AutoMigrate(&CommentPhoto{})
|
DB.AutoMigrate(&CommentPhoto{})
|
||||||
DB.AutoMigrate(&Poll{})
|
DB.AutoMigrate(&Poll{})
|
||||||
|
DB.AutoMigrate(&PollVote{})
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Poll table for user surveys posted in the forums.
|
// Poll table for user surveys posted in the forums.
|
||||||
|
@ -27,6 +30,7 @@ type Poll struct {
|
||||||
func CreatePoll(choices []string, expires int) *Poll {
|
func CreatePoll(choices []string, expires int) *Poll {
|
||||||
return &Poll{
|
return &Poll{
|
||||||
Choices: strings.Join(choices, "\n"),
|
Choices: strings.Join(choices, "\n"),
|
||||||
|
Expires: expires > 0,
|
||||||
ExpiresAt: time.Now().Add(time.Duration(expires) * 24 * time.Hour),
|
ExpiresAt: time.Now().Add(time.Duration(expires) * 24 * time.Hour),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -43,8 +47,89 @@ func (p *Poll) Options() []string {
|
||||||
return strings.Split(p.Choices, "\n")
|
return strings.Split(p.Choices, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsExpired returns if the poll has ended.
|
||||||
|
func (p *Poll) IsExpired() bool {
|
||||||
|
return p.Expires && time.Now().After(p.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InputType returns "radio" or "checkbox" for multiple choice polls.
|
||||||
|
func (p *Poll) InputType() string {
|
||||||
|
if p.MultipleChoice {
|
||||||
|
return "checkbox"
|
||||||
|
}
|
||||||
|
return "radio"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result returns metadata about a poll's status and results, for frontend assist.
|
||||||
|
func (p *Poll) Result(currentUser *User) PollResult {
|
||||||
|
var (
|
||||||
|
result = PollResult{
|
||||||
|
AcceptingVotes: true,
|
||||||
|
CurrentUserVote: []string{},
|
||||||
|
Results: map[string]int{},
|
||||||
|
ResultsPercent: map[string]float64{},
|
||||||
|
ResultsClass: map[string]string{},
|
||||||
|
}
|
||||||
|
votes = p.GetAllVotes()
|
||||||
|
distinctAnswers int
|
||||||
|
)
|
||||||
|
|
||||||
|
// Populate the CSS classes.
|
||||||
|
for i, answer := range p.Options() {
|
||||||
|
result.ResultsClass[answer] = config.PollProgressBarClasses[i%len(config.PollProgressBarClasses)]
|
||||||
|
}
|
||||||
|
|
||||||
|
result.TotalVotes = len(votes)
|
||||||
|
for _, res := range votes {
|
||||||
|
if res.UserID == currentUser.ID {
|
||||||
|
result.CurrentUserVote = append(result.CurrentUserVote, res.Answer)
|
||||||
|
result.AcceptingVotes = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := result.Results[res.Answer]; !ok {
|
||||||
|
distinctAnswers++
|
||||||
|
result.Results[res.Answer] = 0
|
||||||
|
result.ResultsPercent[res.Answer] = 0
|
||||||
|
}
|
||||||
|
result.Results[res.Answer]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the percent splits.
|
||||||
|
if result.TotalVotes > 0 {
|
||||||
|
for answer, count := range result.Results {
|
||||||
|
result.ResultsPercent[answer] = float64(count) / float64(result.TotalVotes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired polls don't accept answers.
|
||||||
|
if p.IsExpired() {
|
||||||
|
result.AcceptingVotes = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// Save Poll.
|
// Save Poll.
|
||||||
func (p *Poll) Save() error {
|
func (p *Poll) Save() error {
|
||||||
result := DB.Save(p)
|
result := DB.Save(p)
|
||||||
return result.Error
|
return result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PollResult holds metadata about the poll result for frontend display.
|
||||||
|
type PollResult struct {
|
||||||
|
AcceptingVotes bool // user voted or it expired
|
||||||
|
CurrentUserVote []string // current user's selection, if any
|
||||||
|
Results map[string]int // answers and their %
|
||||||
|
ResultsPercent map[string]float64
|
||||||
|
ResultsClass map[string]string // progress bar classes
|
||||||
|
TotalVotes int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pr PollResult) GetPercent(answer string) string {
|
||||||
|
value := pr.ResultsPercent[answer]
|
||||||
|
return fmt.Sprintf("%.1f", value*100)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pr PollResult) GetClass(answer string) string {
|
||||||
|
return pr.ResultsClass[answer]
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,11 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
|
)
|
||||||
|
|
||||||
// PollVote table records answers to polls.
|
// PollVote table records answers to polls.
|
||||||
type PollVote struct {
|
type PollVote struct {
|
||||||
|
@ -12,3 +17,52 @@ type PollVote struct {
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CastVote on a poll. Multiple answers OK for multiple choice polls.
|
||||||
|
func (p *Poll) CastVote(user *User, answers []string) error {
|
||||||
|
if len(answers) > 1 && !p.MultipleChoice {
|
||||||
|
return errors.New("multiple answers not accepted for this poll")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this user has already voted, remove their vote.
|
||||||
|
result := DB.Where(
|
||||||
|
"poll_id = ? AND user_id = ?",
|
||||||
|
p.ID, user.ID,
|
||||||
|
).Delete(&PollVote{})
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert their votes.
|
||||||
|
var err error
|
||||||
|
for _, answer := range answers {
|
||||||
|
vote := &PollVote{
|
||||||
|
PollID: p.ID,
|
||||||
|
UserID: user.ID,
|
||||||
|
Answer: answer,
|
||||||
|
}
|
||||||
|
err = vote.Save()
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllVotes for a poll.
|
||||||
|
func (p *Poll) GetAllVotes() []*PollVote {
|
||||||
|
var pv = []*PollVote{}
|
||||||
|
|
||||||
|
result := DB.Where(
|
||||||
|
"poll_id = ?", p.ID,
|
||||||
|
).Find(&pv)
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Error("Poll(%d).GetAllVotes(): %s", p.ID, result.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pv
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save Poll.
|
||||||
|
func (v *PollVote) Save() error {
|
||||||
|
result := DB.Save(v)
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"code.nonshy.com/nonshy/website/pkg/controller/inbox"
|
"code.nonshy.com/nonshy/website/pkg/controller/inbox"
|
||||||
"code.nonshy.com/nonshy/website/pkg/controller/index"
|
"code.nonshy.com/nonshy/website/pkg/controller/index"
|
||||||
"code.nonshy.com/nonshy/website/pkg/controller/photo"
|
"code.nonshy.com/nonshy/website/pkg/controller/photo"
|
||||||
|
"code.nonshy.com/nonshy/website/pkg/controller/poll"
|
||||||
"code.nonshy.com/nonshy/website/pkg/middleware"
|
"code.nonshy.com/nonshy/website/pkg/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -67,6 +68,7 @@ func New() http.Handler {
|
||||||
mux.Handle("/forum/thread/", middleware.CertRequired(forum.Thread()))
|
mux.Handle("/forum/thread/", middleware.CertRequired(forum.Thread()))
|
||||||
mux.Handle("/forum/newest", middleware.CertRequired(forum.Newest()))
|
mux.Handle("/forum/newest", middleware.CertRequired(forum.Newest()))
|
||||||
mux.Handle("/f/", middleware.CertRequired(forum.Forum()))
|
mux.Handle("/f/", middleware.CertRequired(forum.Forum()))
|
||||||
|
mux.Handle("/poll/vote", middleware.CertRequired(poll.Vote()))
|
||||||
|
|
||||||
// Admin endpoints.
|
// Admin endpoints.
|
||||||
mux.Handle("/admin", middleware.AdminRequired(admin.Dashboard()))
|
mux.Handle("/admin", middleware.AdminRequired(admin.Dashboard()))
|
||||||
|
|
|
@ -8,6 +8,11 @@ import (
|
||||||
|
|
||||||
// FormatDurationCoarse returns a pretty printed duration with coarse granularity.
|
// FormatDurationCoarse returns a pretty printed duration with coarse granularity.
|
||||||
func FormatDurationCoarse(duration time.Duration) string {
|
func FormatDurationCoarse(duration time.Duration) string {
|
||||||
|
// Negative durations (e.g. future dates) should work too.
|
||||||
|
if duration < 0 {
|
||||||
|
duration *= -1
|
||||||
|
}
|
||||||
|
|
||||||
var result = func(text string, v int64) string {
|
var result = func(text string, v int64) string {
|
||||||
if v == 1 {
|
if v == 1 {
|
||||||
text = strings.TrimSuffix(text, "s")
|
text = strings.TrimSuffix(text, "s")
|
||||||
|
|
|
@ -105,6 +105,13 @@
|
||||||
</span>
|
</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if .PollID}}
|
||||||
|
<span class="tag is-info is-light mr-2">
|
||||||
|
<span class="icon"><i class="fa fa-poll"></i></span>
|
||||||
|
<span>Poll</span>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .Explicit}}
|
{{if .Explicit}}
|
||||||
<span class="tag is-danger is-light mr-2">
|
<span class="tag is-danger is-light mr-2">
|
||||||
<span class="icon"><i class="fa fa-fire"></i></span>
|
<span class="icon"><i class="fa fa-fire"></i></span>
|
||||||
|
|
|
@ -81,10 +81,20 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if not .EditCommentID}}
|
{{if and (not .EditCommentID) (not .Thread)}}
|
||||||
<div class="field block">
|
<div class="field block">
|
||||||
<label for="message" class="label">Poll <span class="tag is-success">NEW!</span></label>
|
<label for="message" class="label">Poll <span class="tag is-success">NEW!</span></label>
|
||||||
<div v-if="pollVisible">
|
<div v-if="pollVisible">
|
||||||
|
<!-- MultipleChoice option -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="poll_multiple_choice"
|
||||||
|
value="true">
|
||||||
|
<small class="ml-2">Allow multiple selections per vote</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Answer rows -->
|
<!-- Answer rows -->
|
||||||
<div v-for="(answer, i) in answers"
|
<div v-for="(answer, i) in answers"
|
||||||
v-bind:key="i"
|
v-bind:key="i"
|
||||||
|
@ -116,9 +126,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings -->
|
<!-- Settings -->
|
||||||
<div class="columns mt-2 is-mobile">
|
<div class="columns mt-2 mb-0 is-mobile">
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
Poll expires:
|
<small>Poll expires:</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<div class="select is-small is-fullwidth">
|
<div class="select is-small is-fullwidth">
|
||||||
|
@ -355,7 +365,7 @@
|
||||||
delimiters: ['[[', ']]'],
|
delimiters: ['[[', ']]'],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
pollVisible: true,
|
pollVisible: pollOptions.length > 0,
|
||||||
answers: [
|
answers: [
|
||||||
{value: ""},
|
{value: ""},
|
||||||
{value: ""},
|
{value: ""},
|
||||||
|
|
|
@ -81,6 +81,10 @@
|
||||||
<sup class="has-text-success fa fa-thumbtack is-size-6 ml-1"></sup>
|
<sup class="has-text-success fa fa-thumbtack is-size-6 ml-1"></sup>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Thread.PollID}}
|
||||||
|
<sup class="has-text-info fa fa-poll is-size-6 ml-1"></sup>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .Thread.Explicit}}
|
{{if .Thread.Explicit}}
|
||||||
<sup class="has-text-danger fa fa-fire is-size-6 ml-1"></sup>
|
<sup class="has-text-danger fa fa-fire is-size-6 ml-1"></sup>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
@ -153,27 +153,71 @@
|
||||||
{{if and (eq $i 0) $Root.Thread.PollID}}
|
{{if and (eq $i 0) $Root.Thread.PollID}}
|
||||||
<h2>Poll</h2>
|
<h2>Poll</h2>
|
||||||
|
|
||||||
|
<!-- Get the results -->
|
||||||
|
{{$Poll := $Root.Thread.Poll}}
|
||||||
|
{{$PollResult := $Poll.Result $Root.CurrentUser}}
|
||||||
|
|
||||||
<form name="ballot" action="/poll/vote" method="POST">
|
<form name="ballot" action="/poll/vote" method="POST">
|
||||||
{{InputCSRF}}
|
{{InputCSRF}}
|
||||||
<input type="hidden" name="poll_id" value="{{$Root.Thread.PollID}}">
|
<input type="hidden" name="poll_id" value="{{$Root.Thread.PollID}}">
|
||||||
<input type="hidden" name="from_thread_id" value="{{$Root.Thread.ID}}">
|
<input type="hidden" name="from_thread_id" value="{{$Root.Thread.ID}}">
|
||||||
|
|
||||||
|
<!-- Poll is open? -->
|
||||||
|
{{if $PollResult.AcceptingVotes}}
|
||||||
|
{{range $Poll.Options}}
|
||||||
|
<div class="control">
|
||||||
|
<label class="{{$Poll.InputType}} box nonshy-fullwidth p-3 mb-3">
|
||||||
|
<input type="{{$Poll.InputType}}"
|
||||||
|
name="answer"
|
||||||
|
value="{{.}}">
|
||||||
|
{{.}}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{range $Root.Thread.Poll.Options}}
|
{{if $Poll.MultipleChoice}}
|
||||||
<div class="control">
|
<div class="mt-2 mb-4">
|
||||||
<label class="radio box nonshy-fullwidth p-3 mb-3">
|
<strong>Multiple choice:</strong> select all the answers you want before casting your vote!
|
||||||
<input type="radio"
|
</div>
|
||||||
name="answer"
|
{{end}}
|
||||||
value="{{.}}">
|
|
||||||
{{.}}
|
<div class="mb-4">
|
||||||
</label>
|
{{if and ($Poll.Expires) (not $Poll.IsExpired)}}
|
||||||
</div>
|
Poll expires in about <span title="{{$Poll.ExpiresAt.Format "2006-01-02 15:04:05"}}">{{SincePrettyCoarse $Root.Thread.Poll.ExpiresAt}}</span>.
|
||||||
|
Vote or wait to see the responses.
|
||||||
|
{{else}}
|
||||||
|
Poll doesn't expire. Vote to see the responses.
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="button is-primary is-outline">
|
||||||
|
Submit response
|
||||||
|
</button>
|
||||||
|
{{else}}
|
||||||
|
{{range $Poll.Options}}
|
||||||
|
<div class="columns mb-0">
|
||||||
|
<div class="column is-one-quarter">
|
||||||
|
{{.}}
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<progress class="{{$PollResult.GetClass .}}" value="{{$PollResult.GetPercent .}}" max="100">{{$PollResult.GetPercent .}}%</progress>
|
||||||
|
</div>
|
||||||
|
<div class="column is-1">
|
||||||
|
{{$PollResult.GetPercent .}}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<em>
|
||||||
|
{{$PollResult.TotalVotes}} vote{{Pluralize $PollResult.TotalVotes}}.
|
||||||
|
{{if $Poll.IsExpired}}
|
||||||
|
Poll ended <span title="{{$Poll.ExpiresAt.Format "2006-01-02 15:04:05"}}">{{SincePrettyCoarse $Root.Thread.Poll.ExpiresAt}} ago</span>.
|
||||||
|
{{end}}
|
||||||
|
</em>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<button type="submit"
|
|
||||||
class="button is-primary is-outline">
|
|
||||||
Submit response
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user