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:
Noah Petherbridge 2022-12-14 22:57:06 -08:00
parent 8e4bb85934
commit bb79b5cbf3
11 changed files with 16366 additions and 12 deletions

View File

@ -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.

View File

@ -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,
} }

View File

@ -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
View 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
View 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
}

View File

@ -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.

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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>

View File

@ -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}}

View File

@ -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}}