From bb79b5cbf3a0cfecc30ad672c1d88cf80235abda Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Wed, 14 Dec 2022 22:57:06 -0800 Subject: [PATCH] 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. --- pkg/config/config.go | 5 + pkg/controller/forum/new_post.go | 33 + pkg/models/models.go | 1 + pkg/models/poll.go | 50 + pkg/models/poll_votes.go | 14 + pkg/models/thread.go | 4 +- pkg/templates/template_funcs.go | 11 + web/static/js/vue-3.2.45.js | 16081 ++++++++++++++++++++++++++++ web/templates/base.html | 3 + web/templates/forum/new_post.html | 128 +- web/templates/forum/thread.html | 48 +- 11 files changed, 16366 insertions(+), 12 deletions(-) create mode 100644 pkg/models/poll.go create mode 100644 pkg/models/poll_votes.go create mode 100644 web/static/js/vue-3.2.45.js diff --git a/pkg/config/config.go b/pkg/config/config.go index a4500c9..e45fd62 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -96,6 +96,11 @@ const ( // rapidly it does not increment the view counter more. ThreadViewDebounceRedisKey = "debounce-view/user=%d/thr=%d" 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. diff --git a/pkg/controller/forum/new_post.go b/pkg/controller/forum/new_post.go index 278cab7..6ce99a4 100644 --- a/pkg/controller/forum/new_post.go +++ b/pkg/controller/forum/new_post.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strconv" + "code.nonshy.com/nonshy/website/pkg/config" "code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/markdown" "code.nonshy.com/nonshy/website/pkg/models" @@ -45,6 +46,11 @@ func NewPost() http.HandlerFunc { // well (pinned, explicit, noreply) isOriginalComment bool + // Polls + pollOptions = []string{} + pollExpires = 3 + isPoll bool + // Attached photo object. commentPhoto *models.CommentPhoto ) @@ -171,6 +177,15 @@ func NewPost() http.HandlerFunc { // Submitting the form. 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? if forum.PermitPhotos { // 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. 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) @@ -381,6 +411,9 @@ func NewPost() http.HandlerFunc { "IsExplicit": isExplicit, "IsNoReply": isNoReply, + // Polls + "PollOptions": pollOptions, + // Attached photo. "CommentPhoto": commentPhoto, } diff --git a/pkg/models/models.go b/pkg/models/models.go index b3c0099..b8cfce7 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -24,4 +24,5 @@ func AutoMigrate() { DB.AutoMigrate(&Notification{}) DB.AutoMigrate(&Subscription{}) DB.AutoMigrate(&CommentPhoto{}) + DB.AutoMigrate(&Poll{}) } diff --git a/pkg/models/poll.go b/pkg/models/poll.go new file mode 100644 index 0000000..3730a8d --- /dev/null +++ b/pkg/models/poll.go @@ -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 +} diff --git a/pkg/models/poll_votes.go b/pkg/models/poll_votes.go new file mode 100644 index 0000000..04d20d9 --- /dev/null +++ b/pkg/models/poll_votes.go @@ -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 +} diff --git a/pkg/models/thread.go b/pkg/models/thread.go index ae20d3e..2446b34 100644 --- a/pkg/models/thread.go +++ b/pkg/models/thread.go @@ -23,6 +23,8 @@ type Thread struct { Title string CommentID uint64 `gorm:"index"` Comment Comment // first comment of the thread + PollID *uint64 `gorm:"poll_id"` + Poll Poll // if the thread has a poll attachment Views uint64 CreatedAt time.Time UpdatedAt time.Time @@ -30,7 +32,7 @@ type Thread struct { // Preload related tables for the forum (classmethod). 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. diff --git a/pkg/templates/template_funcs.go b/pkg/templates/template_funcs.go index 9fad04a..7423b2b 100644 --- a/pkg/templates/template_funcs.go +++ b/pkg/templates/template_funcs.go @@ -1,6 +1,7 @@ package templates import ( + "encoding/json" "fmt" "html/template" "net/http" @@ -28,6 +29,7 @@ func TemplateFuncs(r *http.Request) template.FuncMap { "ComputeAge": utility.Age, "Split": strings.Split, "ToMarkdown": ToMarkdown, + "ToJSON": ToJSON, "PhotoURL": photo.URLPath, "Now": time.Now, "PrettyTitle": func() template.HTML { @@ -77,6 +79,15 @@ func ToMarkdown(input string) template.HTML { 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 // singular and plural cases, or the defaults are "", "s" func Pluralize[V Number](count V, labels ...string) string { diff --git a/web/static/js/vue-3.2.45.js b/web/static/js/vue-3.2.45.js new file mode 100644 index 0000000..2124b65 --- /dev/null +++ b/web/static/js/vue-3.2.45.js @@ -0,0 +1,16081 @@ +var Vue = (function (exports) { + 'use strict'; + + /** + * Make a map and return a function for checking if a key + * is in that map. + * IMPORTANT: all calls of this function must be prefixed with + * \/\*#\_\_PURE\_\_\*\/ + * So that rollup can tree-shake them if necessary. + */ + function makeMap(str, expectsLowerCase) { + const map = Object.create(null); + const list = str.split(','); + for (let i = 0; i < list.length; i++) { + map[list[i]] = true; + } + return expectsLowerCase ? val => !!map[val.toLowerCase()] : val => !!map[val]; + } + + /** + * dev only flag -> name mapping + */ + const PatchFlagNames = { + [1 /* PatchFlags.TEXT */]: `TEXT`, + [2 /* PatchFlags.CLASS */]: `CLASS`, + [4 /* PatchFlags.STYLE */]: `STYLE`, + [8 /* PatchFlags.PROPS */]: `PROPS`, + [16 /* PatchFlags.FULL_PROPS */]: `FULL_PROPS`, + [32 /* PatchFlags.HYDRATE_EVENTS */]: `HYDRATE_EVENTS`, + [64 /* PatchFlags.STABLE_FRAGMENT */]: `STABLE_FRAGMENT`, + [128 /* PatchFlags.KEYED_FRAGMENT */]: `KEYED_FRAGMENT`, + [256 /* PatchFlags.UNKEYED_FRAGMENT */]: `UNKEYED_FRAGMENT`, + [512 /* PatchFlags.NEED_PATCH */]: `NEED_PATCH`, + [1024 /* PatchFlags.DYNAMIC_SLOTS */]: `DYNAMIC_SLOTS`, + [2048 /* PatchFlags.DEV_ROOT_FRAGMENT */]: `DEV_ROOT_FRAGMENT`, + [-1 /* PatchFlags.HOISTED */]: `HOISTED`, + [-2 /* PatchFlags.BAIL */]: `BAIL` + }; + + /** + * Dev only + */ + const slotFlagsText = { + [1 /* SlotFlags.STABLE */]: 'STABLE', + [2 /* SlotFlags.DYNAMIC */]: 'DYNAMIC', + [3 /* SlotFlags.FORWARDED */]: 'FORWARDED' + }; + + const GLOBALS_WHITE_LISTED = 'Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,' + + 'decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,' + + 'Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt'; + const isGloballyWhitelisted = /*#__PURE__*/ makeMap(GLOBALS_WHITE_LISTED); + + const range = 2; + function generateCodeFrame(source, start = 0, end = source.length) { + // Split the content into individual lines but capture the newline sequence + // that separated each line. This is important because the actual sequence is + // needed to properly take into account the full line length for offset + // comparison + let lines = source.split(/(\r?\n)/); + // Separate the lines and newline sequences into separate arrays for easier referencing + const newlineSequences = lines.filter((_, idx) => idx % 2 === 1); + lines = lines.filter((_, idx) => idx % 2 === 0); + let count = 0; + const res = []; + for (let i = 0; i < lines.length; i++) { + count += + lines[i].length + + ((newlineSequences[i] && newlineSequences[i].length) || 0); + if (count >= start) { + for (let j = i - range; j <= i + range || end > count; j++) { + if (j < 0 || j >= lines.length) + continue; + const line = j + 1; + res.push(`${line}${' '.repeat(Math.max(3 - String(line).length, 0))}| ${lines[j]}`); + const lineLength = lines[j].length; + const newLineSeqLength = (newlineSequences[j] && newlineSequences[j].length) || 0; + if (j === i) { + // push underline + const pad = start - (count - (lineLength + newLineSeqLength)); + const length = Math.max(1, end > count ? lineLength - pad : end - start); + res.push(` | ` + ' '.repeat(pad) + '^'.repeat(length)); + } + else if (j > i) { + if (end > count) { + const length = Math.max(Math.min(end - count, lineLength), 1); + res.push(` | ` + '^'.repeat(length)); + } + count += lineLength + newLineSeqLength; + } + } + break; + } + } + return res.join('\n'); + } + + function normalizeStyle(value) { + if (isArray(value)) { + const res = {}; + for (let i = 0; i < value.length; i++) { + const item = value[i]; + const normalized = isString(item) + ? parseStringStyle(item) + : normalizeStyle(item); + if (normalized) { + for (const key in normalized) { + res[key] = normalized[key]; + } + } + } + return res; + } + else if (isString(value)) { + return value; + } + else if (isObject(value)) { + return value; + } + } + const listDelimiterRE = /;(?![^(]*\))/g; + const propertyDelimiterRE = /:([^]+)/; + const styleCommentRE = /\/\*.*?\*\//gs; + function parseStringStyle(cssText) { + const ret = {}; + cssText + .replace(styleCommentRE, '') + .split(listDelimiterRE) + .forEach(item => { + if (item) { + const tmp = item.split(propertyDelimiterRE); + tmp.length > 1 && (ret[tmp[0].trim()] = tmp[1].trim()); + } + }); + return ret; + } + function normalizeClass(value) { + let res = ''; + if (isString(value)) { + res = value; + } + else if (isArray(value)) { + for (let i = 0; i < value.length; i++) { + const normalized = normalizeClass(value[i]); + if (normalized) { + res += normalized + ' '; + } + } + } + else if (isObject(value)) { + for (const name in value) { + if (value[name]) { + res += name + ' '; + } + } + } + return res.trim(); + } + function normalizeProps(props) { + if (!props) + return null; + let { class: klass, style } = props; + if (klass && !isString(klass)) { + props.class = normalizeClass(klass); + } + if (style) { + props.style = normalizeStyle(style); + } + return props; + } + + // These tag configs are shared between compiler-dom and runtime-dom, so they + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element + const HTML_TAGS = 'html,body,base,head,link,meta,style,title,address,article,aside,footer,' + + 'header,h1,h2,h3,h4,h5,h6,nav,section,div,dd,dl,dt,figcaption,' + + 'figure,picture,hr,img,li,main,ol,p,pre,ul,a,b,abbr,bdi,bdo,br,cite,code,' + + 'data,dfn,em,i,kbd,mark,q,rp,rt,ruby,s,samp,small,span,strong,sub,sup,' + + 'time,u,var,wbr,area,audio,map,track,video,embed,object,param,source,' + + 'canvas,script,noscript,del,ins,caption,col,colgroup,table,thead,tbody,td,' + + 'th,tr,button,datalist,fieldset,form,input,label,legend,meter,optgroup,' + + 'option,output,progress,select,textarea,details,dialog,menu,' + + 'summary,template,blockquote,iframe,tfoot'; + // https://developer.mozilla.org/en-US/docs/Web/SVG/Element + const SVG_TAGS = 'svg,animate,animateMotion,animateTransform,circle,clipPath,color-profile,' + + 'defs,desc,discard,ellipse,feBlend,feColorMatrix,feComponentTransfer,' + + 'feComposite,feConvolveMatrix,feDiffuseLighting,feDisplacementMap,' + + 'feDistanceLight,feDropShadow,feFlood,feFuncA,feFuncB,feFuncG,feFuncR,' + + 'feGaussianBlur,feImage,feMerge,feMergeNode,feMorphology,feOffset,' + + 'fePointLight,feSpecularLighting,feSpotLight,feTile,feTurbulence,filter,' + + 'foreignObject,g,hatch,hatchpath,image,line,linearGradient,marker,mask,' + + 'mesh,meshgradient,meshpatch,meshrow,metadata,mpath,path,pattern,' + + 'polygon,polyline,radialGradient,rect,set,solidcolor,stop,switch,symbol,' + + 'text,textPath,title,tspan,unknown,use,view'; + const VOID_TAGS = 'area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr'; + /** + * Compiler only. + * Do NOT use in runtime code paths unless behind `true` flag. + */ + const isHTMLTag = /*#__PURE__*/ makeMap(HTML_TAGS); + /** + * Compiler only. + * Do NOT use in runtime code paths unless behind `true` flag. + */ + const isSVGTag = /*#__PURE__*/ makeMap(SVG_TAGS); + /** + * Compiler only. + * Do NOT use in runtime code paths unless behind `true` flag. + */ + const isVoidTag = /*#__PURE__*/ makeMap(VOID_TAGS); + + /** + * On the client we only need to offer special cases for boolean attributes that + * have different names from their corresponding dom properties: + * - itemscope -> N/A + * - allowfullscreen -> allowFullscreen + * - formnovalidate -> formNoValidate + * - ismap -> isMap + * - nomodule -> noModule + * - novalidate -> noValidate + * - readonly -> readOnly + */ + const specialBooleanAttrs = `itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly`; + const isSpecialBooleanAttr = /*#__PURE__*/ makeMap(specialBooleanAttrs); + /** + * Boolean attributes should be included if the value is truthy or ''. + * e.g. `