diff --git a/pkg/config/config.go b/pkg/config/config.go
index e45fd62..cf32fbb 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -96,11 +96,26 @@ const (
// rapidly it does not increment the view counter more.
ThreadViewDebounceRedisKey = "debounce-view/user=%d/thr=%d"
ThreadViewDebounceCooldown = 1 * time.Hour
+)
+// Poll settings
+var (
// 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
+
+ // Poll color CSS classes (Bulma). Plugged in to templates like:
+ //
+ // 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.
diff --git a/pkg/controller/forum/new_post.go b/pkg/controller/forum/new_post.go
index 6ce99a4..e4d6aa8 100644
--- a/pkg/controller/forum/new_post.go
+++ b/pkg/controller/forum/new_post.go
@@ -47,9 +47,10 @@ func NewPost() http.HandlerFunc {
isOriginalComment bool
// Polls
- pollOptions = []string{}
- pollExpires = 3
- isPoll bool
+ pollOptions = []string{}
+ pollExpires = 3
+ pollMultipleChoice = r.FormValue("poll_multiple_choice") == "true"
+ isPoll bool
// Attached photo object.
commentPhoto *models.CommentPhoto
@@ -179,13 +180,27 @@ func NewPost() http.HandlerFunc {
if r.Method == http.MethodPost {
// Polls: parse form parameters into a neat list of answers.
pollExpires, _ = strconv.Atoi(r.FormValue("poll_expires"))
+ var distinctPollChoices = map[string]interface{}{}
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
+
+ // 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?
if forum.PermitPhotos {
// Removing or replacing?
@@ -375,6 +390,7 @@ func NewPost() http.HandlerFunc {
if isPoll {
log.Info("It's a Poll! Options: %+v", pollOptions)
poll := models.CreatePoll(pollOptions, pollExpires)
+ poll.MultipleChoice = pollMultipleChoice
if err := poll.Save(); err != nil {
session.FlashError(w, r, "Error creating poll: %s", err)
}
diff --git a/pkg/controller/poll/vote.go b/pkg/controller/poll/vote.go
new file mode 100644
index 0000000..f7dffec
--- /dev/null
+++ b/pkg/controller/poll/vote.go
@@ -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)
+ })
+}
diff --git a/pkg/models/forum_recent.go b/pkg/models/forum_recent.go
index a68da23..38febb4 100644
--- a/pkg/models/forum_recent.go
+++ b/pkg/models/forum_recent.go
@@ -35,7 +35,7 @@ func PaginateRecentPosts(user *User, categories []string, pager *Pagination) ([]
// Hide explicit forum if user hasn't opted into it.
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.
diff --git a/pkg/models/models.go b/pkg/models/models.go
index b8cfce7..68647dd 100644
--- a/pkg/models/models.go
+++ b/pkg/models/models.go
@@ -25,4 +25,5 @@ func AutoMigrate() {
DB.AutoMigrate(&Subscription{})
DB.AutoMigrate(&CommentPhoto{})
DB.AutoMigrate(&Poll{})
+ DB.AutoMigrate(&PollVote{})
}
diff --git a/pkg/models/poll.go b/pkg/models/poll.go
index 3730a8d..66eb715 100644
--- a/pkg/models/poll.go
+++ b/pkg/models/poll.go
@@ -1,8 +1,11 @@
package models
import (
+ "fmt"
"strings"
"time"
+
+ "code.nonshy.com/nonshy/website/pkg/config"
)
// Poll table for user surveys posted in the forums.
@@ -27,6 +30,7 @@ type Poll struct {
func CreatePoll(choices []string, expires int) *Poll {
return &Poll{
Choices: strings.Join(choices, "\n"),
+ Expires: expires > 0,
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")
}
+// 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.
func (p *Poll) Save() error {
result := DB.Save(p)
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]
+}
diff --git a/pkg/models/poll_votes.go b/pkg/models/poll_votes.go
index 04d20d9..1a0afa4 100644
--- a/pkg/models/poll_votes.go
+++ b/pkg/models/poll_votes.go
@@ -1,6 +1,11 @@
package models
-import "time"
+import (
+ "errors"
+ "time"
+
+ "code.nonshy.com/nonshy/website/pkg/log"
+)
// PollVote table records answers to polls.
type PollVote struct {
@@ -12,3 +17,52 @@ type PollVote struct {
CreatedAt 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
+}
diff --git a/pkg/router/router.go b/pkg/router/router.go
index 8c9abdd..50998e7 100644
--- a/pkg/router/router.go
+++ b/pkg/router/router.go
@@ -15,6 +15,7 @@ import (
"code.nonshy.com/nonshy/website/pkg/controller/inbox"
"code.nonshy.com/nonshy/website/pkg/controller/index"
"code.nonshy.com/nonshy/website/pkg/controller/photo"
+ "code.nonshy.com/nonshy/website/pkg/controller/poll"
"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/newest", middleware.CertRequired(forum.Newest()))
mux.Handle("/f/", middleware.CertRequired(forum.Forum()))
+ mux.Handle("/poll/vote", middleware.CertRequired(poll.Vote()))
// Admin endpoints.
mux.Handle("/admin", middleware.AdminRequired(admin.Dashboard()))
diff --git a/pkg/utility/time.go b/pkg/utility/time.go
index bbfea7c..556c7df 100644
--- a/pkg/utility/time.go
+++ b/pkg/utility/time.go
@@ -8,6 +8,11 @@ import (
// FormatDurationCoarse returns a pretty printed duration with coarse granularity.
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 {
if v == 1 {
text = strings.TrimSuffix(text, "s")
diff --git a/web/templates/forum/board_index.html b/web/templates/forum/board_index.html
index 4275d55..9cef992 100644
--- a/web/templates/forum/board_index.html
+++ b/web/templates/forum/board_index.html
@@ -105,6 +105,13 @@
{{end}}
+ {{if .PollID}}
+
+
+ Poll
+
+ {{end}}
+
{{if .Explicit}}
diff --git a/web/templates/forum/new_post.html b/web/templates/forum/new_post.html
index 8ba872b..1c268cd 100644
--- a/web/templates/forum/new_post.html
+++ b/web/templates/forum/new_post.html
@@ -81,10 +81,20 @@
- {{if not .EditCommentID}}
+ {{if and (not .EditCommentID) (not .Thread)}}
Poll NEW!
+
+
+
+
+ Allow multiple selections per vote
+
+
+
-
+
- Poll expires:
+ Poll expires:
@@ -355,7 +365,7 @@
delimiters: ['[[', ']]'],
data() {
return {
- pollVisible: true,
+ pollVisible: pollOptions.length > 0,
answers: [
{value: ""},
{value: ""},
diff --git a/web/templates/forum/newest.html b/web/templates/forum/newest.html
index a88021b..fec9ffd 100644
--- a/web/templates/forum/newest.html
+++ b/web/templates/forum/newest.html
@@ -81,6 +81,10 @@
{{end}}
+ {{if .Thread.PollID}}
+
+ {{end}}
+
{{if .Thread.Explicit}}
{{end}}
diff --git a/web/templates/forum/thread.html b/web/templates/forum/thread.html
index 897382f..04e9e7d 100644
--- a/web/templates/forum/thread.html
+++ b/web/templates/forum/thread.html
@@ -153,27 +153,71 @@
{{if and (eq $i 0) $Root.Thread.PollID}}
Poll
+
+ {{$Poll := $Root.Thread.Poll}}
+ {{$PollResult := $Poll.Result $Root.CurrentUser}}
+
{{end}}