Initial events UI - Creation, editing, viewing

This commit is contained in:
Noah 2018-04-29 12:56:37 -07:00
parent a1c84fa1e9
commit 765e80b64d
15 changed files with 817 additions and 1 deletions

View File

@ -11,6 +11,7 @@ import (
"github.com/kirsle/blog/internal/controllers/authctl"
commentctl "github.com/kirsle/blog/internal/controllers/comments"
"github.com/kirsle/blog/internal/controllers/contact"
eventctl "github.com/kirsle/blog/internal/controllers/events"
postctl "github.com/kirsle/blog/internal/controllers/posts"
"github.com/kirsle/blog/internal/controllers/setup"
"github.com/kirsle/blog/internal/log"
@ -25,6 +26,7 @@ import (
"github.com/kirsle/blog/jsondb/caches/null"
"github.com/kirsle/blog/jsondb/caches/redis"
"github.com/kirsle/blog/models/comments"
"github.com/kirsle/blog/models/events"
"github.com/kirsle/blog/models/posts"
"github.com/kirsle/blog/models/settings"
"github.com/kirsle/blog/models/users"
@ -88,6 +90,7 @@ func (b *Blog) Configure() {
posts.DB = b.DB
users.DB = b.DB
comments.DB = b.DB
events.DB = b.DB
// Redis cache?
if config.Redis.Enabled {
@ -120,6 +123,7 @@ func (b *Blog) SetupHTTP() {
contact.Register(r)
postctl.Register(r, b.MustLogin)
commentctl.Register(r)
eventctl.Register(r, b.MustLogin)
// GitHub Flavored Markdown CSS.
r.Handle("/css/gfm.css", http.StripPrefix("/css", http.FileServer(gfmstyle.Assets)))

View File

@ -0,0 +1,60 @@
package events
import (
"html/template"
"net/http"
"strconv"
"github.com/kirsle/blog/internal/markdown"
"github.com/kirsle/blog/internal/render"
"github.com/kirsle/blog/internal/responses"
"github.com/kirsle/blog/models/events"
)
func editHandler(w http.ResponseWriter, r *http.Request) {
v := map[string]interface{}{
"preview": "",
}
var ev *events.Event
// Are we editing an existing event?
if idStr := r.FormValue("id"); idStr != "" {
id, err := strconv.Atoi(idStr)
if err == nil {
ev, err = events.Load(id)
if err != nil {
responses.Flash(w, r, "That event ID was not found")
ev = events.New()
}
}
} else {
ev = events.New()
}
if r.Method == http.MethodPost {
// Parse from form values.
ev.ParseForm(r)
// Previewing, or submitting?
switch r.FormValue("submit") {
case "preview":
v["preview"] = template.HTML(markdown.RenderTrustedMarkdown(ev.Description))
case "save":
if err := ev.Validate(); err != nil {
responses.Flash(w, r, "Error: %s", err.Error())
} else {
err = ev.Save()
if err != nil {
responses.Flash(w, r, "Error: %s", err.Error())
} else {
responses.Flash(w, r, "Event created!")
responses.Redirect(w, "/e/"+ev.Fragment)
}
}
}
}
v["event"] = ev
render.Template(w, r, "events/edit", v)
}

View File

@ -0,0 +1,75 @@
package events
import (
"net/http"
"sort"
"github.com/gorilla/mux"
"github.com/kirsle/blog/internal/log"
"github.com/kirsle/blog/internal/middleware/auth"
"github.com/kirsle/blog/internal/render"
"github.com/kirsle/blog/internal/responses"
"github.com/kirsle/blog/models/events"
"github.com/urfave/negroni"
)
// Register the blog routes to the app.
func Register(r *mux.Router, loginError http.HandlerFunc) {
// Login-required routers.
loginRouter := mux.NewRouter()
loginRouter.HandleFunc("/e/admin/edit", editHandler)
loginRouter.HandleFunc("/e/admin/invite", inviteHandler)
loginRouter.HandleFunc("/e/admin/", indexHandler)
r.PathPrefix("/e/admin").Handler(
negroni.New(
negroni.HandlerFunc(auth.LoginRequired(loginError)),
negroni.Wrap(loginRouter),
),
)
// Public routes
r.HandleFunc("/e/{fragment}", viewHandler)
}
// Admin index to view all events.
func indexHandler(w http.ResponseWriter, r *http.Request) {
result := []*events.Event{}
docs, _ := events.DB.List("events/by-id")
for _, doc := range docs {
ev := &events.Event{}
err := events.DB.Get(doc, &ev)
if err != nil {
log.Error("error reading %s: %s", doc, err)
continue
}
result = append(result, ev)
}
sort.Sort(sort.Reverse(events.ByDate(result)))
render.Template(w, r, "events/index", map[string]interface{}{
"events": result,
})
}
// User handler to view a single event page.
func viewHandler(w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)
fragment, ok := params["fragment"]
if !ok {
responses.NotFound(w, r, "Not Found")
return
}
event, err := events.LoadFragment(fragment)
if err != nil {
responses.FlashAndRedirect(w, r, "/", "Event Not Found")
return
}
v := map[string]interface{}{
"event": event,
}
render.Template(w, r, "events/view", v)
}

View File

@ -0,0 +1,30 @@
package events
import (
"net/http"
"strconv"
"github.com/kirsle/blog/internal/render"
"github.com/kirsle/blog/internal/responses"
"github.com/kirsle/blog/models/events"
)
func inviteHandler(w http.ResponseWriter, r *http.Request) {
v := map[string]interface{}{
"preview": "",
}
id, err := strconv.Atoi(r.FormValue("id"))
if err != nil {
responses.FlashAndRedirect(w, r, "/e/admin/", "Invalid ID")
return
}
event, err := events.Load(id)
if err != nil {
responses.FlashAndRedirect(w, r, "/e/admin/", "Can't load event: %s", err)
return
}
v["event"] = event
render.Template(w, r, "events/invite", v)
}

View File

@ -4,11 +4,22 @@ import (
"html/template"
"strings"
"time"
"github.com/kirsle/blog/internal/markdown"
)
// Funcs is a global funcmap that the blog can hook its internal
// methods onto.
var Funcs = template.FuncMap{
"StringsJoin": strings.Join,
"Now": time.Now,
"NewlinesToSpace": func(text string) string {
return strings.Replace(
strings.Replace(text, "\n", " ", -1),
"\r", "", -1,
)
},
"Now": time.Now,
"TrustedMarkdown": func(text string) template.HTML {
return template.HTML(markdown.RenderTrustedMarkdown(text))
},
}

243
models/events/events.go Normal file
View File

@ -0,0 +1,243 @@
package events
import (
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/kirsle/blog/jsondb"
"github.com/kirsle/golog"
)
// DB is a reference to the parent app's JsonDB object.
var DB *jsondb.DB
var log *golog.Logger
func init() {
log = golog.GetLogger("blog")
}
// Event holds information about events.
type Event struct {
ID int `json:"id"`
Title string `json:"title"`
Fragment string `json:"fragment"`
Description string `json:"description"`
Location string `json:"location"`
CoverPhoto string `json:"coverPhoto"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
AllDay bool `json:"allDay"`
OpenSignup bool `json:"openSignup"`
RSVP []RSVP `json:"rsvp"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
// RSVP tracks invitations and confirmations to events.
type RSVP struct {
// If the user was invited by an admin, they will have a ContactID and
// not much else. Users who signed up themselves from an OpenSignup event
// will have the metadata filled in instead.
ContactID int `json:"contactId"`
Notified bool `json:"notified"`
Name string `json:"name,omitempty"`
Status string `json:"status,omitempty"` // invited, going, maybe, not going
Email string `json:"email,omitempty"`
SMS string `json:"sms,omitempty"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
// New creates a blank event with sensible defaults.
func New() *Event {
return &Event{
StartTime: time.Now().UTC(),
EndTime: time.Now().UTC(),
}
}
// ParseForm populates the event from form values.
func (ev *Event) ParseForm(r *http.Request) {
id, _ := strconv.Atoi(r.FormValue("id"))
ev.ID = id
ev.Title = r.FormValue("title")
ev.Fragment = r.FormValue("fragment")
ev.Description = r.FormValue("description")
ev.Location = r.FormValue("location")
ev.AllDay = r.FormValue("all_day") == "true"
ev.OpenSignup = r.FormValue("open_signup") == "true"
startTime, err := parseDateTime(r, "start_date", "start_time")
ev.StartTime = startTime
if err != nil {
log.Error("startTime parse error: %s", err)
}
endTime, err := parseDateTime(r, "end_date", "end_time")
ev.EndTime = endTime
if err != nil {
log.Error("endTime parse error: %s", err)
}
}
// parseDateTime parses separate date + time fields into a single time.Time.
func parseDateTime(r *http.Request, dateField, timeField string) (time.Time, error) {
dateValue := r.FormValue(dateField)
timeValue := r.FormValue(timeField)
if dateValue != "" && timeValue != "" {
datetime, err := time.Parse("2006-01-02 15:04", dateValue+" "+timeValue)
return datetime, err
} else if dateValue != "" {
datetime, err := time.Parse("2006-01-02", dateValue)
return datetime, err
} else {
return time.Time{}, errors.New("no date/times given")
}
}
// Validate makes sure the required fields are all present.
func (ev *Event) Validate() error {
if ev.Title == "" {
return errors.New("title is required")
} else if ev.Description == "" {
return errors.New("description is required")
}
return nil
}
// Load an event by its ID.
func Load(id int) (*Event, error) {
ev := &Event{}
err := DB.Get(fmt.Sprintf("events/by-id/%d", id), &ev)
return ev, err
}
// LoadFragment loads an event by its URL fragment.
func LoadFragment(fragment string) (*Event, error) {
idx, err := GetIndex()
if err != nil {
return nil, err
}
if id, ok := idx.Fragments[fragment]; ok {
ev, err := Load(id)
return ev, err
}
return nil, errors.New("fragment not found")
}
// Save the event.
func (ev *Event) Save() error {
// Editing an existing event?
if ev.ID == 0 {
ev.ID = nextID()
}
// Generate a URL fragment if needed.
if ev.Fragment == "" {
fragment := strings.ToLower(ev.Title)
fragment = regexp.MustCompile(`[^A-Za-z0-9]+`).ReplaceAllString(fragment, "-")
if strings.Contains(fragment, "--") {
log.Error("Generated event fragment '%s' contains double dashes still!", fragment)
}
ev.Fragment = strings.Trim(fragment, "-")
// If still no fragment, make one based on the post ID.
if ev.Fragment == "" {
ev.Fragment = fmt.Sprintf("event-%d", ev.ID)
}
}
// Make sure the URL fragment is unique!
if len(ev.Fragment) > 0 {
if exist, err := LoadFragment(ev.Fragment); err == nil && exist.ID != ev.ID {
var resolved bool
for i := 1; i <= 100; i++ {
fragment := fmt.Sprintf("%s-%d", ev.Fragment, i)
_, err := LoadFragment(fragment)
if err == nil {
continue
}
ev.Fragment = fragment
resolved = true
break
}
if !resolved {
return fmt.Errorf("failed to generate a unique URL fragment for '%s' after 100 attempts", ev.Fragment)
}
}
}
// Dates & times.
if ev.Created.IsZero() {
ev.Created = time.Now().UTC()
}
if ev.Updated.IsZero() {
ev.Updated = ev.Created
}
// Write the event.
DB.Commit(fmt.Sprintf("events/by-id/%d", ev.ID), ev)
// Update the index cache.
err := UpdateIndex(ev)
if err != nil {
return fmt.Errorf("UpdateIndex() error: %v", err)
}
return nil
}
// Delete an event.
func (ev *Event) Delete() error {
if ev.ID == 0 {
return errors.New("event has no ID")
}
// Delete the DB files.
DB.Delete(fmt.Sprintf("events/by-id/%d", ev.ID))
// Remove it from the index.
idx, err := GetIndex()
if err != nil {
return fmt.Errorf("GetIndex error: %v", err)
}
return idx.Delete(ev)
}
// getNextID gets the next blog post ID.
func nextID() int {
// Highest ID seen so far.
var highest int
events, err := DB.List("events/by-id")
if err != nil {
return 1
}
for _, doc := range events {
fields := strings.Split(doc, "/")
id, err := strconv.Atoi(fields[len(fields)-1])
if err != nil {
continue
}
if id > highest {
highest = id
}
}
// Return the highest +1
return highest + 1
}

60
models/events/index.go Normal file
View File

@ -0,0 +1,60 @@
package events
// Index maps URL fragments to event IDs.
type Index struct {
Fragments map[string]int `json:"fragments"`
}
// GetIndex loads the index DB, or rebuilds it if not found.
func GetIndex() (*Index, error) {
if !DB.Exists("events/index") {
index, err := RebuildIndex()
return index, err
}
idx := &Index{}
err := DB.Get("events/index", &idx)
return idx, err
}
// RebuildIndex builds the event index from scratch.
func RebuildIndex() (*Index, error) {
idx := &Index{
Fragments: map[string]int{},
}
events, _ := DB.List("events/by-id")
for _, doc := range events {
ev := &Event{}
err := DB.Get(doc, &ev)
if err != nil {
return nil, err
}
idx.Update(ev)
}
return idx, nil
}
// UpdateIndex updates the index with an event.
func UpdateIndex(event *Event) error {
idx, err := GetIndex()
if err != nil {
return err
}
return idx.Update(event)
}
// Update an event in the index.
func (idx *Index) Update(event *Event) error {
idx.Fragments[event.Fragment] = event.ID
return DB.Commit("events/index", idx)
}
// Delete an event from the index.
func (idx *Index) Delete(event *Event) error {
delete(idx.Fragments, event.Fragment)
return DB.Commit("events/index", idx)
}

10
models/events/sorting.go Normal file
View File

@ -0,0 +1,10 @@
package events
// ByDate sorts events by their start time.
type ByDate []*Event
func (a ByDate) Len() int { return len(a) }
func (a ByDate) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByDate) Less(i, j int) bool {
return a[i].StartTime.Before(a[i].StartTime) || a[i].ID < a[j].ID
}

View File

@ -70,6 +70,7 @@ func (idx *Index) Update(p *Post) error {
// Delete a blog's entry from the index.
func (idx *Index) Delete(p *Post) error {
delete(idx.Posts, p.ID)
delete(idx.Fragments, p.Fragment)
return DB.Commit("blog/index", idx)
}

View File

@ -4,6 +4,7 @@
<ul>
<li><a href="/admin/settings">App Settings</a></li>
<li><a href="/e/admin/">Events</a></li>
<li><a href="/blog/edit">Post Blog Entry</a></li>
<li><a href="/admin/editor">Page Editor</a></li>
<li><a href="/admin/users">User Management</a></li>

View File

@ -29,3 +29,9 @@ a.blog-title {
font-size: smaller;
margin-bottom: 1rem;
}
/* Address formatting */
address {
white-space: pre-line;
font-style: italic;
}

138
root/events/edit.gohtml Normal file
View File

@ -0,0 +1,138 @@
{{ define "title" }}Edit Event{{ end }}
{{ define "content" }}
<form action="/e/admin/edit" method="POST">
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
{{ if .Data.preview }}
<div class="card mb-5">
<div class="card-header">
Preview
</div>
<div class="card-body">
<h1>{{ .Data.event.Title }}</h1>
{{ if .Data.event.Location }}
<address>{{ .Data.event.Location }}</address>
{{ end }}
{{ .Data.preview }}
</div>
</div>
{{ end }}
{{ with .Data.event }}
<input type="hidden" name="id" value="{{ or .ID "" }}">
<div class="card">
<div class="card-body">
<h1>Edit Event</h1>
<div class="form-row">
<div class="form-group col-12">
<label for="title">Event Title:</label>
<input type="text"
name="title"
id="title"
class="form-control"
value="{{ .Title }}"
placeholder="Event Title Goes Here">
</div>
<div class="form-group col-md-6">
<label for="start_date">Start Time:</label>
<div class="form-row">
<div class="col">
<input type="date"
name="start_date"
id="start_date"
class="form-control"
value="{{ .StartTime.Format "2006-01-02" }}"
placeholder="YYYY-MM-DD">
</div>
<div class="col">
<input type="time"
name="start_time"
id="start_time"
class="form-control"
value="{{ .StartTime.Format "15:04" }}"
placeholder="HH:MM">
</div>
</div>
</div>
<div class="form-group col-md-6">
<label for="end_date">End Time:</label>
<label class="ml-4">
<input type="checkbox"
name="all_day"
value="true">
All day
</label>
<div class="form-row">
<div class="col">
<input type="date"
name="end_date"
id="end_date"
class="form-control"
value="{{ .EndTime.Format "2006-01-02" }}"
placeholder="YYYY-MM-DD">
</div>
<div class="col">
<input type="time"
name="end_time"
id="end_time"
class="form-control"
value="{{ .EndTime.Format "15:04" }}"
placeholder="HH:MM">
</div>
</div>
</div>
<div class="form-group col-12">
<label for="location">Location:</label>
<textarea
name="location"
id="location"
class="form-control"
cols="80"
rows="3"
placeholder="123 Nowhere Drive">{{ .Location }}</textarea>
</div>
<div class="form-group col-12">
<label for="description">Description (<a href="/markdown" target="_blank">Markdown</a> supported):</label>
<textarea
name="description"
id="description"
class="form-control text-monospace"
cols="80"
rows="12"
placeholder="Come to my awesome event!">{{ .Description }}</textarea>
</div>
<div class="form-group col-12">
<label for="fragment">Custom URL fragment (<abbr title="a-z 0-9 - . _">URL-safe characters only</abbr>):</label>
<input type="text"
name="fragment"
id="fragment"
class="form-control"
pattern="[A-Za-z0-9\-_.]*"
value="{{ .Fragment }}"
placeholder="example: spring-break-2032">
</div>
<div class="col-12">
<button type="submit"
name="submit"
value="preview"
class="btn btn-primary">Preview</button>
<button type="submit"
name="submit"
value="save"
class="btn btn-success">Save</button>
</div>
</div>
</div>
</div>
{{ end }}
</form>
{{ end }}

18
root/events/index.gohtml Normal file
View File

@ -0,0 +1,18 @@
{{ define "title" }}Events{{ end }}
{{ define "content" }}
<h1>Events</h1>
<p>
<a href="/e/admin/edit" class="btn btn-success">New Event</a>
</p>
<ul>
{{ range .Data.events }}
<li>
<a href="/e/{{ .Fragment }}">{{ .Title }}</a> {{ .StartTime.Format "Jan 1 2006 @ 3:04:05 PM" }}
</li>
{{ end }}
</ul>
{{ end }}

108
root/events/invite.gohtml Normal file
View File

@ -0,0 +1,108 @@
{{ define "title" }}Invite People to {{ .Data.event.Title }}{{ end }}
{{ define "content" }}
<form action="/e/admin/edit" method="POST">
<input type="hidden" name="_csrf" value="{{ .CSRF }}">
{{ $e := .Data.event }}
<h1>Invite <em>{{ $e.Title }}</em></h1>
<div class="card mb-4">
<div class="card-header">Contact List</div>
<div class="card-body">
<div class="row" style="max-height: 500px; overflow: auto">
<div class="col-6">
<h4>Invited</h4>
<ul class="list-unstyled">
<li>
<strong>John Doe</strong><br>
<span class="text-muted">name@example.com</span>
</li>
<li>
<strong>John Doe</strong><br>
<span class="text-muted">name@example.com</span>
</li>
</ul>
</div>
<div class="col-6">
<h4>Available</h4>
<ul class="list-unstyled">
<li>
<label class="d-block alert alert-info">
<input type="checkbox" name="invite" value="1">
<strong>John Doe</strong><br>
<span class="text-muted">name@example.com</span>
</label>
</li>
<li>
<label class="d-block alert alert-info">
<input type="checkbox" name="invite" value="1">
<strong>John Doe</strong><br>
<span class="text-muted">name@example.com</span>
</label>
</li>
</ul>
<button type="submit"
name="action" value="send-invite"
class="btn btn-primary">Send Invites</button>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">Invite New People</div>
<div class="card-body">
<div class="form-row">
<div class="form-group col-md-6">
<label for="first_name">First name:</label>
<input type="text"
name="first_name"
id="first_name"
class="form-control"
placeholder="First name">
</div>
<div class="form-group col-md-6">
<label for="last_name">Last name:</label>
<input type="text"
name="last_name"
id="last_name"
class="form-control"
placeholder="Last name">
</div>
</div>
<div class="form-row">
<div class="form-group col-md-6">
<label for="email">E-mail:</label>
<input type="email"
name="email"
id="email"
class="form-control"
placeholder="name@example.com">
</div>
<div class="form-group col-md-6">
<label for="last_name">SMS Number:</label>
<input type="text"
name="sms"
id="sms"
class="form-control"
placeholder="800-555-1234">
</div>
</div>
<div class="form-row">
<button type="button"
class="btn btn-primary">Send Invite</button>
</div>
</div>
</div>
<h2>Invited</h2>
To Do
</form>
{{ end }}

51
root/events/view.gohtml Normal file
View File

@ -0,0 +1,51 @@
{{ define "title" }}{{ .Data.event.Title }}{{ end }}
{{ define "content" }}
{{ with .Data.event }}
<h1>{{ .Title }}</h1>
<div class="row mb-4">
<div class="col-12 col-md-8">
{{ TrustedMarkdown .Description }}
</div>
<div class="col-12 col-md-4">
{{ if .Location }}
<h4>Location</h4>
<address class="mb-4"><a href="https://maps.google.com/?q={{ NewlinesToSpace .Location }}" target="_blank">{{ .Location }}</a></address>
{{ end }}
<h4>Time</h4>
<abbr title="{{ .StartTime.Format "Mon Jan 2 15:04:05 2006" }}">
{{ .StartTime.Format "January 2 @ 3:04 PM" }}
</abbr>
{{ if not .EndTime.IsZero }}
to<br>
<abbr title="{{ .EndTime.Format "Mon Jan 2 15:04:05 2006" }}">
{{ if .AllDay }}
{{ .EndTime.Format "January 2" }}
{{ else }}
{{ .EndTime.Format "January 2 @ 3:04 PM" }}
{{ end }}
</abbr>
{{ end }}
<h4 class="mt-4">Invited</h4>
</div>
</div>
{{ end }}
{{ if and .LoggedIn .CurrentUser.Admin }}
<div class="alert alert-danger">
<a href="/e/admin/edit?id={{ .Data.event.ID }}"
class="btn btn-primary">edit event</a>
<a href="/e/admin/invite?id={{ .Data.event.ID }}"
class="btn btn-success">invite people</a>
</div>
{{ end }}
<h2 id="comments" class="mt-4">Comments</h2>
{{ $idStr := printf "%d" .Data.event.ID }}
{{ RenderComments .Request .Data.event.Title "event" $idStr }}
{{ end }}